1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

xUnit山の獣道で沼にはまって遭難しかけてる話 ~ExceptionFactへの挑戦~

Last updated at Posted at 2025-05-29

いきなり現状報告

GitHubにコードは上げました。
けど、README.md を GPT君に書いてもらっている 書いている最中に「ん?これってそもそもxUnitの世界観と合ってなくね?」と気づいてしまい、現在絶賛仕様再考&手戻り中です。

まぁでも趣味なので笑。外圧ではありません。仕様を決めたのも自分、変えるのも自分。趣味の世界だからこそできるUターン開発ですw

xUnit の獣道へと・・・

背景: やりたかったこと

xUnitでは「例外が起きることを期待するテスト」は Assert.Throws を使うのが定番です。

[Fact]
public void Throws_IfAlreadyDone()
{
    // Arrange (本来Actかも?)
    DoOperation();

    // Act && Assert
    var ex = Assert.Throws<InvalidOperationException>(() => DoOperation()); // 2回目以降は例外を投げる想定
    Assert.Contains("already", ex.Message);
}

ただ、次のようなモヤモヤを感じていました:

  • テストメソッドが「例外を期待している」ことが、アノテーション(属性)から一目でわかってほしい
  • Assert.Throws の書き方だと、どうしてもロジックに埋もれて見えがち
  • 何より、メソッドの中身が仰々しすぎる。できれば一行でスッと書きたい
  • MSTestやNUnitにはある ExpectedException 的な思想を、xUnitにも持ち込めないか?1

そんなこんなで ExceptionFact という属性をつくり、次のような構文で例外テストを書ける世界を目指しました:

[ExceptionFact(typeof(InvalidOperationException), "already")]
public void Throws_IfAlreadyDone()
{
    DoOperation();
    DoOperation(); // 2回目以降は例外を投げる想定
}

これなら、「このテストはこの例外を期待しているんだな」というメタ情報が、属性だけで明示できる!
という思想のもとでスタートしたのが今回の拡張です。

実装の沼:xUnitは思ったより黒魔術

正直、FactAttribute を継承してポリッとやれば済むかと思っていました。
が、現実は ITestCase / XunitTestCase / ITestMethod あたりの世界が思った以上に深く、以下のようなトラップに悩まされることになります。

  • 属性の引数にはプリミティブ型しか使えない → Type はOKだけど、Func やラムダ式は当然NG
    • 要するに「シリアライズ可能」でなければならないということ
    • これは「テストの哲学」なのか「ランナー側の技術的都合」なのか…ちょっと微妙
      • おそらくは、CI/CDとの親和性も含めて「テストケースの生成」と「実行」を別プロセス(リモート含む)で分離したいという設計思想が背景にありそう
  • そして、ITestCase をカスタムで実装するにも、シリアライズまわりの対応が必要になる
  • xUnitの内部動作をある程度理解していないと、"礼儀正しい" ITestCase での拡張実装が書けない(=VSやCLIでの出力の整合性が取れない)

結果的に、ExceptionFact ひとつ取っても以下のような内部構造が必要でした:

そして例えば、こんなの、(ボクの後へと続く若者のために、)一見クールにコメント書いてますけど、この仕組み見つけたときはPCの前で震えましたw だって先頭大文字か小文字かの違いですよ?笑

参考日本語訳:

注意: ここでは、パラメーターとして渡されたアグリゲーターを使用します。これは、パラメータとして提供されたアグリゲーターが基底クラスのアグリゲーターの子(ネストされた)アグリゲーターである可能性があるためです。これにより、例外が適切に伝播し、意図されたスコープ内で隔離されることが確保されます。

参考: 沼を脱出した実装全体

そして、テストのテスト・・・。これがもう Mock をどう作ればいいかというのをGPT君と一緒に手探りで。このへん とか このへん とかマジ意味わからなさすぎてガチ沼案件でした。。。

でもなんとか、(すくなくとも)テストカバレッジだけは完璧。ぱーぺき。
image.png

