xUnit.net

単体テスト.NETC#テストフレームワークオープンソースモダンTDDBDD

xUnit.net

概要

xUnit.netは、.NET Framework用の無料でオープンソースのコミュニティフォーカスな単体テストツールです。堅牢な機能セットを提供してテストの記述と実行を行い、最新のC#言語機能を活用したモダンなテストフレームワークとして設計されています。2024年現在、xUnit.net v3が最新版として提供され、.NET 6.0以降をサポートしています。

詳細

主要な特徴

モダンな設計思想

  • テストメソッドごとに新しいテストクラスインスタンスを作成(テスト分離の徹底)
  • 属性ベースのシンプルなテスト記述
  • .NET 6.0以降に最適化された実装

柔軟なテストデータ提供

  • [Theory]属性による理論テスト(Theory Tests)
  • [InlineData][MemberData][ClassData]による多様なデータソース
  • カスタムデータ属性のサポート

強力なアサーション機能

  • Assert.EqualAssert.Trueなどの直感的なアサーションメソッド
  • 例外テスト用のAssert.Throws
  • 文字列、コレクション、型チェック用の専用アサーション

テストコレクション

  • テストの並列実行制御
  • 共有リソースの管理
  • フィクスチャのライフサイクル管理

設定駆動型アプローチ

  • 設定ファイルによる詳細なカスタマイズ
  • プラグインシステムによる拡張性
  • コマンドラインオプションの最小化で再現可能なテスト実行

メリット・デメリット

メリット

  1. テスト分離: 各テストメソッドで新しいクラスインスタンスを作成し、テスト間の依存を排除
  2. モダンな設計: 最新の.NET機能とC#言語構造を積極的に活用
  3. 豊富なテストデータ: Theory Testsによる効率的なパラメータ化テスト
  4. 並列実行: デフォルトで並列実行をサポートし、高速なテスト実行を実現
  5. 拡張性: プラグインアーキテクチャによる高い拡張性

デメリット

  1. 学習コスト: NUnitやMSTestからの移行時に概念の違いを理解する必要
  2. 設定の複雑さ: 高度な設定には深い理解が必要
  3. デバッグの難しさ: 並列実行により一部のデバッグシナリオが複雑化
  4. メモリ使用量: テストクラスインスタンスの多重作成によるメモリオーバーヘッド

参考ページ

書き方の例

基本的なFact Test

using Xunit;

public class CalculatorTests
{
    [Fact]
    public void Add_TwoPositiveNumbers_ReturnsCorrectSum()
    {
        // Arrange
        var calculator = new Calculator();
        int a = 5;
        int b = 3;
        int expected = 8;

        // Act
        int result = calculator.Add(a, b);

        // Assert
        Assert.Equal(expected, result);
    }

    [Fact]
    public void Divide_ByZero_ThrowsException()
    {
        // Arrange
        var calculator = new Calculator();

        // Act & Assert
        Assert.Throws<DivideByZeroException>(() => calculator.Divide(10, 0));
    }
}

Theory Tests(パラメータ化テスト)

public class MathOperationsTests
{
    [Theory]
    [InlineData(2, 3, 5)]
    [InlineData(10, 15, 25)]
    [InlineData(-1, 1, 0)]
    [InlineData(0, 0, 0)]
    public void Add_VariousInputs_ReturnsExpectedResult(int a, int b, int expected)
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        int result = calculator.Add(a, b);

        // Assert
        Assert.Equal(expected, result);
    }

    [Theory]
    [InlineData(10, 2, 5)]
    [InlineData(15, 3, 5)]
    [InlineData(100, 10, 10)]
    public void Divide_ValidInputs_ReturnsQuotient(int dividend, int divisor, int expected)
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        int result = calculator.Divide(dividend, divisor);

        // Assert
        Assert.Equal(expected, result);
    }
}

MemberDataを使用したテストデータ

public class StringOperationsTests
{
    public static IEnumerable<object[]> GetStringTestData()
    {
        yield return new object[] { "hello", "world", "hello world" };
        yield return new object[] { "foo", "bar", "foo bar" };
        yield return new object[] { "", "test", " test" };
        yield return new object[] { "test", "", "test " };
    }

    [Theory]
    [MemberData(nameof(GetStringTestData))]
    public void Concatenate_VariousStrings_ReturnsExpectedResult(
        string first, string second, string expected)
    {
        // Arrange
        var stringProcessor = new StringProcessor();

        // Act
        string result = stringProcessor.Concatenate(first, second);

        // Assert
        Assert.Equal(expected, result);
    }

    public static TheoryData<string, bool> EmailValidationData =>
        new TheoryData<string, bool>
        {
            { "[email protected]", true },
            { "invalid-email", false },
            { "user@domain", false },
            { "[email protected]", true }
        };

