フィクスチャ — 一度書くだけのセットアップ
セットアップの重複をなくしましょう。@pytest.fixtureで一度だけ記述し、名前で要求すれば、すべてのテストで新しく独立したインスタンスを受け取れます。テストが失敗した場合でも、yieldによって確実にクリーンアップが行われます。
どうやって学びますか?
解決する問題
以下は、小さなショッピングカートの実装cart.pyです:
class Cart:
def __init__(self):
self.lines = []
def add(self, name, price, quantity=1):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.lines.append((name, price, quantity))
def total(self):
return sum(price * quantity for _, price, quantity in self.lines)
def count(self):
return sum(quantity for _, _, quantity in self.lines)そして、そのテストであるtest_cart.pyです:
from cart import Cart
def test_total():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
assert cart.total() == 895.0
def test_count():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
assert cart.count() == 4
def test_add_one_more():
cart = Cart()
cart.add("pen", 15.0, 3)
cart.add("bag", 850.0)
cart.add("notebook", 60.0, 2)
assert cart.total() == 1015.0pytest -q
... [100%]
3 passed in 0.01sテストはパスしますが、読むのが面倒です。3つのテストに対して、セットアップが9行、実際に何かを検証している行はわずか3行しかありません。どのテストでもセットアップが同じであるため、異なっている1行 — つまり、そのテストが存在する目的である肝心の行 — が埋もれてしまっています。
これは後々の負担にもなります。Cart()に引数が必要になった場合、3箇所、あるいは30箇所も修正することになります。前の章では、parametrizeを使ってテストの入力における重複を解消しました。この章では、テストのセットアップにおける重複を解消します。
この章を終える頃には
@pytest.fixtureを使ってフィクスチャを作成し、テストのパラメータ名として指定することでフィクスチャを要求できるようになる- なぜ各テストに新しく独立したインスタンスが渡されるのかを説明し、リストを使ってそれを示すことができるようになる
- あるフィクスチャをもとに別のフィクスチャを構築できるようになる
- 自分自身でクリーンアップを行う
yieldフィクスチャを作成し、セットアップとティアダウンの順序を予測できるようになる --setup-showを使ってフィクスチャが作成される様子を確認できるようになるfixture 'x' not foundや「直接呼び出された(called directly)」というエラーを読み解くことができるようになる- フィクスチャと通常のヘルパー関数のどちらを使うべきかを選択できるようになる
前提条件: `parametrize`を使って1つのテストで複数のケースを実行する。
テストを書く前に
フィクスチャは、テストを書いた後に後付けで追加するものではありません。計画を立てることで自然と導き出されるものであり、まず計画ありきです。
何を保証しているか。 Cartは3つのことを保証しています。add()は明細を記録し、1未満の数量に対してはValueErrorを送出すること、total()は価格×数量の合計であること、count()は数量の合計であることです。ファイルもネットワークもグローバルな状態も関与しないため、今のところ元に戻す必要のあるものはありません。
用意しておくべきもの。 毎章同じです。pytestがインストールされた仮想環境、そしてpytestを実行するフォルダからcart.pyがインポート可能であること(テストファイルをその隣に配置します)です。
どのケースをテストするか。 ケースを書き出してみましょう。その際、普段見落とされがちな列を1つ追加します — テストが開始される際の状態です:
| ケース | 開始時の状態 | アクション | 期待される結果 | |---|---|---|---| | 通常の合計 | ペン×3、バッグ×1 | total() | 895.0 | | アイテム数のカウント | ペン×3、バッグ×1 | count() | 4 | | 商品を1件追加 | ペン×3、バッグ×1 | add("notebook", 60.0, 2) | total()が1015.0になる | | 境界値: カートが空 | 空のカート | total() | 0 | | 無効な数量 | 空のカート | add("pen", 15.0, 0) | ValueError |
2列目を上から下へ見てみてください。3つの行が商品が入った同じカートから始まり、2つの行が空のカートから始まっています。その列で繰り返されている値はすべて、書かれるのを待っているフィクスチャです — ここでは、empty_cartと、それをもとに構築されたcartです。そして、他のどのテストとも共有されない状態から始まる行は、フィクスチャをまったく必要としません。テスト自身で構築すればよいのです。
それぞれの開始時の状態について、もう1つ問いかけてみてください。それを作成することで、テストの外部に何か変更が加わるだろうか? 新しいCartは何も変更しません。しかし、グローバルな辞書に登録された割引コード、開かれた接続、書き込まれたファイルなどは外部に変更を加えます。そうした状態こそが、フィクスチャにクリーンアップが必要である(この章の後半で扱うyield)ことを教えてくれます。
テストすべきでないこと。 pytestがテストごとに新しいフィクスチャを構築することを証明するためのテストは書かないでください。それはpytestの約束であり、この章で一度確認するのは信頼できるようにするためです。一方で、グローバルな状態を元に戻す自分自身のクリーンアップ処理はテストしてください — それは自分が書いたコードであり、間違っている可能性があるからです。
この章の続きでは、この計画をコードに落とし込んでいきます。
最初のフィクスチャ
セットアップ処理を関数に切り出し、その上に@pytest.fixtureを付けます:
import pytest
from cart import Cart
@pytest.fixture
def cart():
c = Cart()
c.add("pen", 15.0, 3)
c.add("bag", 850.0)
return c
def test_total(cart):
assert cart.total() == 895.0
def test_count(cart):
assert cart.count() == 4
def test_add_one_more(cart):
cart.add("notebook", 60.0, 2)
assert cart.total() == 1015.0pytest -q
... [100%]
3 passed in 0.01sテスト内のどこにもcart()を呼び出すコードはありません。各テストは単にcartという名前のパラメータを持っているだけであり、要求はそれですべてです。
テストを実行する前に、pytestはそのパラメータ名を確認します。それぞれの名前について、一致する名前を持つフィクスチャを見つけ、それを実行し、返された値を引数としてテストに渡します。テストは何が必要かを宣言するだけで、自分では構築しません。この仕組みには名前があります — 依存性の注入(dependency injection) です。しかしその仕組みの実態は、「パラメータ名をフィクスチャ名に一致させる」という単純なものに過ぎません。
テストコードは意図を表す1行だけで読めるようになりました。そしてセットアップ処理が1箇所にまとまったため、Cart()に変更があった場合でも、修正するのは1つの関数だけで済みます。
テストごとに新しく生成される
test_add_one_moreはカートにノートを追加します。その後に実行されたテストには、ノートが入ったカートが見えていたでしょうか?答えは「いいえ」です — そして、この動作をしっかりと確認しておく価値があります。なぜなら、これこそがフィクスチャを安全に使えるようにしている特性だからです。
まず、フィクスチャを使わないバージョンを見てみましょう。モジュールの先頭にリストを置き、2つのテストで共有します:
ITEMS = ["pen", "bag"]
def test_add_item():
ITEMS.append("notebook")
assert len(ITEMS) == 3
def test_starts_with_two():
assert len(ITEMS) == 2pytest -q
.F [100%]
=================================== FAILURES ===================================
_____________________________ test_starts_with_two _____________________________
def test_starts_with_two():
> assert len(ITEMS) == 2
E AssertionError: assert 3 == 2
E + where 3 = len(['pen', 'bag', 'notebook'])
test_shared.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_shared.py::test_starts_with_two - AssertionError: assert 3 == 2
1 failed, 1 passed in 0.01s2つ目のテスト自体は正しいコードです。失敗したのは、1つ目のテストが行った処理が原因です。単独で実行するとパスします:
pytest -q test_shared.py::test_starts_with_two
. [100%]
1 passed in 0.01sその前にどのテストが実行されたかによって結果が変わってしまうテストは、テストスイートにおいて最も解決コストの高いバグの1つです。なぜなら、たまにしか発生しないからです。
では、同じ2つのテストで、リストをフィクスチャから受け取るようにしてみましょう。フィクスチャ内部のprintによって、いつ実行されたかがわかります。-sはpytestに出力キャプチャを行わないよう指示するため、出力された内容を確認できます:
import pytest
@pytest.fixture
def items():
print("building a new list")
return ["pen", "bag"]
def test_add_item(items):
items.append("notebook")
assert len(items) == 3
def test_starts_with_two(items):
assert len(items) == 2pytest -q -s
building a new list
.building a new list
.
2 passed in 0.01s「building a new list」が2回表示されています。フィクスチャ関数は、それを要求したテストごとに1回ずつ実行され、各テストは誰も手を触れていないリストを受け取りました。これがデフォルトの動作であり、関数スコープ(function scope)という名前が付いています。フィクスチャの値の寿命は、1つのテスト関数の間だけです。第8章では、そのスコープを変更する方法と、通常は変更すべきでない理由について解説します。
フィクスチャはフィクスチャを使える
フィクスチャは、テストとまったく同じ方法で別のフィクスチャを要求できます — パラメータとしてその名前を指定するだけです:
import pytest
from cart import Cart
@pytest.fixture
def empty_cart():
return Cart()
@pytest.fixture
def cart(empty_cart):
empty_cart.add("pen", 15.0, 3)
empty_cart.add("bag", 850.0)
return empty_cart
def test_new_cart_is_empty(empty_cart):
assert empty_cart.total() == 0
def test_total(cart):
assert cart.total() == 895.0pytest -q
.. [100%]
2 passed in 0.01scartはempty_cartをもとに構築されているため、テストは必要に応じた準備レベルのフィクスチャを要求できます。どちらのテストも何かを明示的に呼び出しているわけではありません。pytestがtest_totalからcartへ、そしてcartからempty_cartへと名前をたどり、その依存関係チェーンが要求する順序で構築してくれます。
実際の動作を確認する — --setup-show
どのフィクスチャが実行されたかを調べるために、わざわざprintを追加する必要はありません。--setup-showを指定すると、すべてのセットアップとティアダウンが表示されます:
pytest -q --setup-show
SETUP F empty_cart
test_cart.py::test_new_cart_is_empty (fixtures used: empty_cart) .
TEARDOWN F empty_cart
SETUP F empty_cart
SETUP F cart (fixtures used: empty_cart)
test_cart.py::test_total (fixtures used: cart, empty_cart) .
TEARDOWN F cart
TEARDOWN F empty_cart
2 passed in 0.01s上から順に見ていきましょう。Fは関数スコープ(function scope)を意味し、1つのテストのために構築され、テスト終了後に破棄されます。empty_cartはテストごとに1回ずつ、計2回セットアップされています。これが前のセクションで説明した「新しさ」です。cartが使われる箇所では、cartがempty_cartに依存しているため、まずempty_cartがセットアップされます。そしてティアダウンは、セットアップとは逆の順序で行われます。つまり、最後に構築されたものが最初に破棄されます。
(プラグインをインストールしている場合、プラグイン自身のフィクスチャに関する行が追加で表示されることがあります。それらはプラグインのものであり、あなたが書いたものではありません。)
ここまでは元に戻すべきものが何もなかったため、ティアダウンによって目に見える変化は起きませんでした。次はそれが変わります。
yield — セットアップとクリーンアップ
セットアップの中には、後で元に戻さなければならないものを残すものがあります。例えば、クローズすべき接続、削除すべきファイル、元に戻すべきグローバル設定などです。そうした場合、フィクスチャではreturnの代わりにyieldを使用します:
import pytest
@pytest.fixture
def connection():
print("\nconnection: open")
conn = {"orders": []}
yield conn
print("connection: close")
def test_starts_empty(connection):
print("test: running")
assert connection["orders"] == []pytest -q -s
connection: open
test: running
.connection: close
1 passed in 0.01syieldより前にあるものはすべてセットアップです。yieldの後に続く値が、テストに渡される値になります。そこでフィクスチャは一時停止し、テストが実行されます。そしてテストが完了すると、pytestはyieldの直後からフィクスチャを再開します。この残りの部分がティアダウンです。(「connection: close」の前に.があるのは、ティアダウンが開始される直前、テストが終了した瞬間にテスト結果が表示されるためです。)
クリーンアップ処理は、セットアップ処理と同じ関数のわずか数行下に記述されます。一方を追加してもう一方を忘れてしまえば、一目でわかります。
一方に依存する2つのyieldフィクスチャがある場合、その順序は--setup-showが示唆していたとおりになります:
import pytest
@pytest.fixture
def connection():
print("\n1. connection: open")
conn = {"orders": []}
yield conn
print("5. connection: close")
@pytest.fixture
def saved_order(connection):
print("2. saved_order: insert")
connection["orders"].append("A-100")
yield "A-100"
connection["orders"].remove("A-100")
print("4. saved_order: delete")
def test_order_is_saved(connection, saved_order):
print("3. test: running")
assert saved_order in connection["orders"]pytest -q -s
1. connection: open
2. saved_order: insert
3. test: running
.4. saved_order: delete
5. connection: close
1 passed in 0.01sセットアップは依存関係チェーンの下から上へと進み、ティアダウンは上から下へと戻ります。うまく機能する順序はこれしかありません。saved_orderは接続を通じてレコードを削除するため、削除を行う時点でも接続が開いたままでなければならないからです。
テストが失敗してもティアダウンは実行される
テストの末尾で単純にクリーンアップすれば済むのに、なぜわざわざフィクスチャを使ってクリーンアップするのでしょうか?実際に試してみましょう。OPENは、テストが残してしまう可能性のあるあらゆるもの(開かれた接続、一時ファイル、変更された設定など)を表しています:
OPEN = []
def test_broken():
OPEN.append("connection")
assert OPEN == ["A-100"]
OPEN.remove("connection")
def test_nothing_left_open():
assert OPEN == []pytest -q --tb=no
FF [100%]
=========================== short test summary info ============================
FAILED test_cleanup_in_body.py::test_broken - AssertionError: assert ['connec...
FAILED test_cleanup_in_body.py::test_nothing_left_open - AssertionError: asse...
2 failed in 0.01s(--tb=noはトレースバックを非表示にし、サマリーのみを表示します。)ミスは1つなのに、失敗が2件発生しました。assertが失敗すると例外が送出され、例外の送出によってテストはその場で終了するため、その下にあるクリーンアップの行は実行されませんでした。接続はOPENに残ったままになり、次のテスト — まったく正しいテスト — は引き継いだゴミのせいで失敗してしまいました。大規模なテストスイートでは、この2つ目の失敗こそが原因調査に半日を費やすようなバグになります。
では同じ処理を、yieldの後にクリーンアップを配置して行ってみましょう:
import pytest
OPEN = []
@pytest.fixture
def connection():
OPEN.append("connection")
yield "connection"
OPEN.remove("connection")
def test_broken(connection):
assert OPEN == ["A-100"]
def test_nothing_left_open():
assert OPEN == []pytest -q --tb=no
F. [100%]
=========================== short test summary info ============================
FAILED test_cleanup_in_fixture.py::test_broken - AssertionError: assert ['con...
1 failed, 1 passed in 0.01sミスは1つ、失敗も1件です。壊れたテストはきちんと報告され(フィクスチャは何も隠しません)、その散らかしたゴミが次のテストに影響することはありませんでした。
これがその理由であり、そこから導かれるルールです: クリーンアップをテスト関数の末尾に書いてはいけません。必ずフィクスチャのyieldの後に書きます。
フィクスチャか、それとも普通の関数か?
セットアップを共有する方法はフィクスチャだけではありません。通常の関数 — 例えば、渡された明細行からカートを構築するcart_with(*lines)など — でも機能しますし、以下の完全な例でも実際に使用しています。
使い分けを決める決定的な違いは次のとおりです: テストはヘルパーを呼び出し、引数を渡すことができます。一方で、テストはフィクスチャを呼び出すことはできず、その名前を指定することしかできません。 フィクスチャは、それを要求するすべてのテストに対して常に同じものを生成します。
したがって、次のように判断します:
- フィクスチャを使う場合: 多くのテストが同じ開始状態を必要としているとき。特に、その状態を後でクリーンアップする必要があるとき。確実なティアダウンが保証されるのはフィクスチャだけです。
- ヘルパー関数を使う場合: 各テストが少しずつ異なるオブジェクトを必要としているとき(特定の明細行を持つカート、特定の名前を持つユーザーなど)。引数を渡すことこそが関数の本来の役割です。
この2つは相性良く組み合わせることができます。次の例のように、フィクスチャからヘルパーを呼び出すことも可能です。(関数を返すフィクスチャを作成する方法 — 第12章で扱う「ファクトリ(factory)」パターン — もありますが、今のところはまだ必要ありません。)
完全な例
割引コードがモジュールレベルの辞書で管理されているショップの例です。コードを登録するとグローバルな状態が変更されるため、登録したテストは後でそれを削除しなければなりません。
shop.py:
DISCOUNTS = {}
def register_discount(code, percent):
DISCOUNTS[code] = percent
def remove_discount(code):
del DISCOUNTS[code]
class Cart:
def __init__(self):
self.lines = []
def add(self, name, price, quantity=1):
if quantity < 1:
raise ValueError(f"quantity must be at least 1: {quantity}")
self.lines.append((name, price, quantity))
def total(self, code=None):
amount = sum(price * quantity for _, price, quantity in self.lines)
if code is not None:
amount = amount * (100 - DISCOUNTS[code]) / 100
return round(amount, 2)test_shop.py:
import pytest
from shop import Cart, register_discount, remove_discount
def cart_with(*lines):
# A plain helper: it takes arguments, so each test can ask for its own cart.
cart = Cart()
for name, price, quantity in lines:
cart.add(name, price, quantity)
return cart
@pytest.fixture
def cart():
# The cart most tests share. Built fresh for every test that asks.
return cart_with(("pen", 15.0, 3), ("bag", 850.0, 1))
@pytest.fixture
def summer_code():
# Changes global state, so it must undo that change afterwards.
register_discount("SUMMER", 10)
yield "SUMMER"
remove_discount("SUMMER")
def test_total(cart):
assert cart.total() == 895.0
def test_summer_discount(cart, summer_code):
assert cart.total(summer_code) == 805.5
def test_code_is_gone_afterwards(cart):
with pytest.raises(KeyError):
cart.total("SUMMER")
def test_small_cart(summer_code):
cart = cart_with(("pen", 15.0, 2))
assert cart.total(summer_code) == 27.0
def test_zero_quantity_is_refused():
with pytest.raises(ValueError):
Cart().add("pen", 15.0, 0)pytest -q --setup-show
SETUP F cart
test_shop.py::test_total (fixtures used: cart) .
TEARDOWN F cart
SETUP F cart
SETUP F summer_code
test_shop.py::test_summer_discount (fixtures used: cart, summer_code) .
TEARDOWN F summer_code
TEARDOWN F cart
SETUP F cart
test_shop.py::test_code_is_gone_afterwards (fixtures used: cart) .
TEARDOWN F cart
SETUP F summer_code
test_shop.py::test_small_cart (fixtures used: summer_code) .
TEARDOWN F summer_code
test_shop.py::test_zero_quantity_is_refused .
5 passed in 0.01s注目すべき点が3つあります。
1つ目は、各テストが自身が使用するものだけを要求している点です。test_small_cartは別のカートを必要としたため、cartをまったく要求しませんでした。独自の明細行を指定してヘルパーを呼び出しています。そして--setup-showを見ると、このテストのためにcartが構築されることはなかったことが確認できます。test_zero_quantity_is_refusedは空のカートと1回の呼び出ししか必要としないため、フィクスチャを使用していません。これは計画表にあった「他のテストと共有されない開始状態」の行に該当します。
2つ目は、test_code_is_gone_afterwardsがフィクスチャのテストになっている点です。このテストはtest_summer_discountの後に実行され、SUMMERがもはや存在しないことを検証します。これがなぜ重要かを確認するために、summer_codeのyieldとクリーンアップ行の代わりにreturn "SUMMER"に変更してみてください:
pytest -q --tb=no
..F.. [100%]
=========================== short test summary info ============================
FAILED test_shop.py::test_code_is_gone_afterwards - Failed: DID NOT RAISE Key...
1 failed, 4 passed in 0.01s割引コードが、それを登録したテストから漏れ出して次のテストに入り込んでしまいました。この検証テストがなければ、この漏洩に気づくことはできなかったでしょう。
3つ目は、ヘルパーとフィクスチャが連携して機能している点です。cart_withはカートをどのように構築するかを知っており、cartフィクスチャは多くのテストが共有するどのカートにするかを決定します。
問題が起きたとき
fixture 'crat' not found
_________________________ ERROR at setup of test_empty _________________________
file .../test_typo.py, line 11
def test_empty(crat):
E fixture 'crat' not found
> available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, capteesys, cart, doctest_namespace, monkeypatch, pytestconfig, record_property, record_testsuite_property, record_xml_attribute, recwarn, subtests, tmp_path, tmp_path_factory, tmpdir, tmpdir_factory
> use 'pytest --fixtures [testpath]' for help on them.パラメータ名がどのフィクスチャとも一致しませんでした。スペルミスをしているか(ここではリスト内にあるcartに対してcratと記述されています)、あるいは関数に@pytest.fixtureの行が付いていないかのどちらかです(後者の場合、リストにも表示されません)。FやFAILEDではなく、EやERRORと表示されている点に注目してください。テストは開始すらされませんでした。
Fixture "cart" called directly
Fixture "cart" called directly. Fixtures are not meant to be called directly,
but are created automatically when test functions request them as parameters.テスト本文でcart()と呼び出してしまっています。フィクスチャはパラメータ名として指定することで要求するものであり、呼び出すことは決してありません。独自の引数を渡して呼び出したい場合は、ヘルパー関数を使うのが適切です。
失敗(failure)ではなくERROR at setup of test_totalが表示される テストが実行される前、フィクスチャの内部で例外が発生しました。例えば、セットアップ時の不正なadd()呼び出しによるValueError: quantity must be at least 1: 0などです。Eは「準備が壊れた」ことを意味し、Fは「アサーションの主張が偽だった」ことを意味します。まずはフィクスチャの中を確認してください。
fixture function has more than one 'yield' フィクスチャがyieldを行うのは厳密に1回だけです。セットアップはyieldより上に書き、クリーンアップはyieldより下に書きます。2つの値を渡したい場合は、タプルをyieldするか、フィクスチャを2つ作成してください。
単独で実行するとパスするのに、他のテストと一緒に実行すると失敗する テスト間で何かが共有されています。モジュールレベルのリストや辞書、あるいはフィクスチャが変更した後に元に戻さなかったグローバル変数などです。フィクスチャの中で構築するようにし、グローバルな状態である場合はyieldの後に変更を元に戻してください。
クリーンアップが実行されなかった 失敗したassertの後のテスト本文内にクリーンアップが書かれているか、フィクスチャがreturnを使用しています。yieldフィクスチャに移し、yieldの後にクリーンアップを記述してください。
ステップ 4 / 6 — 予想する
理解度をチェック
両方のテストが同じフィクスチャを要求し、両方ともリストに要素を追加しています。pytest -qの最後の行には何と表示されますか?
import pytest
@pytest.fixture
def basket():
return []
def test_one(basket):
basket.append("pen")
assert basket == ["pen"]
def test_two(basket):
basket.append("bag")
assert basket == ["bag"]- A1 failed, 1 passed — test_twoには['pen', 'bag']が見えている
- B1 passed, 1 error
- C2 passed
- D2 failed
pytest -q -sで実行した場合、pytest独自のドットやサマリーを除くと、3つの行はどのような順序で出力されますか?
import pytest
@pytest.fixture
def db():
print("open")
yield "db"
print("close")
def test_query(db):
print("query")- Aopen query
- Bopen close query
- Cquery open close
- Dopen query close
このテストは失敗します。どこに問題がありますか?
import pytest
@pytest.fixture
def cart():
return ["pen", "bag"]
def test_count():
assert len(cart()) == 2- Aフィクスチャに対して`len()`を使用することはできない
- Bフィクスチャには`return`ではなく`yield`が必要である
- Cフィクスチャはタプルを返す必要がある
- Dテストがフィクスチャ自体を呼び出している。代わりにパラメータとして`cart`を受け取るべきである
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
モジュールレベルのLOG = []とLibraryクラスを含むlibrary.pyを作成してください。クラスには、add(title)、本が本棚にない場合はValueErrorを送出し、ある場合は本棚から削除してLOGに"borrow <title>"を追加するborrow(title)、give_back(title)、そして本棚にある本のタイトルをソートされたリストとして返すavailable()を実装します。
次にtest_library.pyを作成します。この章の冒頭で行ったように、まずは開始時の状態の列を含む表を作成して計画を立ててから、以下を実装してください:
- 3冊の本が入った
Libraryを返すフィクスチャlibraryと、それを使用する少なくとも3つのテスト(そのうち1つは本を借りるテストにし、後続のテストで本棚が再び満杯に戻っていることを確認できるようにします) libraryに依存し、本を1冊借りてそのタイトルを返すフィクスチャborrowedと、それを使用する2つのテスト- テストの実行前と実行後に
LOGを空にするyieldフィクスチャclean_log、それを使用する1つのテスト、そしてその後にLOG == []をアサートするもう1つのテスト libraryおよび空の本棚を必要とする1つのテストで使用される、通常のヘルパー関数library_with(*titles)
pytest -qを実行し、続いてpytest -q --setup-showを実行して、すべての行が予想どおりになっているか確認してください。
最後に、わざと壊してみましょう。clean_logにおいて、yieldとその後の行をreturn LOGに置き換えてみます。どのテストが失敗するでしょうか?失敗したのはそのフィクスチャを使用したテストでしょうか?
解答例
まずは計画からです:
| ケース | 開始時の状態 | アクション | 期待される結果 | |---|---|---|---| | 本棚に3冊の本がある | Dune、Emma、Ulysses | available() | ソートされた3冊すべて | | 借りると本が削除される | 同上 | borrow("Dune") | Emma、Ulysses | | テスト間の影響がない | 同上 | — | 依然として3冊 | | 2回借りることはできない | 同上、Emmaが貸出中 | borrow("Emma") | ValueError | | 返却する | 同上、Emmaが貸出中 | give_back("Emma") | Emmaが本棚にある | | 貸出がログに記録される | 同上、LOGは空 | borrow("Ulysses") | LOG == ["borrow Ulysses"] | | ログがクリーンアップされる | 上記のテストの後 | — | LOG == [] | | 境界値: 空の本棚 | 本なし | available() | [] |
2つの開始状態が繰り返されているため(3冊の本がある本棚と、Emmaが貸出中の本棚)、これらはフィクスチャになりました。空のログは1回しか必要ありませんが、後で元に戻さなければならないグローバルな状態に手を加えるため、確実に元に戻せる唯一の手段であるフィクスチャにしています。空の本棚は1回しか必要とされず、何も後に残さないため、ヘルパー関数を使用します。
library.py:
LOG = []
class Library:
def __init__(self):
self.shelf = set()
def add(self, title):
self.shelf.add(title)
def borrow(self, title):
if title not in self.shelf:
raise ValueError(f"not on the shelf: {title}")
self.shelf.remove(title)
LOG.append(f"borrow {title}")
def give_back(self, title):
self.shelf.add(title)
def available(self):
return sorted(self.shelf)test_library.py:
import pytest
from library import LOG, Library
def library_with(*titles):
# Helper: each caller chooses its own shelf.
library = Library()
for title in titles:
library.add(title)
return library
@pytest.fixture
def library():
return library_with("Dune", "Emma", "Ulysses")
@pytest.fixture
def borrowed(library):
library.borrow("Emma")
return "Emma"
@pytest.fixture
def clean_log():
LOG.clear()
yield LOG
LOG.clear()
def test_three_books_on_the_shelf(library):
assert library.available() == ["Dune", "Emma", "Ulysses"]
def test_borrow_removes_the_book(library):
library.borrow("Dune")
assert library.available() == ["Emma", "Ulysses"]
def test_next_test_still_sees_three(library):
assert len(library.available()) == 3
def test_cannot_borrow_twice(library, borrowed):
with pytest.raises(ValueError):
library.borrow(borrowed)
def test_give_back_returns_it(library, borrowed):
library.give_back(borrowed)
assert borrowed in library.available()
def test_borrow_is_logged(library, clean_log):
library.borrow("Ulysses")
assert clean_log == ["borrow Ulysses"]
def test_log_is_empty_afterwards():
assert LOG == []
def test_empty_library():
library = library_with()
assert library.available() == []pytest -q
........ [100%]
8 passed in 0.01s--setup-showを指定すると、libraryを使用する6つのテストそれぞれに対してSETUP F libraryが1回ずつ表示され、borrowedはlibraryの後にセットアップされ、その前にティアダウンされること、そして最後の2つのテストにはフィクスチャの行がまったく表示されないことが確認できます。
そして、yieldの代わりにreturn LOGを使った実験結果です:
pytest -q --tb=no
......F. [100%]
=========================== short test summary info ============================
FAILED test_library.py::test_log_is_empty_afterwards - AssertionError: assert...
1 failed, 7 passed in 0.01s設計上の判断を1つずつ確認していきましょう:
libraryはyieldではなくreturnします。 新しいLibraryはテストの外部に何も変更を加えないため、元に戻すものがありません。yieldはクリーンアップが必要なフィクスチャのためのものであり、好みのスタイルの問題ではありません。test_next_test_still_sees_threeは意図して配置されています。 このテストはDuneを借りたテストの直後に実行されますが、libraryが新しく再構築されるためパスします。--setup-showで確認すると、テストごとに1回ずつSETUP F libraryが実行されていることがわかります。borrowedはタイトルを返します。 テストでは"Emma"を繰り返し書く代わりにborrowedを使用しているため、フィクスチャが借りる本を変更した場合でも、テストは自動的に追従します。また、borrowedがlibraryに依存しているため、両方を要求する2つのテストはどちらも同じライブラリ(Emmaが取り出されたライブラリ)を受け取ります。clean_logはyieldの前後の両方でクリアを行います。yieldの前にクリアするのは、borrow()を呼び出す他のテスト(test_borrow_removes_the_bookやborrowedフィクスチャ)がすでにLOGに書き込みを行っているためです。テストは予測可能な既知の状態から開始しなければなりません。yieldの後にクリアするのは、次のテストに何も漏れ出さないようにするためです。- *実験ではもう一方のテストが失敗します。*
test_borrow_is_loggedはパスし続けますが、何の間違いも犯していないtest_log_is_empty_afterwardsが失敗します。クリーンアップの欠落とは常にこういうものです。失敗はまったく別の場所で表面化します。だからこそ、この解答例にはクリーンアップを検証するテストが含まれているのです。 library_withはフィクスチャではなくヘルパー関数です。 これを使用する1つのテストが異なる本棚を必要としており、引数を取ることができるのは関数だけだからです。libraryもこれを呼び出しているため、本棚をどのように満たすかという知識は1箇所に集約されています。