やる気駆動型エンジニアの備忘録

WPF(XAML+C#)の話題を中心に.NET/Android/CI やたまに趣味に関するブログです

(C#/.NET) Fluent Assertions を使ってみる

先日、ChainingAssertion の GitHub ページを見たら2022年に public archive になっていました。 代替手段としてFluent Assertionsというものがリンクにあったので試しに使ってみたので、その内容をメモします。

参考

fluentassertions.com

github.com

Fluent Assertionsはテストコードを自然言語のように記載することができるフレームワークです。 .NET のテスティングフレームワークアサーションAssert.Equals(expected, actula)のような形式が一般的で、地味に書き心地は良くないです。 同じようなアサーションでもFluent Assertionsであれば、actual.Should().Be(expected)のように記載することができます。

環境

  • .NET 7
  • Visual Studio 2022 Comunity
  • Fluent Assertions 6.10.0
  • xUnit.net 2.4.2

Fluent Assertions のバージョンはこのエントリ記載時点で最新のものを使用しています。 テスティングフレームワークは xUnit.net や NUnit、MSTestV2、NSpec、MSpec に対応しています。 今回は xUnit.net を使用しています。

インストール方法は以下を参考にしてください。

NuGet Gallery | FluentAssertions

基本的な使い方と特徴

基本的には検証したい値に対して.Should().~のようにアサーションを書いていきます。 アサーションは単体でも書けますし、And条件を追加することもできます。

string actual = "ABCDEFGHI";

// Should からアサーションが開始される。以降、条件を設定しつつアサーションを書いていく。
actual.Should().StartWith("AB")   // 先頭に AB があること
      .And.EndWith("HI")        // かつ、末尾が HI であること
      .And.Contain("EF")        // かつ、EF が含まれること
      .And.HaveLength(9);       // かつ、9文字であること。

Fluent Assertionsの特徴の一つがこれです。 Shouldから始まる事によって、アサーションそのものが値の確認とドキュメントとしての役割を果たしています。 テストコードを書くメリットの一つに「テストコードがドキュメントとしての役割を持つ」ことがありますが、よりわかりやいドキュメントとして成立させることができるようになります。

もう一つの特徴が「テスト失敗時になぜテストが失敗したのかわかりやすいメッセージを出力する」という点です。 Introduction の Getting startedにもありますが、通常のテストフレームワークよりも出力される情報量が多く、何が原因でテストが失敗しているのかわかりやすくなっています。

例えば、次のコードはテストに失敗します。

var numbers = new[] {1, 2, 3};
numbers.Should().HaveCount(4, "because we thought we put four items in the collection");

このとき、次のような失敗メッセージが出力されます。

Expected numbers to contain 4 item(s) because we thought we put four items in the collection, but found 3: {1, 2, 3}.

他にも、次のようなコードの失敗メッセージも、その原因がわかり易い内容で出力されます。

object theObject = null;
theObject.Should().BeOneOf(1, "obj2", DateTime.Now);

Expected theObject to be one of {1, "obj2", <2023-04-14 00:39:55.9124061>}, but found .

下記はAbout - Why?の引用ですが、明確な理由を説明していないテストの失敗メッセージは厄介で、失敗の原因を探るためにデバッガ地獄に陥る可能性があります。 ひとつのテストケースでひとつのことだけをテストするなどの対策もできますが、Fluent Assertionsを使えば失敗メッセージに含める情報が増えるためより効率的に対策することができます。 また、このメッセージはアサーションを実行する際にカスタマイズした情報も出力することができるようになっています。

Nothing is more annoying than a unit test that fails without clearly explaining why. More than often, you need to set a breakpoint and start up the debugger to be able to figure out what went wrong. Jeremy D. Miller once gave the advice to “keep out of the debugger hell” and I can only agree with that.

For instance, only test a single condition per test case. If you don’t, and the first condition fails, the test engine will not even try to test the other conditions. But if any of the others fail, you’ll be on your own to figure out which one. I often run into this problem when developers try to combine multiple related tests that test a member using different parameters into one test case. If you really need to do that, consider using a parameterized test that is being called by several clearly named test cases.

なお、アサーションに対して失敗メッセージがどのように出力されるかについては、以下のページに記載されています。 fluentassertions.com

使い方のパターン

実際に一通りドキュメントを読みながら、いくつか書いたテストコードの中で有用性の高そうなものや特徴のあるコードを紹介します。 なお、以下の公式ドキュメントを読めば大体の使い方はわかると思うので、読めばわかるようなコードは紹介しません。

fluentassertions.com

文字列系

// 複数の期待値のうち、いずれかに一致すればテスト成功
"This is a String".Should().BeOneOf("That is a String", "This is a String");

// 期待値の文字列が2箇所以上含まれていればテスト成功
"This is a String. This is a String.is a".Should().Contain("is a", AtLeast.Twice());
// 次のコードは上記と同じことを検証している。
"This is a String. This is a String.is a".Should().Contain("is a", 2.TimesOrMore());

// ワイルドカードを使用した期待値が一致していればテスト成功
"firstname.lastname@example.com".Should().Match("*@*.com");

// 正規表現を使用して、期待値が一致していればテスト成功
"This is a String.".Should().MatchRegex("This\\s+is");

特に面白いのは.Contain()の部分です。 実際の値に対して、期待値がいくつ含まれているかを検証します。

シグニチャは以下のようになっています。

public AndConstraint<TAssertions> Contain(string expected, OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs)

このOccurrenceConstraintが回数を表しています。 以上、以下などの表現は次のようになっています。

指定方法 日本語訳 サンプル
Exactly ちょうど~ Exactly.Once()で「一度だけ」
AtLeast 少なくとも~ AtLeast.Twice()で「少なくとも2回以上」
MoreThan ~より多い MoreThan.Thrice()で「3回より多い」
AtMost せいぜい~ AtMost.Times(5)で「5回以下」
LessThan 未満 LessThan.Twice()で「2回未満」

すべての指定方法には1回Once(), 2回Twice(), 3回Thrice(), n回Times(n)のメソッドが用意されているので必要に応じて適宜指定すれば良いです。

また、OccurrenceConstraintには数値で回数を表現する拡張メソッドが用意されており、2.TimesOrMore()ような表現もできます。 (個人的にはこっちのほうが好み)

  • 3.TimesExactly() = 3回 = Exactly.Thrice()と同じ
  • 3.TimesOrLess() = 3回以下 = AtMost.Thrice()と同じ
  • 3.TimesOrMore() = 3回以上 = AtLeast.Thrice()と同じ

実際の値が文字列であれば、.Match(), .NotMatch()ワイルドカードが使用できます。 .MatchRegrex(), .NotMatchRegrex()正規表現を使用できます。

数値系

数値系で特記したいのは「丸め誤差に対するアサート方法」です。 Fluent Assertionsでは、丸め誤差に対するアサート方法が2種類あり、それぞれ次のようになります。

// 期待値に範囲を取るパターン。以下の例では実際の値が 0.3~0.31 であればテスト成功となる。
(0.1 + 0.2).Should().BeInRange(0.3, 0.31);

// 期待値に近似値を使用するパターン。以下の例では実際の値が期待値(0.3)の誤差 ±0.01 に収まっていればテスト成功となる。
(0.1 + 0.2).Should().BeApproximately(expectedValue: 0.3, precision: 0.01);

日時系

日時は英語圏の書式で表現することができます。

var theDatetime = 1.March(2010).At(22, 15).AsLocal();
theDatetime.Should().Be(1.March(2010).At(22, 15));

正直、この書式に慣れていないと読みづらく感じるので、無理せず以下のようにしたほうが良いかな、と思います。

theDatetime.Should().Be(new DateTime(2010, 3, 1, 22, 15, 0));

また、特定部分の数値が一致しているかどうかを確認することもできます。

var theDatetime = new DateTime(2010, 3, 1, 22, 15, 0);

// .Have~(expected) 系は、年月日時分秒が一致しているかどうかをテストできる。
// .HaveDay() であれば、日付部分のみ一致しているかどうかを見ている。
theDatetime.Should().HaveYear(2010);
theDatetime.Should().HaveMonth(3);
theDatetime.Should().HaveDay(1);
theDatetime.Should().HaveHour(22);
theDatetime.Should().HaveMinute(15);
theDatetime.Should().HaveSecond(0);

コレクション系

コレクション系のアサートは以下のように「数+なにか」を確認する形式が多くなると思います。

IEnumerable<int> collection = new[] { 1, 2, 5, 8 };

collection.Should()
          .HaveCount(c => c > 3)            // 要素数が 3 より大きい
          .And.OnlyHaveUniqueItems();       // コレクションの要素の値がすべて一意であることを検証する。

他にも、入れ子になった要素へアクセスするには.Witchを使用します。

var singleEquivalent = new[] { new { Size = 42 } };
singleEquivalent.Should().ContainSingle()
    .Which.Should().BeEquivalentTo(new { Size = 42 });

最初の.Should()でコレクションそのものに対するアサートを行い、.Witch.Should()入れ子の要素に対するアサートを行います。 複雑なコレクションであっても、これでテストできます。

例外系

スローされた例外がArgumentNullExceptionの場合、スローされる原因となった引数名を検証することもできます。

var action = () => new TestTarget().ThrowArgumentNullException(null, "Smith");
action.Should().Throw<ArgumentNullException>().WithParameterName("value");

他にも、一定期間中に例外がスローされて、時間が経過すると例外がスローされなくなるような動作も検証することができます。 何らかの異常時から復旧するような振る舞いをテストするときとかに使えそうですね。 (ドキュメントでは「特定の時間経過すると復旧するネットワークをテストするときなど」と記載している。)

// こんなクラスをテストするものとする。
public class TestTarget
{
   private readonly Stopwatch _stopwatch = new();

   public bool Retry()
   {
       if (!_stopwatch.IsRunning) _stopwatch.Restart();
       if (_stopwatch.Elapsed < 2.Seconds()) throw new InvalidOperationException("まだ2秒経過してないよ。");

       _stopwatch.Stop();
       return true;
   }
}
var testTarget = new TestTarget();
var action    = () => testTarget.Retry();

// 第一引数は「例外がスローされなくなる時間」、第二引数は例外がスローされてから再度 action を実行するまでの間隔。
// つまり、以下は例外がスローされてから次の実行まで1秒間待機して、3秒経過したときには例外がスローされなければテスト成功となる。
action.Should().NotThrowAfter(3.Seconds(), 1.Seconds());

上記の例では、AAA パターンの Arrange に該当するコードがちょっと冗長なので、以下のように書くこともできます。

// FluentActions.Invoke() は同期アクション
// FluentActions.Awaiting() は非同期アクション
// FluentActions.Enumerating() は列挙シーケンスを実行する際に使用することができる。
FluentActions.Invoking(() => new TestTarget().ThrowArgumentNullException(null, string.Empty)).Should().ThrowExactly<ArgumentNullException>();

実行時間を計測する系

ある処理の実行時間を計測することもできます。

Worker worker = new Worker();

// あるメソッドの実行時間を計測し、その時間が期待値以下かどうかを判定することができる。
// 次のように、<テスト対象クラス>.ExecutionTimeOf() で計測対象のメソッドを呼び出し
// .Should().BeLessThanOrEqualTo() で時間を指定することができる。
worker.ExecutionTimeOf(x => x.Work()).Should().BeLessThanOrEqualTo(100.Milliseconds());

時間の判定方法には次のようなものがあります。

  • .BeGreaterThanOrEqualTo(5.Seconds()): 5秒以上
  • .BeLessThanOrEqualTo(5.Seconds()): 5秒以下
  • .BeGreaterThan(5.Seconds()): 5秒より長い
  • .BeLessThan(5.Seconds()): 5秒未満
  • .BeCloseTo(5.Seconds(), 200.Milliseconds()): 5秒から±200ミリ秒の誤差の範囲

Web API

HttpClientを使って Web API を呼び出すようなアサートも用意されています。

using var client   = new HttpClient();
var response = await client.GetAsync("https://xxx.com/api/~~");

// .HaveStatusCode() で具体的なステータスコードを判定する。
response.Should().HaveStatusCode(HttpStatusCode.OK);
// .BeSuccessful() は 2xx 系かどうかを判定する。
response.Should().BeSuccessful();

// HttpResponseMessage では、以下のような検証も可能になっている。
// 例: ヘッダーに"Content-Type"が含まれていて、一つの値を持っていて"application/json; charset=utf-8"であること。
response.Content.Headers
        .Should().ContainKey("Content-Type")
        .WhoseValue
        .Should().ContainSingle(x => x == "application/json; charset=utf-8");

API テストでヘッダー情報の検証をする場合は重宝しそうです。

また、レスポンスのペイロードを検証する際はFluentAssertions.Jsonをインストール必要があります。 以下参照 github.com

Newtonsoft.Json.Linq.JTokenにパースした json に対してアサートを定義することでテストできます。

// これを使うことで、JToken に変換した json オブジェクトの検証が可能になる。
// 次のコードでは、応答結果の json に"longitude"という要素が含まれているかどうか、かつその値に"135.5"があるかどうかを検証している。
JToken.Parse(await response.Content.ReadAsStringAsync()).Should().HaveElement("longitude")
      .Which
      .Should().HaveValue("135.5");

厳密な比較や特定の要素の値をキャストしてアサートすることもできるので、詳細はドキュメントを参照してください。

Analyzer

アサートの書き方によってはテスト失敗時のメッセージに出力される情報が制限され、原因調査に必要な情報が得られない場合があります。 いくら気を使っていても人間なので推奨されない記法になってしますが、FluentAssertions Analyzersを使えば Visual Studio のクイックアクションでより自然言語のように、かつ必要な失敗メッセージを出力できるようなコードを提案してくれます。

具体的には以下のリンクを参照してほしいですが、Fluent Assertionsを使う場合はこちらもインストール推奨です。

github.com

実装したコード

参考までに、Fluent Assertionsを使って実装したコードのリンクを置いておきます。

https://github.com/Iyemon-018/Learning.CSharp.OSS/tree/main/src/Learn.FluentAssertions