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
について以下が分かった
- 単体で実行するとFAILする(期待通り)
- FAILするテスト(
01.test.js
など)のあとに実行するとFAILする(期待通り) - 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-circus
のrun_start
とrun_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される
すると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、ウェブアプリケーションだけじゃなくこういうところにも使われているという身近な例でした
おしまい