フィクスチャのスコープとconftest.py — セットアップを安全に共有する
時間のかかるセットアップを一度だけ構築して共有しましょう。5つのスコープ、--setup-show、共有状態の危険性とScopeMismatch、インポート不要のフィクスチャやディレクトリごとのオーバーライドを実現するconftest.py、autouseのコスト、そしてフィクスチャが作成される順序について学びます。
どうやって学びますか?
解決する問題
前章のフィクスチャは、テストごとに新しく構築されていました。構築対象のコストが高くなるまでは、それが適切なデフォルトの動作です。以下は、実際のサーバーとのハンドシェイクのように、開くのに0.5秒かかるデータベース接続の例です:
test_users.py:
import sqlite3
import time
import pytest
def connect():
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT)")
return conn
@pytest.fixture
def db():
conn = connect()
yield conn
conn.close()
def test_starts_empty(db):
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 0
def test_insert_one(db):
db.execute("INSERT INTO users VALUES ('asha')")
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 1
def test_names_are_text(db):
db.execute("INSERT INTO users VALUES ('ravi')")
name = db.execute("SELECT name FROM users").fetchone()[0]
assert name == "ravi"pytest -q --durations=3を実行すると、どこに時間がかかったかが報告されます:
... [100%]
============================= slowest 3 durations ==============================
0.50s setup test_users.py::test_starts_empty
0.50s setup test_users.py::test_insert_one
0.50s setup test_users.py::test_names_are_text
3 passed in 1.65s3つのテストで3回の接続が行われ、ハンドシェイクだけに1.5秒が費やされています。これが300件のテストになれば、何もしていない無駄な時間が2分半にもなり、それほど遅いテストスイートはいずれ実行されなくなってしまいます。
この章で答える問いは次の通りです: 一度だけ構築して共有しつつ、それを共有するテスト同士が干渉し合わないようにするにはどうすればよいでしょうか?
この章を終える頃には
- フィクスチャのスコープ(
function、class、module、package、session)を選択できるようになる --setup-showを使ってフィクスチャのセットアップとティアダウンの様子を確認できるようになる- ミュータブルな状態を保持する広いスコープのフィクスチャが、なぜテスト間の依存関係を生んでしまうのかを説明し、それを修正できるようになる
ScopeMismatchエラーを読み解き、どちらのフィクスチャを変更すべきかを判断できるようになるconftest.pyを通じてフィクスチャを共有し、単一のディレクトリ用にオーバーライドできるようになるautouse=Trueのコストを理解し、--fixturesで利用可能なすべてのフィクスチャを一覧表示できるようになる- フィクスチャが作成される順序を予測できるようになる
前提条件: フィクスチャ。
テストを書く前に
スコープは、遅いテストスイートを高速化するために後から付け足すものではありません。最初のテストを書く前に、セットアップの各要素についての2つの質問から決めるものです: 構築にどれだけのコストがかかるか、そしてテストによって変更されるか? まずこれらに答えることで、適切なスコープが自ずと決まります。
仕様の合意事項(コントラクト)。 ここでテスト対象となるコードは、小さなusersテーブルです。保証する内容は次の通りです: 新しいデータベースにはユーザーが存在しないこと、ユーザーを挿入するとちょうど1行追加されること、挿入した名前がそのまま取得できることです。これらの保証は、事前にどのテストが実行されたかに関係なく満たされなければなりません。この最後の条件こそが、本章が真にテーマとしている部分です。
用意しておくべきもの。 pytestがインストールされた仮想環境、Pythonに標準付属しているため追加インストールの不要なsqlite3、テストファイルからインポート可能なテスト対象コード(ここでは同一ディレクトリ内に並べて配置し、そのディレクトリから実行します)、そして共有フィクスチャをどこに配置するかの決定(テストツリーの最上位に置くconftest.py)です。
テスト計画。 3つのケースがあり、それぞれ単独でも、かつどのような順序で実行してもパスする必要があります:
| ケース | 入力 | 期待される結果 | |---|---|---| | 新しいデータベース | 何も挿入しない | COUNT(*)が0 | | 1件挿入 | 'asha' | COUNT(*)が1 | | 名前の往復(round-trip) | 'ravi' | 最初に読み出される名前が'ravi' |
セットアップ計画。 次に、テストの土台となるリソースについても同様の表を作成します:
| リソース | 構築コスト | テストによって変更されるか? | スコープ | |---|---|---|---| | 接続 + テーブル | 低速(0.5秒) | いいえ | 広い — moduleまたはsession | | テーブル内の行 | コストなし | はい、毎回の挿入で変更される | function — 各テスト後にリセット |
最後の列をよく見てください。接続という1つのオブジェクトが、時間のかかるものと変化するものの両方を保持しています。だからこそ、この章では1つではなく2つのフィクスチャ(広いスコープのものと狭いスコープのもの)を用意することになるのです。
テストすべきでないこと。 sqlite3自体はテストしません。あなたの書くコードよりもはるかに徹底的にテストされているからです。フィクスチャを直接テストすることもありません — フィクスチャはそれを利用するテストや、--setup-showの出力を読むことによって確認されます。そして、0.5秒の遅延時間もテストしません。処理速度は--durationsで測定するものであり、アサーションで検証するものではないからです。
scope= — 何ごとに1回なのか?
フィクスチャのスコープとは、pytestがその値を破棄して別の値を新しく構築するまでに、1つの値がどれだけの期間生存するかを指定するものです。デフォルトはfunctionであり、テストごとに1回です。1行変更してみましょう:
@pytest.fixture(scope="module")
def db():
conn = connect()
yield conn
conn.close()pytest -q:
..F [100%]
=================================== FAILURES ===================================
_____________________________ test_names_are_text ______________________________
db = <sqlite3.Connection object at 0x79350aa45c60>
def test_names_are_text(db):
db.execute("INSERT INTO users VALUES ('ravi')")
name = db.execute("SELECT name FROM users").fetchone()[0]
> assert name == "ravi"
E AssertionError: assert 'asha' == 'ravi'
E
E - ravi
E + asha
test_users.py:35: AssertionError
=========================== short test summary info ============================
FAILED test_users.py::test_names_are_text - AssertionError: assert 'asha' == ...
1 failed, 2 passed in 0.52s2つのことが起きました。実行時間が1.65秒から0.52秒になりました — 3回の接続の代わりに1回になったからです。そして、先ほどまでパスしていたテストが失敗するようになりました。この失敗を覚えておいてください。これについては後ほど個別の節で詳しく扱います。まずはスコープそのものを見ていきましょう。
最も狭いものから最も広いものまで、5つのスコープがあります:
| スコープ | 値が作成される単位 | 破棄(ティアダウン)されるタイミング | |---|---|---| | function | テストごと(デフォルト) | 各テストの後 | | class | テストクラスごと | そのクラス内の最後のテストの後 | | module | テストファイルごと | そのファイル内の最後のテストの後 | | package | フィクスチャが定義されているディレクトリごと | そのディレクトリ(サブディレクトリを含む)内の最後のテストの後 | | session | pytest実行全体 | 実行全体の最後のテストの後 |
カウンターを使うと、その違いが目に見えて分かります。以下の各フィクスチャは、自分が何回構築されたかをカウントします:
test_scopes.py:
from collections import Counter
import pytest
made = Counter()
@pytest.fixture(scope="session")
def per_session():
made["session"] += 1
@pytest.fixture(scope="module")
def per_module():
made["module"] += 1
@pytest.fixture(scope="class")
def per_class():
made["class"] += 1
@pytest.fixture
def per_test():
made["function"] += 1
class TestFirst:
def test_a(self, per_session, per_module, per_class, per_test):
pass
def test_b(self, per_session, per_module, per_class, per_test):
pass
class TestSecond:
def test_c(self, per_session, per_module, per_class, per_test):
pass
def test_report():
print(dict(made))pytest -q -s(-sはprintの出力をそのまま通します):
...{'session': 1, 'module': 1, 'class': 2, 'function': 3}
.
4 passed in 0.01s3つのテストがフィクスチャを使用しました。functionフィクスチャは3回構築され、classフィクスチャは2回(2つのクラス)、moduleフィクスチャとsessionフィクスチャはそれぞれ1回構築されました。
実際の動作を確認する: --setup-show
カウントするのも有効ですが、pytestならタイムライン全体を図示してくれます。同じファイルをpytest -q --setup-showで実行してみましょう:
SETUP S per_session
SETUP M per_module
SETUP C per_class
SETUP F per_test
test_scopes.py::TestFirst::test_a (fixtures used: per_class, per_module, per_session, per_test) .
TEARDOWN F per_test
SETUP F per_test
test_scopes.py::TestFirst::test_b (fixtures used: per_class, per_module, per_session, per_test) .
TEARDOWN F per_test
TEARDOWN C per_class
SETUP C per_class
SETUP F per_test
test_scopes.py::TestSecond::test_c (fixtures used: per_class, per_module, per_session, per_test) .
TEARDOWN F per_test
TEARDOWN C per_class
test_scopes.py::test_report .
TEARDOWN M per_module
TEARDOWN S per_session
4 passed in 0.01sSETUPの直後にある文字がスコープ(Session、Package、Module、Class、Function)を示しており、インデントによってそれらがネスト(階層化)されます。上から下へと読んでいけば、各値がいつ生成され、いつ破棄されるのかを正確に把握できます。フィクスチャが何をしているのか分からなくなったときは、まず真っ先に使うべきフラグです。
共有されたフィクスチャはその状態も共有する
先ほどの失敗の話に戻りましょう。scope="module"に設定したことで、3つのテストすべてが同一の接続を受け取りました。test_insert_oneが'asha'を追加したものの、それを削除する処理がどこにもありませんでした。その後test_names_are_textが'ravi'を追加し、最初の名前を取得しようとしたところ、'asha'が返ってきたのです。
このバグの最も明確な兆候は、単独で実行するとパスするテストです。pytest -q test_users.py::test_names_are_text:
. [100%]
1 passed in 0.50s単独ではパスするのに、他のテストと一緒に実行すると失敗します。以前に実行されたテストに依存するテストは、単に遅いテストよりも厄介です。実行順序によって失敗したりパスしたりするため、その原因調査で毎回午後の貴重な時間をまるまる無駄にしてしまいます。
ルールはシンプルです: コストが高いもののスコープは広げ、状態のスコープは狭く保つこと。 フィクスチャを2つに分割しましょう — 接続を保持する広いスコープのものと、各テストの後にクリーンアップを行う関数スコープのものです:
@pytest.fixture(scope="module")
def connection():
conn = connect()
yield conn
conn.close()
@pytest.fixture
def db(connection):
yield connection
connection.rollback() # undo whatever this test wroteテスト側のコードは変更しません — 引き続きdbを要求します。pytest -q --durations=3:
... [100%]
============================= slowest 3 durations ==============================
0.50s setup test_users.py::test_starts_empty
(2 durations < 0.005s hidden. Use -vv to show these durations.)
3 passed in 0.51s接続は1回だけで、3つのテストすべてがパスしました。sqlite3は最初のINSERT時にトランザクションを開始するため、rollback()を呼び出すことでトランザクション全体が破棄され、すべてのテストが空のテーブルから開始できるようになります。
なぜ各テストの冒頭でDELETE FROM usersを実行するのではなく、フィクスチャの中でクリーンアップを行うのでしょうか?すべてのテストで覚えておかなければならないクリーンアップは、いずれどこかのテストで忘れられてしまうからです — そして忘れたテスト自身が失敗するのではなく、その次のテストが失敗することになります。フィクスチャ内に記述しておけば、このファイルを読んだこともない人が来年書くテストも含め、dbを要求するすべてのテストの後に確実にクリーンアップが実行されます。そして、なぜDELETEではなくrollback()なのでしょうか?ロールバックを行えば、フィクスチャ側でどのような書き込みが行われたかを知らなくても、そのテストがすべてのテーブルに対して書き込んだ内容をすべて元に戻せるからです。
--setup-showを実行すると、この分割の様子がよく分かります:
SETUP M connection
SETUP F db (fixtures used: connection)
test_users.py::test_starts_empty (fixtures used: connection, db) .
TEARDOWN F db
SETUP F db (fixtures used: connection)
test_users.py::test_insert_one (fixtures used: connection, db) .
TEARDOWN F db
SETUP F db (fixtures used: connection)
test_users.py::test_names_are_text (fixtures used: connection, db) .
TEARDOWN F db
TEARDOWN M connection
3 passed in 0.51sここでdbがconnectionを要求しているように、関数スコープのフィクスチャからより広いスコープのフィクスチャを要求することは自由に行えます。しかし、その逆の方向は許可されていません。
ScopeMismatch — 広いスコープから狭いスコープは要求できない
test_mismatch.py:
import sqlite3
import pytest
@pytest.fixture
def db_name():
return ":memory:"
@pytest.fixture(scope="session")
def connection(db_name):
conn = sqlite3.connect(db_name)
yield conn
conn.close()
def test_connects(connection):
assert connection.execute("SELECT 1").fetchone() == (1,)pytest -q:
E [100%]
==================================== ERRORS ====================================
_______________________ ERROR at setup of test_connects ________________________
ScopeMismatch: You tried to access the function scoped fixture db_name with a session scoped request object. Requesting fixture stack:
test_mismatch.py:11: def connection(db_name)
Requested fixture:
test_mismatch.py:6: def db_name()
=========================== short test summary info ============================
ERROR test_mismatch.py::test_connects - Failed: ScopeMismatch: You tried to a...
1 error in 0.01sこの要求が何を意味するかを考えてみてください。connectionはテスト実行全体を通して生存しますが、db_nameは最初のテストの後に破棄されます。セッションスコープの値が、自身のルール上すでに存在しないはずのものを保持し続けることになってしまいます。そのためpytestはこれを拒否します — そして、これがF(Failure: 失敗)ではなくセットアップ時のE(Error: エラー)であることに注目してください。テスト本体は一度も実行されていません。
エラーメッセージには、両方のフィクスチャ名とそれぞれの行番号が明記されています。修正方法は常に同じです。要求される側のフィクスチャを、要求する側のフィクスチャと同じかそれ以上に広いスコープにするか(ここではdb_nameに@pytest.fixture(scope="session")を付ける)、要求する側のフィクスチャのスコープをより狭くすることです。
conftest.py — インポート不要のフィクスチャ
2つ目のテストファイルでもdbが必要になったとき、フィクスチャをコピーして貼り付けるのは誤ったやり方です。正確にconftest.pyという名前を付けたファイルにフィクスチャを移動しましょう。pytestはこのファイルを自動的に読み込み、そのディレクトリおよびその配下にあるすべてのテストが、importを書くことなくフィクスチャを要求できるようになります。
サブディレクトリを持つプロジェクトの構成例:
conftest.py
test_users.py
reports/
conftest.py
test_reports.pyconftest.py:
import sqlite3
import time
import pytest
def connect():
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT, city TEXT)")
return conn
@pytest.fixture(scope="session")
def connection():
"""One database connection, shared by the whole run."""
conn = connect()
yield conn
conn.close()
@pytest.fixture
def db(connection):
"""The shared connection; every write is rolled back after the test."""
yield connection
connection.rollback()test_users.py — インポートは一切ありません:
def test_starts_empty(db):
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 0
def test_insert_one(db):
db.execute("INSERT INTO users VALUES ('asha', 'Dhaka')")
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 1スコープはsessionになったため、プロジェクト内のすべてのファイルで1つの接続が共有されます。
特定のディレクトリ用にフィクスチャをオーバーライドする
reports/内のテストはすべて、すでにユーザーが登録されているデータベースを必要としています。そのディレクトリにあるconftest.pyで同名のフィクスチャを定義できます。reports/内のテストに対しては、より近い位置にある定義が優先されます。そして、もしそのフィクスチャが自分自身と同じ名前をパラメータとして要求した場合、親階層のバージョンを受け取ることができます:
reports/conftest.py:
import pytest
@pytest.fixture
def db(db):
"""The parent db, with three users already in it."""
db.executemany(
"INSERT INTO users VALUES (?, ?)",
[("asha", "Dhaka"), ("ravi", "Pune"), ("omar", "Cairo")],
)
return dbreports/test_reports.py:
def test_user_count(db):
count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 3
def test_cities_sorted(db):
rows = db.execute("SELECT city FROM users ORDER BY city").fetchall()
assert [city for (city,) in rows] == ["Cairo", "Dhaka", "Pune"]pytest -v:
============================= test session starts ==============================
collecting ... collected 4 items
reports/test_reports.py::test_user_count PASSED [ 25%]
reports/test_reports.py::test_cities_sorted PASSED [ 50%]
test_users.py::test_starts_empty PASSED [ 75%]
test_users.py::test_insert_one PASSED [100%]
============================== 4 passed in 0.51s ===============================実行順序に注目してください。reports/のテストが最初に実行され、毎回3人のユーザーを挿入しました — にもかかわらず、その後に実行されたtest_starts_emptyではテーブルは空のままでした。親のdbが、シード(初期投入)された行も含めてロールバックしたからです。オーバーライドは親の上に構築されているためです。--setup-showを見ると、両方ともdbという名前の2つのレイヤーが確認できます:
SETUP S connection
SETUP F db (fixtures used: connection)
SETUP F db (fixtures used: db)
reports/test_reports.py::test_user_count (fixtures used: connection, db) .
TEARDOWN F db
TEARDOWN F db(これは最初のテストの例であり、残りのテストも同じパターンを繰り返します。)オーバーライドの影響が及ぶのは下層のディレクトリのみです。最上位ディレクトリのテストからそれが見えることは決してありません。
--fixtures — ここで何を要求できるか?
フィクスチャが複数のconftest.pyファイルに分散していると、特定のテストで何を要求できるのかを確認する手段が必要になります。pytest --fixtures reportsを実行すると、そのディレクトリから見えるすべてのフィクスチャが一覧表示されます。リストの先頭にはpytestの組み込みフィクスチャ(第9章で解説)やインストールされたプラグインのフィクスチャが並び、自作のフィクスチャは末尾に表示されます:
------------------------ fixtures defined from conftest ------------------------
connection [session scope] -- conftest.py:15
One database connection, shared by the whole run.
db -- conftest.py:23
The shared connection; every write is rolled back after the test.
db -- reports/conftest.py:5
The parent db, with three users already in it.各エントリには、名前、スコープ(function以外の場合)、ファイルと行番号、そしてdocstringの先頭行が表示されます。この最後の部分こそが、共有フィクスチャのすべてに1行のdocstringを書くべき理由です。これこそが、チームのメンバーが実際に読むドキュメントになるからです。
autouse=True — そしてそのコスト
autouse=Trueとマークされたフィクスチャは、テストがそれを要求しているかどうかにかかわらず、その適用範囲にあるすべてのテストで使用されます。最上位のconftest.pyに次のフィクスチャを追加すると、テストが行を残したままにしていないかを検証できます:
@pytest.fixture(autouse=True)
def no_rows_left(connection):
"""After every test, check that the users table is empty again."""
yield
count = connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]
assert count == 0, f"{count} row(s) left behind"これは期待通りに機能します — connectionを直接使って書き込みを行い、ロールバックをスキップしたテストは、ティアダウン時に検出されます。しかしここで、データベースとはまったく無関係なテストを追加してみましょう:
test_text.py:
def slugify(title):
return title.strip().lower().replace(" ", "-")
def test_slugify():
assert slugify(" Hello World ") == "hello-world"pytest -q --setup-show test_text.py:
SETUP S connection
SETUP F no_rows_left (fixtures used: connection)
test_text.py::test_slugify (fixtures used: connection, no_rows_left) .
TEARDOWN F no_rows_left
TEARDOWN S connection
1 passed in 0.50sわずか1行の文字列テストが、データベースを開いて0.5秒もかかるようになってしまいました。これこそがautouseのコストです。それは目に見えず(test_slugifyのどこにもデータベースが関係しているとは書かれていません)、無条件です(そのconftest.py配下のすべてのテストに対して実行され、それを必要としないテストも含まれます)。autouseは、実行コストが低く、真に普遍的なセットアップに対してのみ使用するようにし、それを必要とするテスト群をカバーする最も限定された階層のconftest.pyに配置するようにしましょう。
どのフィクスチャが最初に作成されるのか?
テストが複数のフィクスチャを必要とするとき、pytestは3つのルールに従ってそれらを順序付けます:
- より広いスコープが先。 テストのシグネチャでの並び順に関係なく、sessionはmoduleより前、moduleはfunctionより前になります。
- 同一スコープ内では、
autouseフィクスチャが先。 - 依存されているフィクスチャが、それを必要とするフィクスチャより先。
test_order.py:
import pytest
log = []
@pytest.fixture(scope="session")
def server():
log.append("server")
@pytest.fixture(scope="module")
def connection(server):
log.append("connection")
@pytest.fixture
def user():
log.append("user")
@pytest.fixture
def cart(user):
log.append("cart")
@pytest.fixture(autouse=True)
def audit():
log.append("audit")
def test_order(cart, connection):
print(log)pytest -q -s:
['server', 'connection', 'audit', 'user', 'cart']
.
1 passed in 0.01sテスト関数はconnectionよりも先にcartを要求していましたが、モジュールスコープであるconnectionの方が、自身の依存関係であるserverの後に真っ先に構築されました。関数スコープのフィクスチャの中では、autouseであるauditが最初に来て、その後にcartが必要とするuserが来ました。ティアダウンは、これとまったく逆の順序で実行されます。これらのルールで決まらない順序に依存してはいけません。あるフィクスチャが別のフィクスチャより前に存在していなければならない場合は、パラメータとして名前を指定して明示的な依存関係にしてください。
完全な例
1つの共有インメモリデータベースに対してテストを行う、小さなショップ(shop)モジュールです。
shop.py:
def add_order(conn, customer, total):
conn.execute(
"INSERT INTO orders (customer, total) VALUES (?, ?)", (customer, total)
)
def revenue(conn):
return conn.execute("SELECT COALESCE(SUM(total), 0) FROM orders").fetchone()[0]
def top_customer(conn):
row = conn.execute(
"SELECT customer FROM orders GROUP BY customer "
"ORDER BY SUM(total) DESC LIMIT 1"
).fetchone()
return row[0] if row else Noneconftest.py:
import sqlite3
import time
import pytest
@pytest.fixture(scope="session")
def connection():
"""One in-memory database with the orders table, for the whole run."""
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE orders (customer TEXT, total REAL)")
yield conn
conn.close()
@pytest.fixture
def db(connection):
"""The shared connection; whatever a test writes is rolled back."""
yield connection
connection.rollback()test_orders.py:
from shop import add_order, revenue, top_customer
def test_revenue_starts_at_zero(db):
assert revenue(db) == 0
def test_add_order_counts_toward_revenue(db):
add_order(db, "asha", 250.0)
assert revenue(db) == 250.0
def test_no_top_customer_without_orders(db):
assert top_customer(db) is Nonereports/conftest.py:
import pytest
from shop import add_order
@pytest.fixture
def db(db):
"""The parent db, with four orders from three customers already in it."""
add_order(db, "asha", 250.0)
add_order(db, "ravi", 900.0)
add_order(db, "omar", 120.0)
add_order(db, "asha", 700.0)
return dbreports/test_reports.py:
from shop import revenue, top_customer
def test_total_revenue(db):
assert revenue(db) == 1970.0
def test_top_customer_by_total(db):
assert top_customer(db) == "asha"pytest -v --durations=1:
============================= test session starts ==============================
collecting ... collected 5 items
reports/test_reports.py::test_total_revenue PASSED [ 20%]
reports/test_reports.py::test_top_customer_by_total PASSED [ 40%]
test_orders.py::test_revenue_starts_at_zero PASSED [ 60%]
test_orders.py::test_add_order_counts_toward_revenue PASSED [ 80%]
test_orders.py::test_no_top_customer_without_orders PASSED [100%]
============================= slowest 1 durations ==============================
0.50s setup reports/test_reports.py::test_total_revenue
============================== 5 passed in 0.51s ===============================注目すべき点が3つあります。
第1に、時間のかかるセットアップはテスト実行全体の最初のテストで1回だけ発生し、他の場所では発生していません。これがセッションスコープの大きな効果です。
第2に、test_orders.py内の各テストは、レポートのテストが合計8件の注文を挿入した後に実行されたにもかかわらず、売上(revenue)が0であることを正しく確認できています。connection.rollback()をpassに置き換えて再度実行してみると、状態を共有してしまった代償がすぐに現れます:
FAILED test_orders.py::test_revenue_starts_at_zero - assert 3940.0 == 0
FAILED test_orders.py::test_add_order_counts_toward_revenue - assert 4190.0 =...
FAILED test_orders.py::test_no_top_customer_without_orders - AssertionError: ...
3 failed, 2 passed in 0.52s第3に、フィクスチャをインポートしているテストファイルは1つもありません。test_orders.pyがインポートしているのはテスト対象のコードであるshopだけであり、フィクスチャは最も近いconftest.pyから名前によって届けられます。
問題が起きたとき
ScopeMismatch: You tried to access the function scoped fixture db_name with a session scoped request object. 広いスコープのフィクスチャが、より狭いスコープのフィクスチャを要求しています。要求されているフィクスチャ(ここではdb_name)のスコープを、要求元のスコープと同じかそれ以上に広げるか、要求元のスコープを狭めてください。Requesting fixture stackの下にある2行を見れば、どちらがどちらであるかが分かります。
AssertionError: assert 'asha' == 'ravi' — ただし単独で実行するとテストはパスする functionより広いスコープのフィクスチャを通じて、テスト間に状態が漏洩しています。--setup-showを付けて実行してどのフィクスチャが共有されているかを確認し、フィクスチャを分割してください: コストの高いオブジェクトは広いスコープのまま保持し、各テストの変更を元に戻す関数スコープのフィクスチャを追加します。
fixture 'report_title' not found フィクスチャは存在していますが、このテストから見える場所にありません。conftest.pyはそのディレクトリおよびその配下のディレクトリにしかフィクスチャを提供しません — reports/conftest.pyにあるフィクスチャは、その上のディレクトリにあるテストからは見えません。両方をカバーする上位のconftest.pyに移動してください。pytest --fixtures path/to/test_file.pyを実行すると、そのファイルから何が見えているかを正確に確認できます。
dbがロールバックしているにもかかわらず、あるテストの行が別のテストに現れる 何かがcommit()を呼び出しています。ロールバックはまだコミットされていない内容しか破棄できないため、コミットを実行するコード — あるいはdbではなくconnectionを直接使っているテスト — は変更を永続化してしまいます。no_rows_leftのような保護用フィクスチャを用意しておけば、これをERROR at teardown ... AssertionError: 1 row(s) left behindという目に見えるエラーにして検出できます。
データベースに触れていないテストが遅くなった その上位のどこかにあるautouseフィクスチャが、コストの高いフィクスチャに依存しています。そのテストに対してpytest --setup-showを実行すると、テストが要求した覚えのないものも含め、実際に使用されたすべてのフィクスチャが一覧表示されます。
テストファイル内のfrom conftest import db conftest.pyからインポートしてはいけません。pytestはすでにフィクスチャを名前で提供してくれますし、import conftestと書くと、Pythonがたまたま最初に見つけたconftest.pyを読み込んでしまいます。上記のプロジェクトにおいて、最上位のtest_users.pyにこのインポート行を追加すると、reports/内のもの(シード用のオーバーライド)がインポートされてしまい、test_starts_emptyがassert 3 == 0で失敗しました。また、インポートされたフィクスチャはテストファイル自身で定義されたものとして扱われるため、すべてのconftest.pyよりも優先されてしまいます。フィクスチャはパラメータ名として指定することでのみ要求し、それ以外の方法をとらないようにしてください。
ステップ 4 / 6 — 予想する
理解度をチェック
pytest -q -sで実行した場合、test_reportは何を出力しますか?
import pytest
calls = {"module": 0, "function": 0}
@pytest.fixture(scope="module")
def conn():
calls["module"] += 1
@pytest.fixture
def row(conn):
calls["function"] += 1
def test_a(row):
pass
def test_b(row):
pass
def test_c(conn):
pass
def test_report():
print(calls)- A{'module': 1, 'function': 2}
- B{'module': 3, 'function': 2}
- C{'module': 1, 'function': 3}
- D{'module': 3, 'function': 3}
このテストは実行前にScopeMismatchエラーになります。最も小さく正しい修正方法はどれですか?
import pytest
@pytest.fixture
def settings():
return {"db": ":memory:"}
@pytest.fixture(scope="session")
def connection(settings):
return settings["db"]
def test_connects(connection):
assert connection == ":memory:"- A`test_connects`をクラスの中に入れる
- B`connection`に`autouse=True`を追加する
- Cファイル内で`settings`を`connection`の下に移動する
- D`settings`に`@pytest.fixture(scope="session")`を付与する
両方のconftest.pyファイルがdbを定義しています。reports/test_reports.pyのtest_countはどちらのdbを受け取りますか?
# conftest.py
@pytest.fixture
def db(connection):
yield connection
connection.rollback()
# reports/conftest.py
@pytest.fixture
def db(db):
db.execute("INSERT INTO users VALUES ('asha', 'Dhaka')")
return db
# reports/test_reports.py
def test_count(db):
...- Aトップレベルの`db`。先に読み込まれるため
- B`reports/`のもの。ただしその`db`パラメータは自分自身を受け取るため、無限に再帰する
- C`reports/`のもの。これはトップレベルの`db`を受け取り、その上に1行を追加する
- Dどちらでもない — 同名のフィクスチャが2つあるとエラーになる
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
2つの関数(add_book(conn, title, author)と、ソートされた書籍タイトルのリストを返すbooks_by(conn, author))を持つlibrary.pyを中心とした小さなプロジェクトを作成してみましょう。
- 最上位の
conftest.pyに、sqlite3.connect(":memory:")を開き、books (title TEXT, author TEXT)テーブルを作成し、低速なサーバーの代わりとして0.5秒スリープする、セッションスコープのconnectionフィクスチャを作成します。docstringも記述してください。 - その上に、テストごとにロールバックを行う関数スコープの
dbフィクスチャを追加します。 - それぞれ書籍を追加して
books_byを検証する3つのテストを含むtest_library.pyを作成します。pytest -q --durations=3を実行し、0.5秒のセットアップが1回だけ現れることを確認します。 - 最初から5冊の書籍が登録された状態になるように
dbをオーバーライドするsearch/conftest.pyと、それらに依存する2つのテストを含むsearch/test_search.pyを作成します。 pytest --setup-showを実行し、search/のテストに対する出力の中に2つのdbレイヤーを見つけてください。次にpytest --fixtures searchを実行し、両方のdocstringが表示されることを確認します。
その後、わざと2回壊してみましょう。まず、rollback()の行をpassに置き換えてスイート全体を実行します — どのテストが失敗し、それぞれのテストを単独で実行した場合はパスするでしょうか?次に、connectionが関数スコープのフィクスチャdb_nameに依存するように設定し、ScopeMismatchのメッセージを1行ずつ読んでみましょう。
この5分間の演習に、この章のすべてが詰まっています。スコープの選択は速度のための決断ですが、それによって引き起こされるバグは「テストの独立性(アイソレーション)」のバグです — そして、それらを見つけられるようになる唯一の方法は、自分自身で何度かバグを経験してみることなのです。
解答例
ファイル構成:
library.py
conftest.py
test_library.py
search/
conftest.py
test_search.pylibrary.py:
def add_book(conn, title, author):
conn.execute("INSERT INTO books VALUES (?, ?)", (title, author))
def books_by(conn, author):
rows = conn.execute(
"SELECT title FROM books WHERE author = ? ORDER BY title", (author,)
).fetchall()
return [title for (title,) in rows]conftest.py:
import sqlite3
import time
import pytest
@pytest.fixture(scope="session")
def connection():
"""One in-memory database with the books table, for the whole run."""
time.sleep(0.5) # stands in for a slow server handshake
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE books (title TEXT, author TEXT)")
yield conn
conn.close()
@pytest.fixture
def db(connection):
"""The shared connection; every test's writes are rolled back."""
yield connection
connection.rollback()test_library.py:
from library import add_book, books_by
def test_unknown_author_has_no_books(db):
assert books_by(db, "Nobody") == []
def test_one_book(db):
add_book(db, "Gitanjali", "Tagore")
assert books_by(db, "Tagore") == ["Gitanjali"]
def test_titles_come_back_sorted(db):
add_book(db, "Kabuliwala", "Tagore")
add_book(db, "Gitanjali", "Tagore")
assert books_by(db, "Tagore") == ["Gitanjali", "Kabuliwala"]search/conftest.py:
import pytest
from library import add_book
@pytest.fixture
def db(db):
"""The parent db, with five books by three authors already in it."""
add_book(db, "Gitanjali", "Tagore")
add_book(db, "Kabuliwala", "Tagore")
add_book(db, "Godaan", "Premchand")
add_book(db, "Nirmala", "Premchand")
add_book(db, "Palace Walk", "Mahfouz")
return dbsearch/test_search.py:
from library import books_by
def test_two_books_by_premchand(db):
assert books_by(db, "Premchand") == ["Godaan", "Nirmala"]
def test_author_match_is_exact(db):
assert books_by(db, "tagore") == []pytest -q --durations=3:
..... [100%]
============================= slowest 3 durations ==============================
0.50s setup search/test_search.py::test_two_books_by_premchand
(2 durations < 0.005s hidden. Use -vv to show these durations.)
5 passed in 0.51spytest -q --setup-show searchを実行すると、2つのテストそれぞれについて、本章で見たものと同じ様子が確認できます: SETUP F db (fixtures used: connection)に続いてSETUP F db (fixtures used: db)が表示され、親のレイヤーの上にオーバーライドのレイヤーが乗っています。pytest --fixtures searchを実行すると、[session scope]付きのconnection、ファイルと行番号付きの両方のdbフィクスチャ、そして3つすべてのdocstringが一覧表示されます。
1つ目の実験(rollback()をpassに置き換える)を行うと、次のようになります:
FAILED test_library.py::test_one_book - AssertionError: assert ['Gitanjali',....
FAILED test_library.py::test_titles_come_back_sorted - AssertionError: assert...
2 failed, 3 passed in 0.52sそして、pytest -q test_library.py::test_one_bookを単独で実行すると:
. [100%]
1 passed in 0.50s先に実行されたsearch/のテストが5冊の書籍を残していったため、test_one_bookではタゴールの書籍が1冊ではなく2冊見つかってしまいました。単独で実行すればパスします。これこそが、本章で解説した状態漏洩の典型的な特徴です。2つ目の実験では、5つのテストすべてがEになり、先ほど遭遇したのと同じScopeMismatchが今度はconftest.pyを指し示します: def connection(db_name)が要求元であり、def db_name()がスコープの狭すぎるフィクスチャです。
それぞれの判断の理由:
connectionをセッションスコープにする理由: 唯一時間のかかる処理であり、テストが行ういかなる操作も接続そのものを変更することはなく、変更されるのはテーブル内の行だけだからです。dbを関数スコープにする理由: 行は実際に変更されるからです。このフィクスチャの役割のすべては各テスト後のロールバックであり、テスト側はその名前以外にこのフィクスチャの存在を気にする必要がありません。- テストが
connectionではなくdbを要求する理由:connectionを直接受け取ったテストはロールバックをスキップしてしまい、次のテストへ変更内容を漏洩させてしまうからです。 search/のオーバーライドがconnectionではなくdbを要求する理由: シードされた書籍がテスト自身の作業の一部となり、親のロールバックによって他のすべてと一緒に削除されるようにするためです。もしdb内のクリーンアップ処理が将来変更されても、オーバーライド側は何一つ手を加えることなくその変更を引き継ぐことができます。- 計画に
test_author_match_is_exactが含まれている理由:"tagore"が"Tagore"に一致しないことを明確に規定するためです。これは将来誰かが変更したくなるかもしれない境界条件であり、失敗するテストによって意図的な変更であることを確認しながら変更すべきだからです。 - すべての共有フィクスチャにdocstringを書く理由: 次にコードを触る人が何を要求できるかを発見する手段こそが、
pytest --fixturesだからです。