できる!Djangoでテスト!

tell-k
DjangoCongress JP 2018 (2018.05.19)

おまえ誰よ?

https://pbs.twimg.com/profile_images/1045138776224231425/3GD8eWeG_200x200.jpg

BePROUD - Pythonメインの受託開発

connpass - エンジニアをつなぐIT勉強会支援プラットフォーム

PyQ - Pythonオンライン学習サービス

https://dl.dropboxusercontent.com/spa/ghyn87yb4ejn5yy/e9121e88c3b64179993a02198a7514f9.png

https://pyq.jp/ ★ Djangoを使ったWeb開発も学習できます! ★

目的/動機

対象

今日の目標

https://dl.dropboxusercontent.com/spa/ghyn87yb4ejn5yy/40dbf595606e4879961ef4a13e5cea84.png

主な参考文献

前提

テストの種類

ユニットテストに期待すること

Developer Testing

目次

テスト設置場所

テスト設置場所

sample
   ├── __init__.py
   ├── admin.py
   ├── apps.py
   ├── forms.py
   ├── models.py
   ├── utils.py
   ├── urls.py
   ├── views.py
   └── tests
      ├── __init__.py
      ├── test_admin.py
      ├── test_forms.py
      ├── test_models.py
      ├── test_utils.py
      └── test_views.py

テストケースを書く

単純な関数をテストしたい

# sample/utils.py ----

from datetime import date

def diff_days(from_date, to_date):
    """
    - from_date から to_date までの日数を返す。
    - from_date が to_date 以降であれば None を返す。
    """
    if from_date >= to_date:
        return None
    return (to_date - from_date).days

# Usage --
date1 = date(2018, 1, 1)
date2 = date(2018, 1, 6)

print(diff_days(date1, date2)) # => 5
print(diff_days(date2, date1)) # => None

テストケースを書く

# sample/tests/test_utils.py ----

import unittest
from datetime import date

class TestDiffDays(unittest.TestCase):

    def _callFUT(self, from_date, to_date):
        from spam import diff_days
        return diff_days(from_date, to_date)

    def test_from_date_is_before(self):
        """ from_date が to_date より古い日付 """
        actual = self._callFUT(date(2018, 1, 1), date(2018, 1, 6))
        self.assertEqual(5, actual)

    def test_from_date_is_after(self):
        """ from_date が to_date と同じか新しい日付 """
        actual = self._callFUT(date(2018, 1, 6), date(2018, 1, 1))
        self.assertIsNone(actual)

Pylons 単体テストガイドライン

テスト対象のモジュールをテストモジュールのスコープでインポートしない

# FUT = Function Under the Test = テスト対象の関数
def _callFUT(self, from_date, to_date):
    from spam import diff_days
    return diff_days(from_date, to_date)
# Bad --

from spam import diff_days  # ImportErrorが発生する

class TestDiffDays(unittest.TestCase):

    def test_from_date_is_before(self):
        # 〜 省略 〜

class TestOther(unittest.TestCase):  # <- X 関係ないテストも実行されない

各テストケースメソッドは、 1つのことだけをテストする

# Bad --

def test_all_test_cases(self):

    # from_date < to_date
    actual = self._callFUT(date(2018, 1, 1), date(2018, 1, 6))
    self.assertEqual(5, actual)

    # from_date >= to_date
    actual = self._callFUT(date(2018, 1, 6), date(2018, 1, 1))
    self.assertIsNone(actual)

同値分割/境界値分析

同値分割/境界値分析

def test_from_date_is_before(self):
    """ from_date が to_date より古い日付 """
    actual = self._callFUT(date(2018, 1, 1), date(2018, 1, 6))
    self.assertEqual(5, actual)

    # 1日前だったらという境界値
    actual = self._callFUT(date(2018, 1, 1), date(2018, 1, 2))
    self.assertEqual(1, actual)

def test_from_date_is_after(self):
    """ from_date が to_date と同じか新しい日付 """

    actual = self._callFUT(date(2018, 1, 6), date(2018, 1, 1))
    self.assertIsNone(actual)

    # 同日だったらという境界値
    actual = self._callFUT(date(2018, 1, 1), date(2018, 1, 1))
    self.assertIsNone(actual)

Assertion Roulette

このような場合

# Bad --

def test_say_hello(self):
    self.asserEqual(say_hello('tell-k'),   'hello tell-k')   # 1. 失敗
    self.asserEqual(say_hello('hirokiky'), 'hello hirokiky') # 2. 以後のアサーションは無視
    self.asserEqual(say_hello('django'),   'hello django')
    self.asserEqual(say_hello('bucho'),    'hello bucho')
    self.asserEqual(say_hello('james'),    'hello james')
    self.asserEqual(say_hello('nakagami'), 'hello nakagami')
    self.asserEqual(say_hello('crohaco'),  'hello crohaco')

Parameterized Test がおすすめ

# Good --

def test_say_hello(self):
    names = ['tell-k', 'james', 'django', 'bucho', ...]
    for name in names:
       with self.subTest(name=name, expected='hello %s' % name):
           self.assertEqual(say_hello(name), expected)  # <- テストが失敗しても次のサブテストは実行される

