Closed4

PyTestのfixturesコードリーディング

Yusuke IwakiYusuke Iwaki

https://note.com/navitime_tech/n/n61063b9e8fab

これみて、やっぱりFixturesいいよねーと思った。

Playwrightテストランナーのfixtures
https://playwright.dev/docs/test-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処理を抱え込んだものが作れる、というのだ。

Yusuke IwakiYusuke Iwaki

ところで、このfixturesというのはPlaywrightテストランナーで降って湧いてきた概念ではなく、PyTestのfixturesを大いに参考にしていると思われる。

https://docs.pytest.org/en/6.2.x/fixture.html

@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ロジックを書けるもの

がある。まんまやんけ。

Yusuke IwakiYusuke Iwaki

Playwrightテストランナーのfixtures周りの実装

// aaaとbbbがfixtureで、それぞれ123, 345と定義されているとする

test(xxx, ({aaa, bbb}) => {
  ...
})

なんとなく、テスト実行時には { aaa: 123, bbb: 345 } のようなオブジェクトを渡してテスト実行する、みたいな流れが想像できる。

テストで利用されるfixtureの抽出

そもそも { aaa, bbb } のパターンマッチパラメータからどうやって 'aaa' と 'bbb' を抽出しているのだろうか?JSでそんなことできるんだろうか?と気になった。

答えはソースコードにある。
https://github.com/microsoft/playwright/blob/eb31b9e4a9cb6688b5c687e6bc1b01e62910279f/src/test/fixtures.ts#L286

functionをtoString() したあと、力ずくで字句解析しているじゃないか!!

関数全体は

  • async function(...) { ... }
  • async () => { ... }

あたりのパターンを想定して正規表現でパラメータ定義部分を抽出。パラメータ定義部分は

  • ({a, b}) => { ... }
  • ({a: x, b: y}) => { ... }

あたりのパターンを想定して、先頭から字句解析している。

fixtureの検証

https://github.com/microsoft/playwright/blob/eb31b9e4a9cb6688b5c687e6bc1b01e62910279f/src/test/fixtures.ts#L187

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はどうやって実現しているのか?

https://github.com/microsoft/playwright/blob/eb31b9e4a9cb6688b5c687e6bc1b01e62910279f/src/test/fixtures.ts#L33

値を返すだけのもの(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を呼び出すケースもケアされていてもっと複雑)

Yusuke IwakiYusuke Iwaki

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() という関数パラメータ情報を取得する便利なものがあるので、そのあたりを使っているようだ。

https://github.com/pytest-dev/pytest/blob/3c451751af3f53fdb90aa6ac4afa2329222d54d2/src/_pytest/compat.py#L127

    # 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 など、処理を順に追っていくと、以下の分岐を発見。

https://github.com/pytest-dev/pytest/blob/69356d20cfee9a81972dcbf93d8caf9eabe113e8/src/_pytest/fixtures.py#L928

inspect.isgeneratorfunction() という「ジェネレータか否か」をはんべつする便利なものがあるらしい。
https://docs.python.org/ja/3/library/inspect.html

さて、これで分岐ができることが分かればあとは単純で、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は、ただ呼び出すだけ。

(まぁーー、なんてスマート!!)

このスクラップは2021/09/03にクローズされました