JUCEでエラーを黙殺しないテストの構築

目次

Introduction

JUCEはC++フレームワークだが各所にJUCE固有の特徴が見え隠れしている。そのひとつがjassertというアサーション機構だ。jassertfalsejassert()の引数を取らない常に失敗するやつ)は、デバッグ実行時にはアサーションエラーを引き起こし、その後何事もなかったかのようにアプリケーションを続行する。これがもしassertであれば、アプリケーションはきちんとクラッシュする。

これはアプリケーション実行時にユーザーが直面するクラッシュを引き起こさないという意味では開発者が望む機能と言えるかもしれない。しかしコードの品質を上げるためには、このようなエラーを握りつぶすjassertfalseを引き起こすようなコードはテストでは看過されるべきではない。せっかくjassertfalseを起こしたことで発覚したエラーは、正しくテストの失敗として報告される必要がある。VBでOption Strictを外したり、テストメソッド全体をtry ... catch (Exception e) {}で囲まれたようなコードでは、まともなテストとは言えない。現在のJUCEのエコシステムにはこの考え方が欠落しているのである。

われわれはコードの品質を向上させるために、CIサーバー上でビルドを行いテストを実行する(べきだ)。なぜCIが重要なのかというと、JUCEの場合はさまざまな環境でビルドして実行できるものの、プラットフォーム固有のオプションを指定する余地がさまざまな場所に存在していて、特定のプラットフォームでしかビルドできないコードが書かれる可能性が高く、また同じコードでもプラットフォームによって失敗するという状況が頻繁に生じるためである。これはJUCE固有の問題ではなく、クロスプラットフォーム開発ツール全般に生じる問題だ。

しかしJUCEコミュニティにはそもそもCIサーバー上でビルドとテストを実行する慣習が無い(断言)。問題のあるコードがCI上のテストで露見したらきちんと失敗を報告する、というのはQAの基本なのに、それが出来ていないのである。何はともあれ、JUCEコミュニティにそういう意識が無いのであれば、自分でまとめるしかない。そう思って書いているのがこの文章ということになる。

JUCEのテストはどうやって書くのか

「まともなテストはどうやって書くのか」という話をする前に、まずそもそも「JUCEではどうやってテストを書くのか」というレベルの話をしなければならないだろう。JUCEコミュニティにはそもそもJUCEのunit testingに関する知見がほとんど共有されていない。

JUCEにはjuce_coreモジュールの中にUnitTestRunnerというクラスがあり、これを自分でどこかしらで呼び出して実行することになる。JUnitNUnitのように単体で実行できるtest runnerがあるわけでもなければ、IDE統合でテストフレームワークがサポートされているわけでもない。JUCEアプリケーションの場合、GUIを使うか、仮にGUIが使われていないとしてもアプリケーションループを使うことが多い関係で、一般的には、アプリケーションループを立ち上げてから、その中でUnitTestRunnerを使うことになるのではないかと思う。

UnitTestRunnerは、runAllTests()を呼び出すと、登録されているUnitTestを全て実行する。この「登録」とは何なのかというと、UnitTestクラスの変数を宣言(あるいはnew)すると、そのコンストラクターの中で自動的にUnitTestRunnerが実行対象とするインスタンスのリストに自身を追加するのである。テスト作成者としては、テストクラスを定義してそのインスタンスを宣言するだけでよいことになる。

さてこのUnitTestの仕組みは、JUCEを使っているプロジェクトでどのように使われているのだろうか。

先例1: JUCE

先のJUCEのUnitTestの書き方は、JUCEのソースコードから読み取ることが出来る。実際、英語圏のJUCEまわりのコミュニティでUnitTestRunnerやUnitTestの使用例が見つからない、と発言したら、JUCEのソースを見てみろ、と言われるだろう(経験談)。ちなみにテストはこんな感じで実装と同じコードの中に含まれている。というか、juce::UnitTestの使用例がほとんどこのリポジトリにしか見つからない。

