速習 AAA : Arrange-Act-Assert による読みやすいテスト


こちらは C# その2 Advent Calendar 2019 の 12 日目の記事です。

本記事では C# による単体テストの記述にあまり馴染みのない人を対象とし、シンプルかつ効果的なプラクティスの一つ Arrange-Act-Assert (AAA) パターンについて紹介します。
バリバリ書いている方は得るところがあまりないと思いますので、代わりにツッコミとか頂けますと幸いです。

まず単体テストを書く

単体テストを書くこと、それ自体は難しくありません。文字列を int に変換するメソッドのテストもすぐに書けます:

Assert.AreEqual(123, int.Parse("123"));

ではもう少し複雑なものはどうでしょうか:

Assert.AreEqual("01234567-89ab-cdef-0123-456789abcdef", new Guid("01234567-89ab-cdef-0123-456789abcdef").ToString("D"));

Guid のコンストラクタをテストしているのか、それとも ToString("D") をテストしているのか。
もしかしたら両方かも知れませんが、テストコードを見ても判断できません。
(意味のあるテスト名を記述していればそこで判りますが、だからと言ってテストコードが読み難くて良いとはならないので、ここでは GuidTest という曖昧なテスト名だったとします)

Arrange-Act-Assert パターン

Arrange-Act-Assert (AAA) パターンというのを訊いた事があるでしょうか。大まかには下記のようなものです:

// Arrange
var guid = new Guid("01234567-89ab-cdef-0123-456789abcdef");

// Act
var actual = guid.ToString("D");

// Assert
Assert.AreEqual("01234567-89ab-cdef-0123-456789abcdef", actual);

Guid の何をテストしているのか判然としなかった先ほどのテストも、AAA パターンで整理する事で一目瞭然となります。
// Act と書かれているコメント直下が Act セクションになり、ここでは Guid.ToString("D") のテストをしている事が判るようになりました。同様に // Assert と書かれている Assert セクションでは、Act の戻り値の文字列 actual が期待値 "01234567-89ab-cdef-0123-456789abcdef" と一致しているかの検査をしている事が判ります。

AAA パターンについては Microsoft Docs でも 単体テストの基本単体テストのベストプラクティス で紹介されています:

  • 単体テスト メソッドの Arrange セクションでは、オブジェクトを初期化し、テスト対象のメソッドに渡されるデータの値を設定します。
  • Act セクションでは、設定されたパラメーターでテスト対象のメソッドを呼び出します。
  • Assert セクションでは、テスト対象のメソッドの操作が予測どおりに動作することを検証します。

https://docs.microsoft.com/ja-jp/visualstudio/test/unit-test-basics?view=vs-2019#write-your-tests

こちらも参照してみて下さい。

テストケースのパラメーター化

上記 Guid の例では特定のパラメーターのみでテストしましたが、他のパラメーターでもテストを行いたいと思います。

その際、テストメソッドを分割しそれぞれに適したテスト名を付けるのがセオリーです。一方で AAA セクションが同一であれば、テストケースをパラメーター化する事で効率的にテストを記述する事もできます:

// Arrange
var guid = new Guid("01234567-89ab-cdef-0123-456789abcdef");

void TestCase(int testNumber, string format, string expected) {
    // Act
    var actual = guid.ToString(format);

    // Assert
    Assert.AreEqual(expected, actual, $"No.{testNumber}");
}

// Test cases
TestCase(1, "D", expected: "01234567-89ab-cdef-0123-456789abcdef");
TestCase(2, "B", expected: "{01234567-89ab-cdef-0123-456789abcdef}");
TestCase(3, "P", expected: "(01234567-89ab-cdef-0123-456789abcdef)");
TestCase(4, "N", expected: "0123456789abcdef0123456789abcdef");

例外ケースのパラメーター化

format パラメーターに未知のフォーマット "A" を入力した場合、Guid.ToString(format)FormatException をスローします。しかし、上記のテストコードでは例外テストは考慮されていない為、単に例外がスローされテストは失敗に終わります。

例外テストも考慮したテストケースのパラメーター化は次のように書く事ができます:

