🦈

[xUnit] 単一の共有オブジェクトを非同期に初期化したい

2024/05/12に公開

以下のような状況に対するxUnitでの打開策を書き残します。

  • 複数のテスト間で、1つのオブジェクトを使いまわしたい
    • 初期化に長時間かかる・メモリを大量に消費する等の何らかの事情がある
  • 初期化メソッドが、要async

題材例

  • Amazon S3からテストに必要なデータを予め取得したい。データはかなり大きい想定(数百MBとか)。
  • この処理を、テストケース実行の前に1回だけ走らせるようにしたい。

S3からのデータ取得 (GetObject) は、.NET (Core) 向けのAWSSDKにおいてはasync版メソッドしか用意されません[1]
https://docs.aws.amazon.com/sdkfornet/v3/apidocs/?page=MS3GetObjectAsyncGetObjectRequestCancellationToken.html&tocid=Amazon_S3_AmazonS3Client

asyncを生やせるのが普通に考えるとテストメソッドしかないこともあり、以下のようにテストケースの都度ダウンロードするのが本来はシンプルでxUnitの哲学に沿う(らしい)方法です。しかし巨大ファイルを都度ダウンロードはさすがに無駄が多すぎるので、1回にまとめたくなったという背景として考えます。

public class FooTests(IAmazonS3 s3Client)
{
    [Theory]
    [InlineData("Foo")]
    [InlineData("Bar")]
    [InlineData("Baz")]
    public async Task WordContains(string searchWord)
    {
        using var getResponse = await s3Client.GetObjectAsync("my-bucket", "huge.txt");
        using var reader = new StreamReader(getResponse.ResponseStream);

        // ReadToEnd() の出力文字列がメモリに収まらない心配はしないものとします
        Assert.Contains(searchWord, reader.ReadToEnd());
    }
}

S3を日頃使わない方は、何か別のasyncを要する処理で読み替えてご想像ください。

打開策の例

環境・利用パッケージ

テストプロジェクトのcsprojファイルとして示します。

MyProject.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="AWSSDK.S3" Version="3.7.307.31" />
    <PackageReference Include="AWSSDK.Extensions.NETCore.Setup" Version="3.7.300" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
    <PackageReference Include="xunit" Version="2.8.0" />
    <PackageReference Include="Xunit.DependencyInjection" Version="9.2.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <!-- 本記事では登場しない -->
    <!-- <ProjectReference Include="(テスト対象のライブラリプロジェクト).csproj" /> -->
  </ItemGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

</Project>

Startup.cs

やや本題とそれますが、S3アクセスクライアントをDIで仕込みます。

xUnitではテストプロジェクト全体に適用させたいDIの設定を書くのが面倒[2]なため、Xunit.DependencyInjection を使います。ほかに類似の機能を持つライブラリもいくつかありますが、一番無難に思います。
https://github.com/pengweiqhca/Xunit.DependencyInjection

Startup.cs
using Amazon.S3;
using Microsoft.Extensions.DependencyInjection;

namespace MyProject.Tests;

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // credentialsの設定は割愛
        services.AddAWSService<IAmazonS3>();
    }
}

Fixtureクラス

ここからが肝の部分で、シングルトン的に保持したいオブジェクトをまとめたFixtureクラスを作ります。

Fixtureの作り方はxUnit公式のサイトに解説があり、xUnit利用者なら既にそこそこ馴染みかと思われますが、普通は同期的な初期化処理しかサポートされません。
https://xunit.net/docs/shared-context#class-fixture

そこで、IAsyncLifetime と合わせ技するとこの問題を突破できます。以下の記事が参考になります(この記事だけで十分で、例によって本記事は要らない)。
https://dev.to/tswiftma/initialize-asynchronous-data-in-xunit-collections-for-sharing-data-between-tests-39hc

IAsyncLifetime はxUnitが用意しているインタフェースで、 InitializeAsyncDisposeAsync メソッドを要求します。InitializeAsync の中にasyncな初期化処理を書いておけば実行されるわけです。

public class HogeFixture : IAsyncLifeTime
{
    public async Task InitializeAsync()
    {
        // 非同期の初期化処理を書く
    }

    public async Task DisposeAsync()
    {
        // 非同期の解放処理を書く
    }    

以下はS3からファイルをダウンロードし、中身のテキストを保持するFixtureの例です。InitializeAsyncがコンストラクタでなく普通のメソッドである制約上、readonlyなフィールド変数やget-onlyなプロパティに代入ができず、nullableの兼ね合いがややもどかしい感じにはなります。

S3FileFixture.cs
public class S3FileFixture(IAmazonS3 s3Client) : IAsyncLifetime
{
    public string Text { get; private set; } = null!;

    public async Task InitializeAsync()
    {
        using var getResponse = await s3Client.GetObjectAsync("my-bucket", "huge.txt");
        using var reader = new StreamReader(getResponse.ResponseStream);
        Text = await reader.ReadToEndAsync();
    }

    public Task DisposeAsync() => Task.CompletedTask;
}

テストクラス

最後にFixtureを使う部分です。

これを実行すると、最初にダウンロードで待たされるものの、そのあと一気に3ケースの検証が完了するはずで、所要時間は当初の都度ダウンロード状態と比べ約1/3になります。

FooTests.cs
public class FooTests(S3FileFixture fixture) : IClassFixture<S3FileFixture>
{
    [Theory]
    [InlineData("Foo")]
    [InlineData("Bar")]
    [InlineData("Baz")]
    public void WordContains(string searchWord)
    {
        Assert.Contains(searchWord, fixture.Text);
    }
}
脚注
  1. 一例として TransferUtility.Download メソッドは同期版として生き残っており、これでファイル経由でやりとりする、といった手が無くはないです。 ↩︎

  2. というか、そもそもどう書くのが正解か未だわかりません。xUnitの3系では改善されるらしいです。 ↩︎

Discussion