https://github.com/WeAreROLI/JUCE/blob/3a4c0f901204a1aeec05597421da80acff403a4a/modules/juce_core/containers/juce_AbstractFifo.cpp#L171

問題は、JUCEのリポジトリではテストを実行している様子が見られないということだ。CI関係のセットアップとしては、なぜかgitlabの設定ファイルがある。CIだけgitlabでプライベートリポジトリを作って運用しているのかもしれないし、結局使っていないのかもしれない。外部の人間にとっては参考にならない、ということだけがわかる。

先例2: tracktion_engine

わたしがよくコードを追っかけているtracktion_engineのdevelopブランチでは、Azure Pipelinesを使用してLinux, Mac, Windowsの3プラットフォームでビルドを実行していて、わたしもこれをベースに自社プロダクトでAzure Pipelinesをセットアップした。

しかしこのtracktion_engineのCI設定にはテスト実行が伴っていない。実のところテストそのものが「かろうじて存在しているけど、ほとんど存在していないと言っていいレベル」である。

/sources/tracktion_engine$ grep -nR UnitTest tests modules/tracktion_engine/
modules/tracktion_engine/tracktion_engine.h:132:    If enabled, these will be added the UnitTestRunners under the "Tracktion" category.
modules/tracktion_engine/utilities/tracktion_ConstrainedCachedValue.cpp:16:class ConstrainedCachedValueTests   : public juce::UnitTest
modules/tracktion_engine/utilities/tracktion_ConstrainedCachedValue.cpp:20:        : juce::UnitTest ("ConstrainedCachedValue ", "Tracktion") {}
modules/tracktion_engine/model/edit/tracktion_TempoSequence.cpp:1172:class TempoSequenceTests : public UnitTest
modules/tracktion_engine/model/edit/tracktion_TempoSequence.cpp:1175:    TempoSequenceTests() : UnitTest ("TempoSequence", "Tracktion") {}
/sources/tracktion_engine$ git log -n 1
commit 9a844e09134b701efe6056bcd2964acf7fa809f0 (HEAD -> develop, origin/develop)

JUCEのテストコードをCIサーバー上で実行する

前節では、JUCEのUnitTestRunnerがアプリケーションの任意の位置に追加して自分で実行すべきものになっている、という話を書いた。テストを実行するためには、まずビルドしなければならないし、もしProjucerで生成したファイルをリポジトリに含めていなければ、Projucerを実行するところから始めなければならない。

CIサーバー上でProjucerを実行するやり方については以前にここで書いたとおりなのだけど、必要な部分をもういちど適宜抜粋する。

atsushieno.hatenablog.com

特にJUCEアプリケーションを実行する時に必要になるのは(Linuxの場合は)xvfb-runだ。

xvfb-run -a --server-args="-screen 0 1280x800x24 -ac -nolisten tcp -dpi 96 +extension RANDR" 実行したいコマンド

CIターゲットがmacOSWindowsの場合は、単純にアプリケーションを実行するだけでよい。もっともMacの場合はbashから呼び出すものは Foo.app/Contents/MacOS/Foo のようになるだろう。

わたしの場合は、ここにビルドとテストを両方実行するスクリプトを渡している(xvfb-runをProjucerとテスト実行の両方で指定するのが面倒なので)。実行プログラムは--run-unit-testsという引数があったらテストを実行して自動的に終了するように作られている。

jassertfalseを退治する

さて、ここまでやっておけばCIサーバー上で単体テストを実行してリグレッションが無いか確認することができる。と思うわけだが、実際にはこれでは十分ではない。そう、本稿の契機になったjassertfalseがエラーを握りつぶす問題である。実際に次のようなテストを追加して実行してみよう。

class DummyTest : public UnitTest {

  public DummyTest() : UnitTest("DummyTest") {}

  void runTest() {
    String s{"\u3102"};
    expectEquals(s.length(), 1, "length");
  }
}