Django に 依存するテストケース

https://docs.djangoproject.com/en/2.0/_images/django_unittest_classes_hierarchy.svg

django.test.TestCase

from django.test import TestCase

from sample.models import Item

class TestSample(TestCase):

    def test_one(self):
        Item.objects.create(name='name1')
        self.assertEual(1, len(Item.objects.all()))

    # テストケースが終わるとDBの中身はクリア(rollbackされる)
    def test_two(self):
        Item.objects.create(name='name1')
        self.assertEual(1, len(Item.objects.all()))

テストを実行する

$ python manage.py test

# テストt用に設定ファイルを用意して
$ python manage.py test --settings sample.settings_test
$ python  manage.py test
Creating test database for alias 'default'...  # <- テスト用DB生成
System check identified no issues (0 silenced).
.....................................................................
.....................................................................
----------------------------------------------------------------------
Ran 279 tests in 15.139s

OK (skipped=0)
Destroying test database for alias 'default'... # <- テスト用DB破棄

pytest を 使いたい人に注意点

どこまでユニットテストの対象にすべきか?

フィクスチャー

フィクスチャー

フィクスチャー

from django.test import TestCase

class TestDoSomething(TestCase):

    def _callFUT(self, data):
        from sample.api import compose_data
        return do_something(data)

    def setUp(self):
        # フィクスチャーの生成
        self.good_data = make_fixture_data(good=True)
        self.bad_data = make_fixture_data(bad=True)

    def tearDown(self):
        # フィクスチャーの破棄
        del self.good_data
        del self.bad_data

    def test_do_something_ok(self):
        self.assertTrue(self._callFUT(self.good_data))

    def test_do_something_ng(self):
        self.assertFalse(self._callFUT(self.bad_data))

なるべくセットアップを共有しない

  • 無駄に生成している

なるべくセットアップを共有しない

# Good --

from django.test import TestCase

class TestDoSomething(TestCase):

    def _callFUT(self, data):
        from sample.api import compose_data
        return do_something(data)

    def test_do_something_ok(self):
        good_data = make_fixture_data(good=True)
        self.assertTrue(self._callFUT(good_data))

    def test_do_something_ng(self):
        bad_data = make_fixture_data(bad=True)
        self.assertFalse(self._callFUT(bad_dataa))

Djangoモデルのフィクスチャー

だけど..

from django.test import TestCase
from myapp.models import Animal

class AnimalTestCase(TestCase):
   fixtures = ['animals.json']

factory_boy

factory_boy

# sample/tests/factories.py
import factory

from account.tests.factories import UserFactory

class ItemFactory(factory.django.DjangoModelFactory):
    name = factory.Sequence(lambda n: 'name{}'.format(n))
    email = factory.Sequence(lambda n: 'hoge{}@example.com'.format(n))
    price = 100
    owner = factory.SubFactory(UserFactory)

    class Meta:
        model = "sample.Item"
item = ItemFactory()
print(item.name) # => name0
print(item.user) # => User object

# フィールドの値も指定できる
ItemFactory(name='newitem')

# 一気に複数オブジェクトを生成することもできる
ItemFactory.create_batch(10)

factroy_boy のハマりポイント

# テスト対象

def get_display_price(item):
    return "{}円".format(item.price)
# Bad --

def test_display_price(self):
    item = ItemFactory()  # <- ItemFactory.price 100から変更されたらテスト失敗
    expected = '100円'
    self.assertEqual(expected, self._callFUT(item))

factroy_boy のハマりポイント

# Good --

def test_display_price(self):
    item = ItemFactory(price=100) # <- 100固定
    expected = '100円'
    self.assertEqual(expected, self._callFUT(item))
# Good --

def test_display_price(self):
    item = ItemFactory()
    expected = '{}円'.format(item.price)  # <- item.price を使って期待値を生成
    self.assertEqual(expected, self._callFUT(item))

モック

モック

Test Double

http://xunitpatterns.com/Types%20Of%20Test%20Doubles.gif

Test Double



unittest.mock

# sample/api.py ---
from item.api import calc_tax_included_price

# テスト対象
def get_display_price(item):
    price = calc_tax_included_price(item)  # <- これをモック(Test Stub)に置き換える
    return "{}円".format(price)
def test_display_price(self):
    item = ItemFactory()

    # patch を通して 108 という間接入力値 をテスト対象(get_display_price) に渡してる
    with mock.patch('sample.api.calc_tax_included_price', return_value=108) as m:
         expected = '108円'

         self.assertEqual(expected, self._callFUT(item))  # => OK

         # calc_tax_included_price に item引数が渡ったかチェック
         m.assert_called_with(item)

mock.patch の ハマりポイント

# egg.py  ---
import spam

def say_egg():
    return spam.say_spam() # <- patch対象
from unittest import mock
from egg import say_egg

with mock.patch('spam.say_spam', return_value="Patched!"):
    print(say_egg()) # => Patched!

mock.patch の ハマりポイント

# egg.py  ---
- import spam
+ from spam import say_spam

def echo():
-   return spam.say_spam()
+   return say_spam()
# Good

