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

# いきなり現状報告

[GitHubにコードは上げました。](https://github.com/cozyupk/Nventx.Xunit/)  
けど、README.md を ~~GPT君に書いてもらっている~~ 書いている最中に「ん？これってそもそもxUnitの世界観と合ってなくね？」と気づいてしまい、現在絶賛仕様再考＆手戻り中です。

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

# xUnit の獣道へと・・・

### 背景: やりたかったこと

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

```csharp
[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]

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


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

```csharp
[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` ひとつ取っても以下のような内部構造が必要でした：

* [`ExceptionFactAttribute`](https://github.com/cozyupk/Nventx.Xunit/blob/14ff00406310b723d9e358ec190af58676d8f00e/src/NventX.XunitPkg/ForXunitV2/Impl/011100_ExceptionTesting/011170_ExceptionFactAttribute.cs)：ユーザが書くアノテーション
* [`ExceptionFactDiscoverer`](https://github.com/cozyupk/Nventx.Xunit/blob/14ff00406310b723d9e358ec190af58676d8f00e/src/NventX.XunitPkg/ForXunitV2/Impl/011100_ExceptionTesting/011160_ExceptionFactDiscoverer.cs)：アノテーションからテストケース実装(`IEnumerable<ITestCase>`)を見つけ出す
* [`ExceptionTestCase.cs`](https://github.com/cozyupk/Nventx.Xunit/blob/14ff00406310b723d9e358ec190af58676d8f00e/src/NventX.XunitPkg/ForXunitV2/Impl/011100_ExceptionTesting/011150_ExceptionTestCase.cs) : シリアライズ可能な`ITestCase`を実装するテストケース/RunAsyncでTestCaseRunnerのRunAsyncを呼ぶ。
* [`ExceptionTestCaseRunner`](https://github.com/cozyupk/Nventx.Xunit/blob/14ff00406310b723d9e358ec190af58676d8f00e/src/NventX.XunitPkg/ForXunitV2/Impl/011100_ExceptionTesting/011130_ExceptionTestRunner.cs) : テストケースの実行/CreateTestRunnerでTestRunnerを返す
* [`ExceptionTestRunner`](https://github.com/cozyupk/Nventx.Xunit/blob/14ff00406310b723d9e358ec190af58676d8f00e/src/NventX.XunitPkg/ForXunitV2/Impl/011100_ExceptionTesting/011130_ExceptionTestRunner.cs) : もはや謎 
* [`ExceptionTestInvoker`](https://github.com/cozyupk/Nventx.Xunit/blob/14ff00406310b723d9e358ec190af58676d8f00e/src/NventX.XunitPkg/ForXunitV2/Impl/011100_ExceptionTesting/011120_ExceptionTestInvoker.cs) : 実際のテストロジックのテンプレートをようやくここで実装

そして例えば、[こんなの](https://github.com/cozyupk/Nventx.Xunit/blob/14ff00406310b723d9e358ec190af58676d8f00e/src/NventX.XunitPkg/ForXunitV2/Impl/011100_ExceptionTesting/011130_ExceptionTestRunner.cs#L48-L50)、（ボクの後へと続く若者のために、）一見クールにコメント書いてますけど、この仕組み見つけたときはPCの前で震えましたw だって先頭大文字か小文字かの違いですよ？笑

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

参考: [沼を脱出した実装全体](https://github.com/cozyupk/Nventx.Xunit/tree/14ff00406310b723d9e358ec190af58676d8f00e/src/NventX.XunitPkg/ForXunitV2/Impl/011100_ExceptionTesting)

そして、テストのテスト・・・。これがもう Mock をどう作ればいいかというのをGPT君と一緒に手探りで。[このへん](https://github.com/cozyupk/Nventx.Xunit/blob/14ff00406310b723d9e358ec190af58676d8f00e/src/NventX.XunitPkg/ForXunitV2/UnitTests/019140_ExceptionTestCaseRunnerTests.cs#L42-L43) とか [このへん](https://github.com/cozyupk/Nventx.Xunit/blob/14ff00406310b723d9e358ec190af58676d8f00e/src/NventX.XunitPkg/ForXunitV2/UnitTests/019140_ExceptionTestCaseRunnerTests.cs#L51) とかマジ意味わからなさすぎてガチ沼案件でした。。。

でもなんとか、（すくなくとも）テストカバレッジだけは完璧。ぱーぺき。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4041503/685b61c3-5108-4ed7-a035-9f6dda8382eb.png)

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

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

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

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

![README GPT](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4041503/08c97095-bb90-43dc-b6c3-330fab6673b2.png)

# 課題発覚と新たな方向性

### そして発覚したこと

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` 属性を使用します。

```csharp
[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` メソッドを使用します。

```csharp
[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](https://github.com/cozyupk/Nventx.Xunit/tree/14ff00406310b723d9e358ec190af58676d8f00e) に書いておきましたが、詳しくは実装完了した暁にということで・・・。

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

:::note
次回からは README 書かせる前に、思想チェックもしとけってことだね！（でもまたやる）
:::

おしまい