このコードは間違っているのだが、それに気付くJUCEアプリケーション開発者はほとんどいないかもしれない。正しいコードはこうだ。

    String s{CharPointer_UTF8("\u3102")};

こう書かないとStringのコンストラクターは入力文字列がASCIIの範囲に収まらない文字を認識するやいなやjassertfalseを引き起こす。これは予期しないエンコーディングの文字列を渡しがちな開発者にとっては有り難いjuce::Stringの挙動だ。ではこういうコードが含まれていたらrunTest()の結果はどうなるか?

JUCE Assertion failure in juce_String.cpp:345
All tests completed successfully

"All tests completed successfully" である。メッセージだけではなく、テスト結果を格納するUnitTestRunner::TestResult.failuresにはご丁寧に0が入っている。何をバカな…と思われるかもしれないが、jassertfalseはエラーを報告した後で握りつぶすので、テスト結果としてはこのような挙動になるのである。これではまともなQAにならない。expect()やexpectEquals()でチェックしているコード以外でも、jassertfalseが発生した場合にはきちんと失敗扱いしてCI上も「テスト失敗」として報告される必要がある。

そういうわけで、jassertfalseを「退治する」必要がある。これを実現するアプローチは複数ある:

(1) jassertfalseの出力するメッセージをLoggerで捕捉して、最後に発生していたjassertfalseに対処する

比較的簡単に実現できる方法としては、テスト実行中にjassertfalseによって呼び出されるLogger::writeToLog()が呼び出されるLoggerインスタンスLogger::setCurrentLogger()で独自に設定して、UnitTestRunner.runAllTests()が終わった後にjassertfalseの呼び出しがあったかどうか検出する、というやり方だ。

jassertfalseの呼び出しがあった場合には、アプリケーションをクラッシュさせることでexit codeに0以外を返させて、CIに「失敗」扱いさせるのが手っ取り早い。ちなみに、このときLoggerで記録しておいたメッセージを全部出すと親切そうにも見えるが、実際にLoggerに渡されるのはJUCE Assertion failure in juce_String.cpp:345のようなスタックフレームのない情報なので、ほとんど参考にならない。開発者に"JUCE Assertion failure"でログをgrepしてもらったほうが早いだろう。

実際には、jassertfalseがLogger::writeToLog()を呼び出すようにするためには、JUCE_LOG_ASSERTIONSという定数が0以外に定義されていないといけないので、これを*.jucerファイルで記述しておいて(Projucerで全体設定の部分にある定義済みシンボルに追加する)、プロジェクトを再生成する必要がある。

理想を言えば、jassertfalseは各テストのスコープのうちにfaluresがきちんと記録されるようにしておきたいものだが、UnitTest.runTest()のスコープからアクセスできる範囲にはTestResultが存在しない(派生クラスでUnitTestRunnerを渡すようにしても、UnitTestRunner.getTestResult()の戻り値は参照ではないので値を変更できない)ので、そこは妥協するしかなさそうだ。

(2) jassertfalse自体を書き換えてエラーを握りつぶさないようにする

もうひとつ、こちらはJUCEコミュニティで教えてもらった方法として、単なるマクロとして定義されているjassertfalseを書き換えて throw std::runtime_error{"message"} みたいな内容にする、というやり方だ。AppConfig.hで定義すれば、JUCE本体のソースコードを書き換える必要もないはずである。

最初のjassertfalseでいきなり例外を出してテストが中断されるのは、テストフレームワークとしてはNGだ。複数人でプロジェクトを開発していて、誰かのテストが失敗する状態にmasterが陥っていた場合、それが直るまで他のテストが正常に通過しているかどうかが分からないというのでは、まともな開発ができない。だからここはthrowではなく、あくまで「後で失敗していたことがわかるように記録しておく」やり方が望ましい。

成果

この仕組みに基づいてテストランナーを実装した結果(ほんの5-6行のコードだ)、テスト中にjassertfalseが発生したら、CI上もエラー終了するようになった。

f:id:atsushieno:20190913002743p:plain
assertion failures cause crashes