(C#/.NET) Fluent Assertions を使ってみる
先日、ChainingAssertion の GitHub ページを見たら2022年に public archive になっていました。
代替手段としてFluent Assertions
というものがリンクにあったので試しに使ってみたので、その内容をメモします。
参考
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
使い方のパターン
実際に一通りドキュメントを読みながら、いくつか書いたテストコードの中で有用性の高そうなものや特徴のあるコードを紹介します。 なお、以下の公式ドキュメントを読めば大体の使い方はわかると思うので、読めばわかるようなコードは紹介しません。
文字列系
// 複数の期待値のうち、いずれかに一致すればテスト成功 "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
を使う場合はこちらもインストール推奨です。
実装したコード
参考までに、Fluent Assertions
を使って実装したコードのリンクを置いておきます。
https://github.com/Iyemon-018/Learning.CSharp.OSS/tree/main/src/Learn.FluentAssertions