115
94

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Pythonその2Advent Calendar 2020

Day 6

pytest で単体テストの方法まとめ

Last updated at Posted at 2020-12-06

概要

Python には pytest という単体テストを書く機能があり、便利なのですが、他の言語と若干仕様が異なるので、よく使う機能を備忘録としてまとめておきます。

テストの書き方・実行方法

CreateXxx というクラスが print_aaa というメソッドを持っている場合のテストケース。

tests/test_create_xxx.py
import pytest
from xxx import CreateXxx, XxxError

class TestCreateXxx:
  # 通常の評価
  def test__can_print_aaa(self):
    xxx = CreateXxx()
    assert xxx.print_aaa(111) == 'aaa'

  # エラーがでることを評価
  def test__can_raise_error(self):
    xxx = CreateXxx()
    with pytest.raises(XxxError):
      xxx.print_aaa(222)

このようなファイルを用意し、

sh実行
$ pytest tests/test_create_xxx.py

と実行することで実行。

いわゆる setup 的なもの

tests/test_create_xxx.py
  @pytest.fixture
  def init_xxx(self):
     self.xxx = CreateXxx()

  def test__can_print_aaa(self, init_xxx):
    assert self.xxx.print_aaa() == 'aaa'
  • fixture という機能を使って setup を実現します。
  • 引数に fixture として定義したメソッド名を書くことでテスト開始直前に実行されることになります。

いわゆる teardown 的なもの

tests/test_create_xxx.py
  @pytest.fixture
  def init_xxx(self):
     self.xxx = CreateXxx()
     yield
     # ここから teardown
     self.xxx.close()

  def test__can_print_aaa(self, init_xxx):
    assert self.xxx.print_aaa() == 'aaa'
  • fixture の中で yield を呼ぶと、その段階でいったん実行が中断され、テストメソッド本体が実行され、その後、yield 以降の処理が実行されることになります。つまり teardown 相当の処理となります。
  • 変数をローカルに閉じ込められるので可読性を高められます。
  • 戻り値を戻したいときは yield の後ろに戻り値を書きます。
  • なお、yield は1回しか使えません。複数書くと怒られます。

戻り値を返す、いわゆる fixture

tests/test_create_xxx.py
  @pytest.fixture
  def xxx(self):
     return CreateXxx()

  def test__can_print_aaa(self, xxx):
    assert xxx.print_aaa() == 'aaa'

戻り値を複数返す fixture

tests/test_create_xxx.py
  @pytest.fixture
  def init_xxx(self):
     return CreateXxx(), 'aaa'

  def test__can_print_aaa(self, init_xxx):
    xxx, expected = init_xxx
    assert xxx.print_aaa() == expected

データジェネレート

同じテストケースを引数違いで何度も実行したい場合

@pytest.mark.parametrize を使う方法 (テストケースごとに定義)

シンプル。fixture ではないので、共有はできないし、複雑な処理もできないが、とにかくシンプルにかける。

tests/test_create_xxx.py
  @pytest.mark.parametrize('val, expected', [
    ('aa', 'bb'),
    ('xx', 'yy'),
  ])
  def test__can_print_aaa(self, val, expected):
    assert xxx.print_aaa(val) == expected

parametrize のテストケースタイトルが文字化けしないようにする方法

全角文字がエスケープされないように

parametrize のテストケースタイトルは扱う値の組み合わせで生成されるのだが、値が全角文字だと \u306f\u~ のようにエスケープされてどのケースなのか判別できなくなる。
pytest.ini というファイルをルートディレクトリに置き、下記を設定することでエスケープされないようにできる:

[pytest]
disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True
オブジェクトの内容が表示されるように

parametrize のテストケースタイトルは扱う値の組み合わせで生成されるのだが、デフォルトだとオブジェクトは hoge_obj1 のようにオブジェクト名+連番でタイトルに採用されてしまい、内容が全く分からない。
ids を指定することでカスタマイズが可能:

方法1) ids=strを使う方法
@pytest.mark.parametrize('aa, bb', [
  (HogeObj(), FugaObj()),
  ...
], ids=str)
# これですべての値は str() されることになる。str の代わりに repr を指定しても良い
方法2) idsで自作関数を指定することも可能
def my_ids(v):
  if type(v) is HogeObj:
    return f'{v.aaa}'
  if type(v) is FugaObj:
    return f'{v.bbb}'
  return v

@pytest.mark.parametrize('aa, bb', [
  (HogeObj(), FugaObj()),
  ...
], ids=my_ids)

fixture を使う方法 (複数のテストケースで共有可能)

tests/test_create_xxx.py
  @pytest.fixture(params=[
    ('aa', 'bb'),
    ('xx', 'yy'),
  ])
  def case__printable(self, request) -> Tuple[str, str]:
    return request.param

  def test__can_print_aaa(self, case__printable):
    val, expected = case__printable
    assert xxx.print_aaa(val) == expected