    [Theory]
    [MemberData(nameof(EmailValidationData))]
    public void ValidateEmail_VariousInputs_ReturnsExpectedResult(
        string email, bool expected)
    {
        // Arrange
        var validator = new EmailValidator();

        // Act
        bool result = validator.IsValid(email);

        // Assert
        Assert.Equal(expected, result);
    }
}

ClassDataを使用したテストデータ

public class CalculatorTestData : IEnumerable<object[]>
{
    public IEnumerator<object[]> GetEnumerator()
    {
        yield return new object[] { 1, 2, 3 };
        yield return new object[] { 5, 10, 15 };
        yield return new object[] { -1, -2, -3 };
        yield return new object[] { 0, 5, 5 };
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

public class CalculatorClassDataTests
{
    [Theory]
    [ClassData(typeof(CalculatorTestData))]
    public void Add_ClassDataInputs_ReturnsExpectedResult(int a, int b, int expected)
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        int result = calculator.Add(a, b);

        // Assert
        Assert.Equal(expected, result);
    }
}

コレクションフィクスチャ

// フィクスチャクラス
public class DatabaseFixture : IDisposable
{
    public DatabaseFixture()
    {
        // データベース接続の初期化
        ConnectionString = "Server=localhost;Database=TestDB;";
        InitializeDatabase();
    }

    public string ConnectionString { get; private set; }

    private void InitializeDatabase()
    {
        // テスト用データベースのセットアップ
    }

    public void Dispose()
    {
        // リソースのクリーンアップ
    }
}

// コレクション定義
[CollectionDefinition("Database collection")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
{
    // このクラスにはコードは不要です。
    // xUnit.netが自動的にフィクスチャを管理します。
}

// テストクラス
[Collection("Database collection")]
public class UserRepositoryTests
{
    private readonly DatabaseFixture _fixture;

    public UserRepositoryTests(DatabaseFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public void GetUser_ValidId_ReturnsUser()
    {
        // Arrange
        var repository = new UserRepository(_fixture.ConnectionString);

        // Act
        var user = repository.GetUser(1);

        // Assert
        Assert.NotNull(user);
        Assert.Equal(1, user.Id);
    }
}

非同期テスト

public class AsyncOperationTests
{
    [Fact]
    public async Task GetDataAsync_ValidRequest_ReturnsData()
    {
        // Arrange
        var service = new DataService();
        var request = new DataRequest { Id = 123 };

        // Act
        var result = await service.GetDataAsync(request);

        // Assert
        Assert.NotNull(result);
        Assert.Equal(123, result.Id);
    }

    [Fact]
    public async Task SlowOperation_Timeout_ThrowsException()
    {
        // Arrange
        var service = new SlowService();

        // Act & Assert
        await Assert.ThrowsAsync<TimeoutException>(
            () => service.SlowOperationAsync(TimeSpan.FromMilliseconds(100)));
    }
}

カスタムアサーションとヘルパーメソッド

public class AdvancedAssertionTests
{
    [Fact]
    public void StringAssertions_Examples()
    {
        string actual = "Hello, World!";
        
        Assert.StartsWith("Hello", actual);
        Assert.EndsWith("World!", actual);
        Assert.Contains("World", actual);
        Assert.DoesNotContain("xyz", actual);
    }

    [Fact]
    public void CollectionAssertions_Examples()
    {
        var numbers = new List<int> { 1, 2, 3, 4, 5 };
        
        Assert.Equal(5, numbers.Count);
        Assert.Contains(3, numbers);
        Assert.DoesNotContain(10, numbers);
        Assert.All(numbers, num => Assert.True(num > 0));
    }

    [Fact]
    public void TypeAssertions_Examples()
    {
        object obj = "Hello World";
        
        Assert.IsType<string>(obj);
        Assert.IsAssignableFrom<IEnumerable<char>>(obj);
    }

    // カスタムアサーションヘルパー
    private void AssertUserIsValid(User user)
    {
        Assert.NotNull(user);
        Assert.False(string.IsNullOrEmpty(user.Name));
        Assert.True(user.Age >= 0);
        Assert.Matches(@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", user.Email);
    }

    [Fact]
    public void CreateUser_ValidData_ReturnsValidUser()
    {
        // Arrange
        var userService = new UserService();

        // Act
        var user = userService.CreateUser("John Doe", 30, "[email protected]");

        // Assert
        AssertUserIsValid(user);
    }
}

Skip属性とTraits

public class ConditionalTests
{
    [Fact(Skip = "この機能は現在開発中です")]
    public void FeatureUnderDevelopment_Test()
    {
        // このテストは実行されません
    }

    [Fact]
    [Trait("Category", "Integration")]
    [Trait("Priority", "High")]
    public void DatabaseIntegration_Test()
    {
        // 統合テスト
    }

    [Fact]
    [Trait("Category", "Unit")]
    [Trait("Priority", "Low")]
    public void UnitTest_Example()
    {
        // 単体テスト
    }
}