best-test-frameworkの話

TL, DR

  • 自分だけのJSテストフレームワークを作ろう
  • concurrentで動かすと分からん
  • jestのmonorepoはよく考えて分けられているので、設計に迷ったら参考にしたい

はじめに

仕事でハンズオンをしている

「君だけのJest、best-testing-frameworkを作ろう」という内容

てくてく進めていくと、jestっぽいフレームワークができていき、nodejsでcli toolを作るときにあるあるなコードが書けるようになるので、ハンズオンとしてめっちゃ良い

今回は、これでconcurrentでハマったのでそれの話

ハマり

ハンズオンに従って、jest-circus packageを追加して、以下のコードを追加した

const {testResults} = await run();
    testResult.testResults = testResults;
    testResult.success = testResults.every((result) => !result.errors.length);

これで、node index.mjsを走らせると、FAILするはずのcircus.test.jsがPASSするパターンに4,5回に1回遭遇するようになった

はい、concurrent周りのデバッグ大変なやつです、本当にありがとうございました

というわけで、せっせとparallelらへんのコードをsequencialにします

const hasteMap = new JestHasteMap.default({
  extensions: ['js'],
  maxWorkers: 1,
  name: 'best-test-framework',
  platforms: [],
  rootDir: root,
  roots: [root],
})

const { hasteFS } = await hasteMap.build()
const worker = new Worker(join(root, 'worker.js'), {
  enableWorkerThreads: false,
  numWorkers: 1,
  forkOptions: {
    silent: false
  }
})

workerの数を1個にして、workerのconsole.log を表示するためにforkOptionのsilentをfalseにする

テストの実行順を固定するためにhasteFSで取得したtest fileの配列をそのままコードに書く

const testFiles = [
  '<YOUR PATH>/best-test-framework/tests/01.test.js',
  '<YOUR PATH>/best-test-framework/tests/02.test.js',
  '<YOUR PATH>/best-test-framework/tests/03.test.js',
  '<YOUR PATH>/best-test-framework/tests/04.test.js',
  '<YOUR PATH>/best-test-framework/tests/05.test.js',
  '<YOUR PATH>/best-test-framework/tests/06.test.js',
  '<YOUR PATH>/best-test-framework/tests/mock.test.js',
  '<YOUR PATH>/best-test-framework/tests/circus.test.js'
]

circus.test.jsの内容はこちら

// tests/circus.test.js
describe('circus test', () => {
  it('works', () => {
    expect(1).toBe(1);
  });
});
 
describe('second circus test', () => {
  it(`doesn't work`, () => {
    expect(1).toBe(2);
  });
});

色々テストファイルを絞ったり増やしたりを繰り返すとcircus.test.jsについて以下が分かった

  1. 単体で実行するとFAILする(期待通り)
  2. FAILするテスト(01.test.jsなど)のあとに実行するとFAILする(期待通り)
  3. PASSするテスト(02.test.jsなど)のあとに実行するとPASSする(🤔)

テスト実行した際の結果に依存してなんか変わっているらしい

ということでおもむろにjest-circusに飛んで中身を読む

READMEにあるようにflux baseのstate managementをするtest runnerらしい

ファイルを眺めてもeventHandler.tsとかそれっぽいのが並んでいる

どんなイベントが来ているのか雑にlogを仕込んで走らせてみると3の場合、add_testイベントらへんのエラーハンドリングコードに引っかかっているっぽい

    case 'add_test': {
      const {currentDescribeBlock, currentlyRunningTest, hasStarted} = state;
      const {asyncError, fn, mode, testName: name, timeout} = event;

      if (currentlyRunningTest) {
        currentlyRunningTest.errors.push(
          new Error(
            `Tests cannot be nested. Test "${name}" cannot run because it is nested within "${currentlyRunningTest.name}".`,
          ),
        );
        break;
      } else if (hasStarted) {
        state.unhandledErrors.push(
          new Error(
            'Cannot add a test after tests have started running. Tests must be defined synchronously.',
          ),
        );
        break;
      }

hasStartedがtrueなので、unhandledErrorsに落ちている

ハンズオンのコードに戻って以下のようにやると、きちんとFAILするようになった

    const {testResults, unhandledErrors} = await run()
    testResult.testResults = testResults 
    testResult.success = testResults.every((r) => !r.errors.length) && unhandledErrors.length === 0 

こうすると不思議なのが、2の挙動

jest-circusrun_startrun_finishイベントは対かと思いきや、run_finishではなんのstateも変更されない

    case 'run_start': {
      state.hasStarted = true;
      global[TEST_TIMEOUT_SYMBOL] &&
        (state.testTimeout = global[TEST_TIMEOUT_SYMBOL]);
      break;
    }
    case 'run_finish': {
      break;
    }

つまり、3が起こるなら、2もunhandledErrorsに入ってきて、PASSになるのでは?という疑問

ということでもう一度コードに立ち返ると何が起こっているかが分かる

こちらが01.test.js

expect(1).toBe(2);

このコードをevalすると、特にtest関数やdescribe関数にwrapされているわけでもないので、即座にErrorがthrowされる

runkit.com

するとcatch句に落ちるので、await run()までたどり着かない

よって、jest-circusの出番はなく、次のテストファイル(circus.test.js)を実行しにいく

そこにdescribeとtest関数が書かれているので、jest-circusのstateが変更され、テストがきちんとFAILするという流れになっている

これらが合わさると、冒頭のこれになっていたのでした

これで、node index.mjsを走らせると、FAILするはずのcircus.test.jsがPASSするパターンに4,5回に1回遭遇するようになった

ちなみに、ここまで色々やってきたがこの挙動を期待通りにするにはjest-circusからresetState関数をimportしてきてevalの前に実行するようにすれば良い

  try {
    resetState()
    eval(code)
    const {testResults, unhandledErrors} = await run()

最後に

ハンズオンをやっている最中に気になったことを深ぼったら、jestのstate managementのコードを読みに行くことになって楽しい

flux baseのstate management、ウェブアプリケーションだけじゃなくこういうところにも使われているという身近な例でした

おしまい