Dartのテストランナーの仕組み調べる
テストランナーがどう動いているのか追いかけてみる。
テストコードの中身って main()
で始まる普通の実行可能プログラムじゃないですか。
なのにmainの終端にたどり着いてからtestで設定したテストケースが走り始めてますよね。よく考えたら不思議じゃないですか?
ので、どうなってるのか知りたい。
するとexecutableの中身がそのままexportされている事がわかる
test_core
パッケージの executable.main
に本体があるらしいことがわかる
test_core
の main
を見ると _execute(args)
なる関数を呼んでいる。
直下にある runTests
というそれっぽい関数も同じ様になっているので、これが本体だろうとあたりをつけて掘り進む。
_execute()
を読むと、一般的なコマンドラインプログラムにありがちなオプションによる分岐があるので、これが本体で間違いなさそう。
バージョンを表示して終了、などすぐに終了しない系の箇所を追っていくと、 Runner
なるものを作って終了コードを得ている箇所がある。これがテストの実行であると当たりをつける
とりあえず結果出力は放っておいて、実行に関わる部分を見たい。
名前からして Loader
がテストケースを読み込み、 Engine
が実行するんだろう。
どちらも test_core
パッケージの中のものである。
Loader
はテストケースを読み込んで実行可能な形にするらしい。
Engine
はちょっとややこしくて、説明文が頭に入ってこない。
test suiteがどんな単位なのか正確にわかってない。
おそらく、 test()
関数一つの単位のことなのではないかと予想。
一旦 Runner
に戻る
run()
メソッドが本体なので、これを追う。
success
に値を入れてreturnする流れになっているので、 succsess
に代入をしている箇所を見つけて潜っていくことにする。
_config.pauseAfterLoad
如何で分岐している。
falseの場合は results.last
を最終的な結果としていて、 results.last
にあたるのは _engine.run()
の結果になることがわかる。
_loadThenPause()
も最終的に _engine.run()
を読んでいる。
次は Engine.run()
を掘る。
Runner.run()
と同様、真偽値で結果を必ず返すとのこと。
success
なので、どこかのメソッドでこれが変更されているっぽい。
_suiteController
から作った subscription
の onData
でやっている様子。
中でも _group.add()
多分この中のどれかの処理が success
をいじっているはず。
succsess
はgetterだった…
suiteがスキップされた場合と成功した場合にtrueを返すようになっていて、これは liveTests
が持っている状態を取得して見ている。
その前に _group
と _runPool
の終了を待ち受けているのも気になる。
liveTests
は passed
skipped
failed
active
をまとめたもの。
active
の中身は _active
の QueueList
だった。これに LiveTest
を挿入している箇所があるんだろうか。
_runLiveTest
が引っかかった。
これを見ると liveTest
に run()
メソッドが生えていて、これがテストを実行しているらしい。
実行の仕組みはここを掘っていけば良さそう。
LiveTest
は test_api
パッケージに入ってた。
これ自体はabstructなので、実装クラスが何種類かありそう。
suite
か run
を見るとどう実行しているかがわかりそう。
コードジャンプで実装クラスを辿れなかったので一旦LiveTestはスキップ。
Engineに戻って、 _runLiveTest
がどこから呼ばれているのか見る。
_runGroup
メソッドに突き当たり、それが Engine.run()
から呼ばれている経路があるのがわかる。
とすると、テストケースの実行は以下のコールスタックで実現されていそう。
-
test
のmain()
-
test_core
のmain()
_execute()
Runner.run()
Engine.run()
Engine._runGroup()
Engine._runLiveTest()
LiveTest.run()
実際に実行されているところを見つけるには、 LiveTest
の実装を調べれば良さそうだ。
あと、LiveTestのインスタンスを作っている箇所を見つけること。
Engine.run()
から _runGroup()
を呼んでいる箇所をよく見ると、 LiveSuiteController
という、どこかで見たことのあるものと名前が似ているものが出てくる。そう、LiveTestと何か関連があるんじゃなかろうか。
LiveSuite
を動かすものらしい。
_runGroup
。liveTestを生成している load()
メソッドがある。
load()
は Test
クラスのメソッドだった。
あと load()
メソッドで LiveTest
を作ってくれるとも書いてあるので、これの実装クラスを調べる必要がありそう。
RunnerTest
らしい。コードジャンプですぐ出てきた。LiveTestはコードジャンプできないの何故だ。
遠隔実行されるテストらしい。どういうこっちゃ。ブラウザでもテスト実行できるそうなので、多分DartVM以外の実行環境でも動かせるようになってるんだろうな。
load()
の実装を見ると LiveTestController
というものを作ってそのままreturnしている。そしてreturn型は LiveTest
型なので、 LiveTestController
は LiveTest
実装クラスだと想像がつく。
LiveTest
を継承しているので、このクラスを調べたらどう実行しているのかの手がかりが掴めそう。
_onRun
をそのまま実行している。
LiveTestController
の _onRun
には、先程の RunnerTest.load()
だとラムダ式が渡されている。
testChannel
というのにコマンドを送って、そのままStreamで落ちてくるイベントを待っているだけの様子。
FlutterのMethodChannelみたいなもんか。その向こう側の実装をコードジャンプで追うのは難しそうだ。
もしかして他の Test
実装クラスがあるんじゃないか?
ちょっと寄り道。
test_api
のbackendディレクトリ内に Suite
というクラスがあった。
Suiteはtestのsetだとのこと。
メンバにはGroupもある。各Suiteはtop levelに必ず一つのGroupを持っている、ということになりそう。
Group
はtestをグループ化するものと書かれている。
GroupEntry
は Group
と Test
のいずれかを表現。
構造的には Suite --(1..1)--> Group --(1..n)--> GroupEntry という形になるんだろうか。
Suite
のコンストラクタがどこから呼ばれているのかを調べて見たら何かわかるのではあるまいか。
hooks_testing.dart
の _createTest()
というそれっぽい関数名の関数。
引数 body
を LocalTest
に突っ込んで test.load()
で LiveTest
を作っている。
LocalTest
も気になるけど、この _createTest()
がどこで呼ばれているのかまず探ってみる。
TestCaseMonitor
のコンストラクタで呼ばれている。
TestCaseMonitor
はテストケースのbodyを取ってその実行状況を監視するためのもの。
staticメソッドとして持っている run
または start
でインスタンスを作っている。
このどちらかが呼ばれている箇所がテストケース実行の仕組みに迫れそう。
run
から見ていくことにする。
run
は結局 start
を呼んで終了を待っているだけっぽい。
start
は _liveTest
を走らせ始める。
コードジャンプではどこで呼んでいるかわからなかった。
埒が明かないので、テストコードから呼ぶ test()
関数から追ってみることにする。
test()
は test
パッケージに入っている…ように見えるが、 test_core
の test()
をそのままexportしただけだったりする。
body
として渡される。
body
は _declarer.test()
に渡されているので、このメソッドを追っていく。
_declarer
がどこから来ているのかも気になるが、一旦はメソッドの方を見ることにする。
declarerってなんて読めば良いんだ?ディクレアラー?
test()
メソッドは Declarer
クラスのメソッドだった。
現在のzoneに紐づいたDeclarerは current
というstaticなgetterで取得できるらしく、どうやらこれが _declarer
であるみたいだ。
Zone
は昔Crashlyticsのプラグインの中身読んでたときに見た気がするな。キャッチされていない例外を捕まえるために使っていたのが珍しかったのでよく覚えている。
よく知らないので後で改めてよく調べておこう。
Declarer
の中身を流し読みしていたら build()
というメソッドを見つけた。 Group
を一つだけ返すメソッドのようだ。
確か Suite
が一つだけtop levelの Group
を保持する構造になっていたから、これの戻り値が Suite
に入るんじゃないのか?
test_api
パッケージの RemoteListener
の staticメソッド start()
の中で Suite
の引数に渡されていた。
start()
のドキュメントコメントとシグネチャを見てみると、 getMain
なる、Functionを返すFunctionを引数に取っていることがわかる。
これがテスト用Dartファイルのmainを取得しているんじゃないのか?
getMain
がどこから来るのかを追ってみる。
RemoteListener.start()
は test_core
パッケージの serializeSuite()
関数から呼ばれている。
こいつも引数から getMain
を受け取っている。
serializeSuite()
は internalBootstrapVmTest()
と internalBootstrapNativeTest()
から呼ばれていて、どちらも引数から getMain
を取っている。
通常のDartのテスト実行はDart VMで行うことが多いように思うので、一旦 internalBootstrapVmTest()
の呼び出し元を追いかけることにする。
_bootstrapIsolateTestContents()
はDartのソースコードを文字列で生成する関数。
テスト対象のファイルをimportで読み取って、このmain関数を返すようにしたラムダ式が getMain
の正体だった様子。
_TestCompilerForLanguageVersion._generateEntrypoint()
があったがほぼ同じソースコードを文字列で生成している。
どちらを追うのが正解かわからないが、一旦 _bootstrapIsolateTestContents()
を追いかけて、 dart test
コマンドからどうやってここに至るのかわかるまで追いかけたい。
_bootstrapIsolateTestContents()
は VMPlatform._bootstrapIsolateTestFile()
から呼ばれている。
テスト実行用の一時ファイルを作成してそのUriを返している。
VMPlatform._spawnIsolate()
。 compiler
が Compiler.source
だったときに VMPlatform._bootstrapIsolateTestFile()
を呼んでいたようだ。
load()
メソッドから呼ばれていた。引数の path
がテストファイルのパスのようだ。
load()
は PlatformPlugin
が基底クラスとして持っているメソッド。
VMPlatform
のコンストラクタが呼ばれる箇所を探したら、 Loader
のコンストラクタに出てきた。
Loader
のコンストラクタが呼ばれるのは… Runner
の初期化時だ!!!
これで dart test
コマンドからテストファイルがどう実行されるかの流れは見当がついた。
一応ファイルパスが渡されるはずの VMPlatform.load()
がどこから呼ばれるか追いかけておく。
Loader.loadFile()
から呼ばれていることがわかった。引数の path
がそのまま VMPlatform.load()
に渡されている。
Loader.loadFile()
が呼ばれている箇所が2箇所あったが、一箇所は Loader.loadDir()
だった。
ディレクトリの中身をpathとして渡しているのがわかる。
Loader.loadFile()
も Loader.loadDir()
も、どちらも Runner._loadSuites()
で使用されていた。
_config.testSelections.entries
がテストファイルのパスと Set<TestSelection>
のMapになっていて、key側のファイルパスがpathとして渡されている。これで疑問は大体解決した。
Runner._loadSuites()
は Runner.run()
で呼ばれている。
Runner.run()
は以前にも見た _execute()
から呼ばれている。これでコールスタックは大体わかった!
ざっくりまとめると、
- 我々(test系パッケージの)ユーザーが書いてるテストコードは、テスト実行用のdartファイルにimportされており、mainは関数として実行される(一般的なエントリーポイントとして使われているわけではない)
- その中で実行された
test()
関数によって、Zoneごとにテストの実行計画?が作られる
ということらしい。
更に掘り下げるならば、テスト実行用のdartファイルがどのように実行されるのかと、Zoneの概念だろうか。
Zone.current[#key]
的なやつはzone local valueというやつっぽい。
Javaのローカルスレッドっぽい。
まだ推測だけど、テスト用のソースコードが例外吐いても中止せずに処理を続行したいのでテスト用のソースコードごとにZoneを作っていたりするんだろう。その単位でZone作っていて、Declarerも作ってるとしたら納得。
手がかりになりそうなのは #test.declarer
なるシンボル。
これが zoneValues
でセットされている箇所が4箇所。
全部 Declarer
のメソッドで、thisを #test.declarer
に対応するインスタンスとして渡している。
declare()
というそのまんまなメソッド。自身を参照できるようにしたZoneでbodyの関数を実行するだけ。
これは RemoteListener.start()
で呼ばれている。
ご丁寧に引数が main
だ。
このmainはユーザー定義のテストコードの main関数の様子。
ここから、このZoneはテストケース定義収集の間だけ使われていて、テストケース実行時は別のZoneっぽいことがわかる。
declare()
は group()
からも呼ばれており、これは子グループ用に新しく作ったDeclarerインスタンスを渡すために使われていた。
残りの #test.declarer
でzoneValue作ってる箇所は setUpAll や tearDownAll に対応した部分、test関数に対応した部分だった。各テストのコールバックも共通のDeclarerが使えるようになっている様子。
あと調べるとしたらテスト実行用に作られた一時ファイルがどう実行されるか、か。
VMPlatform._spawnIsolate()
から辿ってみる。
_bootstrapIsolateTestFile()
でやっていて、その File
インスタンスが _spawnIsolateWithUri()
に渡されているのがわかる。
Isolate.spawnUri()
でIsolateを起動している。
Isolateはいわゆるスレッドのようなもので、Dartの場合、Isolate単位でイベントループが管理されている…んじゃなかったっけ…?復習する必要がある。
Erlangのアクターモデルみたいなもの、と言われるとすんなり理解できる。
昔々にElxilrを触ろうとしてErlangをやったことが理解の助けになろうとは。
とりあえず指定したファイルを別Isolateで実行するメソッドがある。
ドキュメントによれば、これがmainを実行するので、この機能を使ってテスト実行用一時ファイルを呼び出している、と。internalBootstrapVmTest()
の中を見ると、 MultiChannel
とか IsolateChannel
とかのクラスが出てくる。
おそらくこれがテストランナー本体と、実行中のテストファイルの間の通信を取り持っているのだけれど、どんなものなのかわからないので調べておく。
test
パッケージの話が書かれている。
ブラウザでも動かすため、VMでのIsolateの動作とブラウザでの動作を抽象化して取り扱うため、どちらもStreamChannelとして取り扱えるようにするために使っている様子。
StreamChannel
は送受信が可能なStream…ということらしい。
StreamとStreamShinkの両方の機能を持っているものといえば StreamController
が思いつくけれども、これじゃ駄目なの?
まあabstract classなので様子見。
virtualChannel()
で仮想チャンネルを払い出し、チャンネルに対応するIDのチャンネルを相手方で作れば双方向通信ができると。
通信経路はStream/StreamShinkで抽象化できるものならなんでも良いっぽいので、これでSoketとReceivePort/SendPortの抽象化を実現しているっぽい。
なんとなくわかった
VMPlatform.load()
の中に対応する MultiChannel
を作っていると思しき箇所があった。
送られてきたIDを元に、Platform側でもvirtualChannelを発行している様子が見て取れる。
Platformでは受け取ったSuiteをどうしているのか?
deserializeSuite()
関数がある。
RunnerSuiteController
ができるらしい。
コードを見ていると Test
と名の付くクラスがたくさん出てくる。
一体誰がどんな役割をしているのだろう。
Test
。 test_api
にいる。
抽象クラスであり、メソッドは実装を持っていない。
一回使い切りのLiveバージョンのテストを払い出す load()
というメソッドの定義がある。
LiveTest
も test_api
にいる。
これは実行状態だとかを持ったテスト1件あたりを表現するクラスらしい。
LiveTest
を実装しているのが LiveTestController
。これも test_api
のもの。
同じライフサイクルで LiveTest
が動くのを保証するらしい。
こいつは Invoker
のコンストラクタで生成されていて、 Invoker._onRun()
は LiveTestController.onRun()
で呼び出される様になっている。
で、 Invoker._onRun()
の中にテストのbody関数を呼び出すコードが有る。
この _test
は LocalTest
という型らしい。
LocalTest
も test_api
所属。
このIsolateに置けるTestとのことで、こいつはテスコードのの実装であるbody関数をメンバに持っている。
話は戻って Invoker._onRun()
、それを呼び出す LiveTestController.onRun()
、更にそれを呼び出す箇所を辿っていくと RemoteListener._runLiveTest()
に出てくる。
更に呼び出し元をたどると RemoteListener._serializeTest()
の中で testChannel
にセットされたstreamのlistenerの中で、'run'
なるcommandが来た時に呼ばれているのがわかる。
つまり、テストランナーはテストファイルの数だけ通信できるIsolateを立ち上げ、立ち上げた先のIsolate内でテストを実行させて結果を報告させている、ということみたい。
test_api
ばかりだったが、 test_core
にも Test
継承クラスがある。
RunnerTest
は多分テストランナー本体内におけるテストケースに対応するもの、という意味でRunnerTestなんだろう。
load()
メソッドの実装を見ると、対になっている RemoteListener
に 'run'
commandを送っているのがわかる。