PyTestのfixturesコードリーディング
これみて、やっぱりFixturesいいよねーと思った。
Playwrightテストランナーのfixtures
- 値を返すもの
- async関数で、use() したものを返すもの
後者がとくに有用で、
helloWorld: async ({ hello }, use) => {
// Set up the fixture.
const value = hello + ', world!';
// Use the fixture value in the test.
await use(value);
// Clean up the fixture. Nothing to cleanup in this example.
},
setup/teardown処理を抱え込んだものが作れる、というのだ。
ところで、このfixturesというのはPlaywrightテストランナーで降って湧いてきた概念ではなく、PyTestのfixturesを大いに参考にしていると思われる。
@pytest.fixture
def mail_admin():
return MailAdminClient()
@pytest.fixture
def sending_user(mail_admin):
user = mail_admin.create_user()
yield user
admin_client.delete_user(user)
@pytest.fixture
def receiving_user(mail_admin):
user = mail_admin.create_user()
yield user
admin_client.delete_user(user)
サンプルコードを見ればわかるように、
- 値を返すだけのもの
- yieldしたものを返しつつ、setup/teardownロジックを書けるもの
がある。まんまやんけ。
Playwrightテストランナーのfixtures周りの実装
// aaaとbbbがfixtureで、それぞれ123, 345と定義されているとする
test(xxx, ({aaa, bbb}) => {
...
})
なんとなく、テスト実行時には { aaa: 123, bbb: 345 }
のようなオブジェクトを渡してテスト実行する、みたいな流れが想像できる。
テストで利用されるfixtureの抽出
そもそも { aaa, bbb }
のパターンマッチパラメータからどうやって 'aaa' と 'bbb' を抽出しているのだろうか?JSでそんなことできるんだろうか?と気になった。
答えはソースコードにある。
functionをtoString() したあと、力ずくで字句解析しているじゃないか!!
関数全体は
async function(...) { ... }
async () => { ... }
あたりのパターンを想定して正規表現でパラメータ定義部分を抽出。パラメータ定義部分は
({a, b}) => { ... }
({a: x, b: y}) => { ... }
あたりのパターンを想定して、先頭から字句解析している。
fixtureの検証
registrationというのがfixture定義のkey-valueを持っていて、
for (const name of fixtureParameterNames(fn, location)) {
const registration = this.registrations.get(name);
if (!registration)
throw errorWithLocations(`${prefix} has unknown parameter "${name}".`, { location, name: prefix, quoted: false });
if (!allowTestFixtures && registration.scope === 'test')
throw errorWithLocations(`${prefix} cannot depend on a test fixture "${name}".`, { location, name: prefix, quoted: false }, registration);
「知らない子ですね」というのはここで例外になる。
(実際にはfixturesは別のfixturesをもとに作ったりもできるので、再帰呼び出しでvalidateしている)
fixturesの代入
値のfixturesは単純に置き換えればよいだけだろうが、async/useのfixturesはどうやって実現しているのか?
値を返すだけのもの(registration)もasync/useのものも、ここでFixtureというオブジェクトに正規化される。
値を返すだけのものは typeof this.registration.fn !== 'function'
の分岐で処理されるので、
- setup処理: なし
- value: その値そのもの
- teatdown処理: なし
のようなFixtureになる。
async/useのものは、もう少し複雑で、
async (value: any) => {
if (called)
throw new Error(`Cannot provide fixture value for the second time`);
called = true;
this.value = value;
setupFenceFulfill();
return await teardownFence;
}, info)).catch((e: any) => {
if (!this._setup)
setupFenceReject(e);
else
throw e;
}
そもそも use()
は上のような定義のようで、valueを与えて呼ぶと
- valueをセット
- setupは完了したものとする
- teardown待ち状態でpendingになるPromise(teardownFence)を返す
のような挙動をする。
- setup処理: async関数を呼び出し、useのところでteardownFence待ちで止まる
- value: useに指定した値が入る
- teardown処理: teardownFenceをfulfillさせてasync関数を最後まで通す
なんとなくこんな感じのFixtureができあがる。(実際にはfixtureからfixtureを呼び出すケースもケアされていてもっと複雑)
PyTestは?
さて、本題。PyTestがPlaywrightのように力ずくで字句解析したり、setup/teardownをPromiseを駆使しているとは考えにくく、じゃあどうしているんだろう?
@pytest.fixture
から順にみる。
fixture定義
def fixture(
fixture_function: Optional[FixtureFunction] = None,
~~~~~~
fixture_marker = FixtureFunctionMarker(
scope=scope,
params=params,
autouse=autouse,
ids=ids,
name=name,
)
# Direct decoration.
if fixture_function:
return fixture_marker(fixture_function)
return fixture_marker
FixtureFunctionMarker というのは、デコレート先のfunctionに「これはPyTestのfixtureだぞ!」ってattrをいくつかつけたり事前検証を少ししたりしてるだけ。
- 値を返すだけのfixture
- yieldしたものを返しつつsetup/teardownロジックを書けるfixture
の両方がfixture_functionになりうる。
ちなみに、昔は後者は @pytest.yield_fixture
という別のデコレータだったが、最近はどちらも @pytest.fixture
になったようだ。
テストで利用されるfixtureの抽出
Pythonには inspect.signature()
という関数パラメータ情報を取得する便利なものがあるので、そのあたりを使っているようだ。
# The parameters attribute of a Signature object contains an
# ordered mapping of parameter names to Parameter instances. This
# creates a tuple of the names of the parameters that don't have
# defaults.
try:
parameters = signature(function).parameters
except (ValueError, TypeError) as e:
fail(
f"Could not determine arguments of {function!r}: {e}",
pytrace=False,
)
arg_names = tuple(
p.name
for p in parameters.values()
if (
p.kind is Parameter.POSITIONAL_OR_KEYWORD
or p.kind is Parameter.KEYWORD_ONLY
)
and p.default is Parameter.empty
)
fixturesの評価
まず、PyTestのfixtureは、
- 値を返すだけのfixture
- yieldしたものを返しつつsetup/teardownロジックを書けるfixture
いずれも単なる関数定義である。どうやって2つを判別しているのだろう?
pytest_fixture_setup
など、処理を順に追っていくと、以下の分岐を発見。
inspect.isgeneratorfunction()
という「ジェネレータか否か」をはんべつする便利なものがあるらしい。
さて、これで分岐ができることが分かればあとは単純で、setup/値/teardownの正規化処理は以下のようになっている。
if is_generator(fixturefunc):
fixturefunc = cast(
Callable[..., Generator[FixtureValue, None, None]], fixturefunc
)
generator = fixturefunc(**kwargs)
try:
fixture_result = next(generator)
except StopIteration:
raise ValueError(f"{request.fixturename} did not yield a value") from None
finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
request.addfinalizer(finalizer)
else:
fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
fixture_result = fixturefunc(**kwargs)
return fixture_result
- Pythonのジェネレータは途中で止まって再開する仕組みなので、それを素直に使っている。
ジェネレータでyieldされたものを返しつつ、yieldのあとの残りの処理を実行するための関数をfunctools.partial
で作って、ファイナライザ登録している。 - 値を返すだけのfixtureは、ただ呼び出すだけ。
(まぁーー、なんてスマート!!)