- with mock.patch('spam.say_spam', return_value="Patched!"):
+ with mock.patch('egg.say_spam', return_value="Patched!"):
     print(say_egg()) # => Patched!

モック その他

モック その他

可能な限り簡潔に

コードカバレッジ

コードカバレッジ

  • テストが意図した通りにパスしてるか
  • テストが書かれてない場所がないか確認
  • 不要なコードをないか(使われてない, 到達不能なコード)

コードカバレッジの分類

カバレッジの計測ツール

カバレッジ

$ coverage run --omit 'manage.py','*/migrations/*' python manage.py test
$ coverage report -m
Name                          Stmts   Miss  Cover   Missing
-----------------------------------------------------------
account/__init__.py               1      0   100%
account/admin.py                145     22    85%   19, 24-26, 59, 63-64, 100, 104-105, 137, 141-142, 173, 177-178, 209, 213-214, 247, 251-252
account/apps.py                   9      0   100%
account/forms.py                 29      0   100%
account/models.py               239      0   100%
account/views.py                150      5    97%   59, 101, 199

〜 省略 〜
-----------------------------------------------------------
TOTAL                         28606   1240    96%

設定ファイル

[run]
omit = */migrations/*,manage.py

対象を絞ってテストする

  • テストがない ところがわかる
  • 素早くテストも終わる
# 特定のモジューブ
$ python manage.py test spam.tests.test_models

# 特定のテストクラス
$ python manage.py test spam.tests.test_models.TestSpamClass

# 特定のテストケース
$ python manage.py test spam.tests.test_models.TestSpamClass.test_sham

システム全体での カバレッジ 100% に固執しない

先人たちのお言葉

雑多なネタ

viewのテストどうしてる?

viewのテストどうしてる?

class TestItemDetailView(TestCase):

    def _getTarget(self, pk):
        return reverse('item:detail', kwargs={'pk': pk})

    def test_not_found(self):
        res = self.client.get(self._getTarget(1))
        self.assertEqual(404, res.status_code)

    def test_display_item(self):
        item = ItemFactory()

        res = self.client.get(self._getTarget(item.id))

        self.assertEqual(item.id, res.context['item'].id)
        self.assertEqual(200, res.status_code)
        self.assertTemplateUsed(res, 'item/detail.html')

Eメール送信のテストは

from django.core import mail
from django.test import TestCase

class TestEmail(TestCase):

    def test_send_email(self):

        mail.send_mail('subject',
                   'body.',
                   'from@example.com',
                   ['to@example.com'], fail_silently=False)

        self.assertEqual(len(mail.outbox), 1)
        self.assertEqual(mail.outbox[0].subject, 'subject')

Django コマンドのテスト

class TestSpamCommand(TestCase):

    def _callCommand(self, *args, **kwargs):
        from django.core.management import call_command
        return call_command('spam', *args, **kwargs)

    def test_spam_command(self):
        ...

ログ/標準出力の確認

>>> import logging
>>> from testfixtures import LogCapture
>>> with LogCapture() as l:
...     logger = logging.getLogger()
...     logger.info('a message')
...     logger.error('an error')
...
>>> l.check(
...     ('root', 'INFO', 'a message'),
...     ('root', 'ERROR', 'an error'),
... )

テスト用に一時的にsettingsの中身を変更したい

class LoginTestCase(TestCase):

    @override_settings(LOGIN_URL='/other/login/')
    def test_login(self):
        response = self.client.get('/sekrit/')
        self.assertRedirects(response, '/other/login/?next=/sekrit/')
class MiddlewareTestCase(TestCase):

    @modify_settings(MIDDLEWARE={
        'append': 'django.middleware.cache.FetchFromCacheMiddleware',
        'prepend': 'django.middleware.cache.UpdateCacheMiddleware',
    })
    def test_cache_middleware(self):
        response = self.client.get('/')

ジョブキュー(Celery)のユニットテスト

どうすれば良いのか?

tox

tox の 設定

[tox]
envlist = py36, flake8
skipsdist = true

[testenv]
deps = -r{toxinidir}/dev-requires.txt

setenv =
  DJANGO_SETTINGS_MODULE = settings.test

changedir = {toxinidir}/src
commands = coverage run --omit "manage.py","*/migrations/\*" python manage.py test {posargs}

[testenv:flake8]
deps =
  flake8
  mccabe

commands = flake8 .

[flake8]
exclude = tests/\*, \*/migrations/\*, urls.py, manage.py
max-line-length = 100

tox の 実行

$ tox # 全ての testenv が実行される
$ tox -e flake8 # flake8のtestenvだけ実行される

テストの高速化

トピック


まとめ

Pythonプロフェショナルプログラミング 第3版

来月くらいに第3版がでます!よろしくお願いします!(予定)

https://images-na.ssl-images-amazon.com/images/I/41jP7BdvluL._SX385_BO1,204,203,200_.jpg

Kent Beck のお言葉

参考

ご静聴ありがとうございました

Use the left and right arrow keys or click the left and right edges of the page to navigate between slides.
(Press 'H' or navigate to hide this message.)