...
void TestCase(int testNumber, string format, string expected = default, Type expectedExceptionType = default) {
    // Act
    string ret = default;
    Type exceptionType = default;
    try {
        ret = guid.ToString(format);
    }
    catch (Exception ex) {
        exceptionType = ex.GetType();
    }

    // Assert
    Assert.AreEqual(expectedExceptionType, exceptionType, $"No.{testNumber}");
    Assert.AreEqual(expected, ret, $"No.{testNumber}");
}

// Test cases
...
TestCase(5, "A", expectedExceptionType: typeof(FormatException));

ここまでで例外テストを考慮したテストケースのパラメーター化ができましたが、代償として Act セクションに定型文が追加されてしまいました。Act を記述するたびにこれを書くのは面倒なので、ここでは TestAA を使用して記述を簡略化したいと思います。

TestAA による Act-Assert

TestAA は AAA パターンのうち Act-Assert のサポートを目的として作成した、単体テスト向けのシンプルなライブラリです。

先ほどのテストコードを TestAA で書き換えてみます:

// Arrange
var guid = new Guid("01234567-89ab-cdef-0123-456789abcdef");

void TestCase(int testNumber, string format, string expected = default, Type expectedExceptionType = default) {
    TestAA
        .Act(() => guid.ToString(format))
        .Assert(expected, expectedExceptionType, message: $"No.{testNumber}");
}

// Test cases
TestCase(1, "D", expected: "01234567-89ab-cdef-0123-456789abcdef");
TestCase(2, "B", expected: "{01234567-89ab-cdef-0123-456789abcdef}");
TestCase(3, "P", expected: "(01234567-89ab-cdef-0123-456789abcdef)");
TestCase(4, "N", expected: "0123456789abcdef0123456789abcdef");
TestCase(5, "A", expectedExceptionType: typeof(FormatException));

.Act() に渡されるラムダ式によって Act セクションが表され、.Assert() に渡される戻り値の期待値ならびに例外型の期待値によって Assert セクションがコンパクトに表現されています。

先ほどまで Act セクションに記述されていた try ~ catch は TestAA.Act() にカプセル化された為、Act セクションがとてもシンプルになりました。Act セクションの結果は TestActual<TReturn> の形で戻され、この中に戻り値または生じた例外が含まれています:

TestActual<string> actual = TestAA.Act(() => guid.ToString("A"));
// actual.Return: default(string)
// actual.Exception: FormatException instance

Assert セクションは TestActual<TReturn>.Assert(@return, exception) の形で表す事ができます。 @return パラメーターは戻り値の期待値を、exception パラメーターは例外型の期待値をそれぞれ渡す事で、内部で actual との比較検証が行われます。

actual.Assert(@return: default(string), exception: typeof(FormatException));

今回は TestAA をパラメーター化されたテストケースで使用しましたが、通常のテストコードでも AAA セクション分けとテスト検証をサポートしてくれます。

// Arrange
var guid = new Guid("01234567-89ab-cdef-0123-456789abcdef");

TestAA.Act(() => guid.ToString("D")).Assert("01234567-89ab-cdef-0123-456789abcdef");

詳細は README.md に記載してあります。ご活用いただければ幸いです (宣伝)

総括

単体テストにおいて、AAA パターンはとてもシンプルでありながら強力なプラクティスです。それは書き手にとっても読み手にとっても多くのメリットをもたらします。

// Arrange
...

// Act
...

// Assert
...

突き詰めればこれだけですので、初めて知った方や実践した事の無い方は是非一度試してみて下さい。


余談: 続)単体テストを書く

単体テストは人によっては重視されない事もありますが、自分の実装が自分の意図通り・設計通りである事を証明する為にとても有効な手段だと考えています。またそれは回帰テストという形で、将来の変更に対する容易さにも繋がります。

という事は誰しも朧気ながらに感じているところと思いますが、では実際に単体テストを書こうとすると、どうやって書いたら良いかで手が止まる事も少なくありません。

ちょうど 1 年ほど前に書いた リーダブルテスト という記事で、自分が業務で実践している読みやすい・書きやすいテストの指針を示しました。これに倣う事である程度は定型化した単体テストの記述が可能となり、迷いが減らせる事と思います。お悩みの方はこちらも一度ご覧頂ければと思います。