ファイルをまたいで共通化したい場合: conftest.py

conftest.py に書いたものはファイル間で共有される。

tests/conftest.py
@pytest.fixture
def xxx():
   return CreateXxx()
tests/test_create_xxx.py
  def test__can_print_aaa(self, xxx):
    assert xxx.print_aaa() == expected

fixture の実行タイミングを明示する

デフォルトは(引数で fixture 名を指定している)テストケースメソッドの開始直前。
これを変更できる。下記のように明示する。

  @pytest.fixture(scope='class')
スコープ名 実行されるタイミング
function テストケースごとに1回実行(デフォルト)
class テストクラス全体で1回実行
module テストファイル全体で1回実行
session テスト全体で1回だけ実行

モック

たとえば、下記のような場合にモックしたくなる。

  • AWSなど外部と繋ぐ部分(単体Testでは繋げたくない)
  • 現在時、ランダム値を使う部分(毎回変わるのでテストしづらい)
  • 外部の責務(=別の単体テストですでにテストしている)部分(テストのメンテで別の責務に引きずられたくない)

import される関数の中身を一時的に差し替える

  • テストケース終了時に元に戻る。

mocker.patch.object で何もしないようにする

tests/test_create_xxx.py
from pytest_mock import MockFixture
import hoge

class TestCreateXxx:
  def test__can_print_aaa(self, mocker: MockFixture, xxx):
    mocker.patch.object(hoge, 'fuga')  # hoge の fuga という関数を何もしないモックに差し替える
    assert xxx.print_aaa() == 'aaa'

mocker.patch.object で戻り値を定義する

tests/test_create_xxx.py
from pytest_mock import MockFixture
import hoge

class TestCreateXxx:
  def test__can_print_aaa(self, mocker: MockFixture, xxx):
    mocker.patch.object(hoge, 'get_fuga', return_value='fuga')
    assert xxx.print_aaa() == 'aaa'

mocker.patch.object で例外が発生するようにする

tests/test_create_xxx.py
from pytest_mock import MockFixture
import hoge

class TestCreateXxx:
  def test__can_print_aaa(self, mocker: MockFixture, xxx):
    mocker.patch.object(hoge, 'get_fuga', side_effect=hoge.PiyoError('test'))
    with pytest.raises(hoge.PiyoError):
      xxx.print_aaa()

mocker.patch.object で関数に渡された引数をチェックする

tests/test_create_xxx.py
from unittest.mock import call
import hoge

class TestCreateXxx:
  def test__can_print_aaa(self, mocker: MockFixture, xxx):
    m_get_fuga = mocker.patch.object(hoge, 'get_fuga', return_value='fuga')
    xxx.print_aaa()
    assert m_get_fuga.assert_has_calls([
        call(1, 'a'), # 初回呼び出しの第1引数が 1, 第2引数が 'a'
    ])

mocker.patch.object で呼び出しごとに戻り値を変える

tests/test_create_xxx.py
from unittest.mock import call
import hoge

class TestCreateXxx:
  def test__can_print_aaa(self, mocker: MockFixture, xxx):
    # side_effect で配列を渡すと呼び出しごとに順に返すようになる
    m_get_fuga = mocker.patch.object(hoge, 'get_fuga', side_effect=[
        'fuga1',
        'fuga2',
    )
    xxx.print_aaa()  # fuga1 が使われる
    xxx.print_aaa()  # fuga2 が使われる

mocker.patch.object でクラスをモック(Newした際にモックが返るように)

tests/test_create_xxx.py
from unittest.mock import call
import hoge

class TestCreateXxx:
  def test__can_print_aaa(self, mocker: MockFixture):
    # 注意: return_value 必須
    mocker.patch.object(hoge.Hoge, '__init__', return_value=None)
    # 注意: 一見、static メソッドを差し替えてる風に見えるがちゃんと動く
    mocker.patch.object(hoge.Hoge, 'get_fuga', return_value='fuga')

    xxx.print_aaa()
    assert xxx.print_aaa() == 'aaa'

環境変数を一時的に差し替える

  • テストケース終了時に元に戻る。

monkeypatch.setenv で差し替える

tests/test_create_xxx.py
import hoge

class TestCreateXxx:
  def test__can_print_aaa(self, monkeypatch, xxx):
    monkeypatch.setenv('HOGE', '1234')
    assert xxx.print_aaa(111) == 'aaa'

static なプロパティを一時的に差し替える

  • テストケース終了時に元に戻る。

monkeypatch.setattr で差し替える

tests/test_create_xxx.py
import hoge
import MyConfig

class TestCreateXxx:
  def test__can_print_aaa(self, monkeypatch, xxx):
    monkeypatch.setattr(MyConfig, 'abc', 123)

以上!
随時、更新していきます。

115
94
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
115
94

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?