02エンジニアリング5 min

緑を出しながら嘘をついていたテスト

TODOS LOS TESTS OK と出力して終了コード 0 で終わるテストの中に、SCRIPT ERROR が六つ。緑が偽物だとどう見抜くか、そして信じる前に今は何をしているか。

著者: Salva Castells

カバー画像: Roland Tanglao, CC BY 2.0.
この記事の内容

Godot で作ったマルチプレイヤーゲームのテストが「TODOS LOS TESTS OK」と出力し、終了コード 0 で終わっていました。その同じ実行の中に SCRIPT ERROR が六つありました。うち二つは Compilation failed、もうひとつは Trying to assign value of type 'CanvasLayer' to a variable of type 'character_select_panel.gd' です。ランナーは終了コードを見ていて、終了コードは 0 で、それ以上は誰も見ていませんでした。

落ちようのないテストは、テストがないより悪い。テストがなければ、わかっていないことがわかります。偽の緑があると、わかっていると思い込みます。

緑の中の六つのエラー

失敗していたブロックは、宣言されている収集可能なオブジェクトを数えるためにレベルのシーン全体をインスタンス化していました。そのシーンは UI の領域を二つ引き連れていて、そのスクリプトが使う autoload は、エンジンを --script で起動すると存在しません。スクリプトはコンパイルできず、ノードは期待した型ではなく CanvasLayer として出てきて、Godot はプロセスを倒さずに stderr へエラーを吐きました。

醜いのはエラーではなく、テストがそのまま先に進み、その後のアサーションが半分だけ組み立てられたツリーの上で評価されていたことです。

修正はインスタンス化をやめることでした。シーンは PackedScene.get_state() で読みます。これはノード名、instance=、上書きされたプロパティを、_ready を一切実行せず、領域のスクリプトも読み込まずに返します。前提条件は本番のファイルに紐づいたままです。誰かがレベルからオブジェクトを取り除けば、テストは赤くなります。

トートロジーだったアサーション

同じテストにはもっと悪い問題がありました。stderr に何の音も立てないからです。前提条件は「レベルには N 個のテンプレートがある」と言っていて、その N はシーン自身から導かれていました。そのうえで変換が N を返すことを確認していました。それは必ず成立します。そこにあるテンプレートを変換することを確かめてはおらず、いま読んだ数字がそれ自身と等しいことを確かめているだけです。

いまその数字は手で書かれています。

const PLAYGROUND_TEMPLATE_COUNT := 7  # エディタで配置した7つのpickup

そして変換された各ペイロードは、.tscn の宣言と位置で一対一に突き合わされます。ホストが変換前に重力を一フレームぶん適用しているので 1 px の許容を入れ、さらに種類と数量も突き合わせます。7 を手で固定することが、「あるだけ全部変換する」と「そこにある 7 個を変換する」を分けます。

赤になりうることをどう確かめるか

テストが役に立つかどうかを知る方法は、わざとコードを壊して気づくかを見ることです。その修正のときに試した三つの変異を書き残しました。テンプレートを切り離す関数から remove_child を取り除くと三つのアサーションが赤になる。ペイロードの数量を 1 にすると二つが赤になる。シーンからオブジェクトをひとつ消すと前提条件が赤になる。

変異ひとつにつき三十秒。これが、テストが何かを守っているという唯一の本物の証拠です。赤になるところを一度も見たことがないテストは仮説です。

二インスタンスのテストを通していた競合

あるスクリプトはゲームのインスタンスを二つ、localhost 上の ENet でホストとクライアントとして立ち上げ、両方の状態を取得していました。緑でした。偽の緑を同時に三つ抱えていて、何かに触る前に三つとも再現しました。

  • ホストが途中で死んで 0 を返していた。 クライアントはローカルプレイヤーのいるフレームをすでに取得していたので、スクリプトは満足していました。いまは、ホストがスクリプト自身の SIGTERM で死ぬことも要求しています。測定される終了コードは 143 です。
  • pkill -f がエスケープされていないパスを ERE に渡していた。 パスに + が入ると一致せず、ホストはタイムアウトまで生き延びます。二十秒のはずが三分。. が入ると別の実行の Godot を殺しかねません。いまは直接の子プロセスの PID で殺します。
  • クライアントがフレーム番号でホストの存在を推測していた。 そして推測はできません。ホストする前の Godot は OfflineMultiplayerPeer を置くので、メニューでは get_unique_id() が 1 を返し is_server() が true を返します。これからクライアントになるインスタンスでも同じです。「ホストしている」という本物の指標は、その JSON に is_local を持つプレイヤーがいることだけです。

ついでに、autopilot の at は秒ではなく描画されたフレームを数えていることもわかりました。vsync があると、隠れたウィンドウやフォーカスのないウィンドウはコンポジタを待って 1〜3 fps まで落ちます。測ったところ、90 秒で 85 フレーム。--disable-vsync --max-fps 60 にすると安定して 51 fps でした。

ビルドするものではなく配布する成果物をテストする

いちばん手こずったのは文字起こしのデスクトップアプリでした。CI は緑で、マイクが死んだ .deb を公開していました。ビルドしたものを何ひとつ実行していなかったからです。

PyInstaller は sounddevice の wheel に入っている PortAudio を同梱していて、それは PulseAudio のバックエンドなしでコンパイルされたものでした。さらに alsa-lib のモジュールを持たない libasound.so.2 も。バンドルの中ではそれが PCM の pulse、pipewire、default を消し、排他アクセスの hw: だけを残します。PipeWire がすでにマイクを掴んでいたので、録音は Invalid sample rate [PaErrorCode -9997] で失敗しました。

再現するのはインストールした .deb だけです。開発中はシステムの PortAudio にリンクされ、そちらには Pulse があります。修正は libportaudio、libasound、libjack をバンドルから除外することでした。どれもすでに Depends で要求されていたものです。

修正よりも、CI で変えたことのほうが重要です。いまはパッケージングの前に、音声系のライブラリがバンドルに入っていないことと、バイナリが xvfb の下で起動することを確認します。そして .deb は libc6 (>= 2.43) を宣言していました。どの Ubuntu にもインストールできない下限です。これはバンドルの GLIBC タグの最大値を取った結果、つまりコンパイルするマシンからコピーされたライブラリに由来していました。アプリではなくビルド機を記述していたのです。いまはそれが本来何であるか、つまりリリース上の決定として手で宣言されています。ランナーのものである 2.35 です。

CI から Windows のビルドも外しました。丸ごとコメントアウトし、リリースの needs から build-windows を取り除きました。誰もテストしない成果物を緑に保つのは、得られるものより高くつきました。

いま新しいテストを前にして自問すること

二つあります。どちらか一方でも落ちれば、もう信用しません。ひとつめ。形を壊す変更ではなく本当に挙動を壊す変更で、それが赤くなるのを見たか。ふたつめ。期待値は判定対象のコードとは別のところから来ているか、それともそこから導いているか。

そして CI 用にもうひとつ。テストする対象は、誰かがダウンロードするファイルでなければならず、自分の run から出てきたものであってはなりません。