xunit.netで、テストに対して独自の属性を付ける(Trait)

# はじめに

[dotnet/runtime](https://github.com/dotnet/runtime)の単体テストでは、OuterLoop属性を付けることで、CI/CD時の通常のユニットテストフローでは実行しないような単体テストを作成することができる。

この機能がどのような仕組みに基づいて作られているかということを調べたので、記事にする。

# xunit.net

まず、dotnet/runtimeのテストフレームワークには [xunit.net](https://xunit.net) が使われている。
このフレームワーク自体の詳しい解説は、今回の記事では触れないので、[公式ドキュメント](https://xunit.net/#documentation)を参考にしてほしい。

# 独自の属性を付ける意味

いくら単体テストは一つ一つ短くするべきと言われても、何百、何千(何万?)という巨大プロジェクトでは、全体を通して実行すると多大な時間がかかる場合がある。そして、CI/CDで回す時ならともかく、個別コンポーネントでテストを回したいときは全体ではなくて一部だけでテストを行いたいものである。
そこで、[dotnet testには、`--filter`オプションを付けて実行するテストをフィルタリングできる機能がある。](https://docs.microsoft.com/en-us/dotnet/core/testing/selective-unit-tests?pivots=xunit)
xunitを使っている場合は、名前(FullyQualifiedName,DisplayName)でフィルタリングすることが可能となっている。
しかし、名前だけでは分類できない属性も時にはある。

例えば、dotnet/runtimeでは、実行に時間がどうしてもかかってしまうテストというものが存在している。
そのようなテストはPRを出した時に実行されるCIで障害になってしまうため、デフォルトでは除外したい。

このような処理を実現したい場合に使うのが今回紹介するTraitとなる。

# やり方

xunitで独自の属性を付与したい場合、

* 独自の属性
* 独自の属性が付いたテストを集めるもの

の二つが必要になる。
以下でサンプルを用いて説明していく。

## 独自の属性

独自の属性については、以下のようにC#属性のクラスに更に `Xunit.Sdk.TraitDiscovererAttribute` を追加する。

```csharp:HogeAttribute.cs
using System;
using Xunit.Sdk;

// Attributeの他に、必ずITraitAttributeを継承させる
// コンストラクタで指定する値は、後で属性情報を集めるクラスで使用される
[TraitDiscoverer("HogeDiscoverer", "HogeTest")]
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
class HogeAttribute : Attribute, ITraitAttribute
{
    public HogeAttribute(string value) { }
}
```

`TraitDiscoverer`属性で指定する二つの引数は、

1. 後述する独自の属性がついたテストを集めるクラスの名前
2. 第一引数で指定したクラスが存在するアセンブリの名前

の二つとなる。
例では属性を付ける対象はメソッドのみに限定しているが、必要であればクラスにも適用することができる(`AttributeTargets.Class`を`|`で追加する)。
クラスに付与すれば、クラスに属するテスト群にまとめて適用される。
ここで作成した属性を、以下のように実際のテストに適用していく。

```csharp:Example.cs
using System.Threading;
using Xunit;

public class Example
{
    [Fact, Hoge("Piyo")]
    public void ExampleFact()
    {
        Assert.True(true); 
    }
}
```

## 独自の属性を集めるクラス

独自の属性を付けたら、次はそれを集めるクラスを作成する。
例として以下のコードを示す。

```csharp:HogeDiscoverer.cs
using System.Linq;
using Xunit.Sdk;
using Xunit.Abstractions;
using System.Collections.Generic;

public class HogeDiscoverer : ITraitDiscoverer
{
    public IEnumerable<KeyValuePair<string, string>> GetTraits(IAttributeInfo traitAttribute)
    {
        var ctorArgs = traitAttribute.GetConstructorArguments().ToList();
        // ここで返すKeyValuePairのKeyとValueが、filterオプションで使えるキーと値のペアとなる
        yield return new KeyValuePair<string, string>("Hoge", ctorArgs[0].ToString());
    }
}
```

注意として、クラスは外部から見えるようにpublicにしておくこと。
`IAttributeInfo`で使えるのは、`GetConstructorArguments()`の他に、

* `TValue GetNamedArgument<TValue>(string name)`
    * 属性に存在する名前付きプロパティの値を取得する
* `IAttributeInfo GetCustomAttributes(string attributeName)`
    * 属性に更に属性が付与されている場合に、その情報を取得する

が使える。
引数で渡された`IAttributeInfo`から、`IEnumerable<KeyValuePair<string, string>>`を返せば、KeyValuePairの組がfilterとして使える。

## 実行例

上記例を実装した状態で、下記のように実行すれば、**"Hoge"="Piyo"が付与されているテストのみが実行できる。**

```
dotnet test --filter "Hoge=Piyo"
```

応用例として、以下のようにすれば、**Hoge=Piyoを付与されたテストを除外することができる(属性自体が付与されてないものもヒットする)**

```
dotnet test --filter "Hoge!=Piyo"
```

filterで指定できる文字列の書式の詳細は、[選択的ユニットテストの実行](https://docs.microsoft.com/en-us/dotnet/core/testing/selective-unit-tests?pivots=xunit)を参照のこと。

## VS上でのフィルタリング

VS上では、テストエクスプローラー上部の検索窓で、"Trait:[キー]"で、その属性が付与されているユニットテストだけを表示できる。
また、詳細オプションの"グループ化"で"特徴"を選択すれば、値でグループ分けをしてくれる(テスト環境が日本語VSだったので英語名は不明)。

# 終わりに

今回紹介した昨日は、カテゴリ分けが必要なほど大きなプロジェクトになるまではありがたみが少ないかもしれないが、これに当てはまるようなプロジェクトではかなり役立ってくれると思う。