GitHubへ初回コミット:勢いって大事

ということで「もうこれでいい!テスト業界に一石投じてやる!」ということで、勢いでGitHubにプゥーッシュ!:

git remote set-url origin https://github.com/cozyupk/Nventx.Xunit.git
git push -u origin master

そしておもむろに GPT に README.md を書かせるという雑っぷり(笑)
Deep Research にも対応してくれてありがたい。

README GPT

課題発覚と新たな方向性

そして発覚したこと

READMEを校正中のことです。いろいろと事実確認を行っていました。

cozyupk

ExpectedException hides the assert and obscures intent. Assert.Throws makes the intent explicit.

これって誰がどこで言ってるの?

ChatGPT(4o)

あ〜それ、xUnit作ったBrad Wilsonが言ってます。「ExpectedExceptionは意図が読めないから、Assert.Throwsを明示的に書け」って。

その話、Hadi Hariri氏のブログにもまとまってて、ほらコレです:

https://hadihariri.com/2008/10/17/testing-exceptions-with-xunit/

ということで

まさかの、「えー、GPT君、あんなに楽しく一緒にコードもテストも書いたじゃん!早く言ってよぉぉ~~(孤独のグルメ)」案件の発覚ですw

[ExceptionFact] って、これ、xUnitの世界に全然馴染んでないじゃんwww

GPT君が挙げてくれたブログ記事の要約は次のような感じです。(by GPT)

従来の ExpectedException 属性の問題点

多くのテストフレームワークでは、特定の例外が発生することを期待する際に ExpectedException 属性を使用します。

[TestMethod]
[ExpectedException(typeof(AuthenticationException))]
public void Authenticate_With_Invalid_Credentials_Throws_AuthenticationException()
{
    AuthenticationServices services = new AuthenticationServices();
    services.Authenticate("user", "wrong");
}

この方法では、指定された例外が発生すればテストは成功と判断されますが、例外がどこで発生したかは考慮されません。そのため、例えば AuthenticationServices のコンストラクタ内で AuthenticationException が発生しても、テストは成功してしまいます。これは、意図しない場所で例外が発生しても検出できないという問題を引き起こします。

xUnit における Assert.Throws の利点

xUnit では、ExpectedException 属性の代わりに Assert.Throws メソッドを使用します。

[Fact]
public void Authenticate_With_Invalid_Credentials_Throws_AuthenticationException()
{
    AuthenticationServices services = new AuthenticationServices();
    var ex = Assert.Throws<AuthenticationException>(() => services.Authenticate("user", "wrong"));
    Assert.Equal("Authentication Failed", ex.Message);
}

この方法では、例外が発生することを期待するコードをラムダ式で明示的に指定します。これにより、例外が発生する場所を正確に特定でき、意図しない場所での例外発生を検出できます。また、発生した例外オブジェクトを取得できるため、例外メッセージなどの詳細な検証も可能です。

テストは一つの状況に対して行うべき

記事では、Assert.Throws を使用して一つのテストで複数の例外を検証することは推奨されていません。各テストは一つの状況に対して行うべきであり、異なる例外が発生する場合は、それぞれ別々のテストケースとして分けるべきです。

このように、xUnit の Assert.Throws を使用することで、より明確で信頼性の高い例外のテストが可能になります。特に、例外が発生する場所や内容を正確に検証したい場合に有効です。

さて、どうするのか?

さんざん沼って実装した [ExceptionFact] が、実は xUnit の設計思想の裏をいく危険実装だったという話。このままでは立ち直れません。。。

ということで、今後の方向性は Github の README.md に書いておきましたが、詳しくは実装完了した暁にということで・・・。

ここでGPT君からの締めの一言です。

次回からは README 書かせる前に、思想チェックもしとけってことだね!(でもまたやる)

おしまい

  1. NUnitでは現在 ExpectedException は非推奨です。より明示的な Assert.Throws スタイルを推奨しています。

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?