mcpField
D
dotnet-unit-testing

dotnet-unit-testing

github.com/ronnythedevテスト・品質7762 回インストール15 週間前に更新
リポジトリ

dotnet-unit-testing について

xUnit、NSubstitute、FluentAssertionsを使う、.NET Clean Architecture向けのユニットテストガイドとテンプレートです。ハンドラーやバリデーターに加え、状態遷移、時刻依存の処理、境界値の網羅を重視します。

主な機能

  1. 01ハンドラー、クエリ、バリデーターのテストテンプレート
  2. 02xUnit、NSubstitute、FluentAssertionsの利用
  3. 03Arrange-Act-Assert形式のテスト構成
  4. 04状態値と生成側から利用側へのフローの網羅
  5. 05TimeProviderによる時刻と境界値のテスト

活用シーン

  1. 01Clean Architectureのコマンドハンドラーのテスト作成
  2. 02クエリ、入力検証、副作用の確認
  3. 03状態値ごとの動作確認
  4. 04時間依存の遷移と閾値境界のテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/ronnythedev/dotnet-clean-architecture-skills --skill dotnet-unit-testing

インストール後、Claude Code で/dotnet-unit-testingを入力して読み込まれたか確認します。

詳細

ユニットテストジェネレーター

概要

クリーンアーキテクチャのハンドラーのユニットテスト:

  • xUnit - テストフレームワーク
  • NSubstitute - モックライブラリ
  • FluentAssertions - 読みやすいアサーション
  • AAA パターン - Arrange、Act、Assert

クイックリファレンス

テスト種別目的例
成功テスト正常系を検証するShould_ReturnSuccess_When_ValidRequest
失敗テストエラー処理を検証するShould_ReturnFailure_When_NotFound
バリデーションテスト入力の検証を確認するShould_ReturnValidationError_When_EmptyName
動作テスト副作用を検証するShould_CallRepository_When_ValidRequest

テストプロジェクトの構成

tests/
└── {name}.Application.UnitTests/
    ├── {Feature}/
    │   ├── Create{Entity}/
    │   │   ├── Create{Entity}CommandHandlerTests.cs
    │   │   └── Create{Entity}CommandValidatorTests.cs
    │   └── Get{Entity}ById/
    │       └── Get{Entity}ByIdQueryHandlerTests.cs
    ├── Abstractions/
    │   └── BaseTest.cs
    └── {name}.Application.UnitTests.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="FluentAssertions" Version="6.12.0" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
    <PackageReference Include="NSubstitute" Version="5.1.0" />
    <PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.16">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    </PackageReference>
    <PackageReference Include="xunit" Version="2.6.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.4">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    </PackageReference>
    <PackageReference Include="coverlet.collector" Version="6.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\{name}.application\{name}.application.csproj" />
    <ProjectReference Include="..\..\src\{name}.domain\{name}.domain.csproj" />
  </ItemGroup>

</Project>

テンプレート:基底テストクラス

// tests/{name}.Application.UnitTests/Abstractions/BaseTest.cs
using NSubstitute;
using {name}.domain.abstractions;

namespace {name}.Application.UnitTests.Abstractions;

public abstract class BaseTest
{
    protected static CancellationToken CancellationToken => CancellationToken.None;

    /// <summary>
    /// Creates a mock that returns the provided result
    /// </summary>
    protected static T CreateMock<T>() where T : class
    {
        return Substitute.For<T>();
    }

    /// <summary>
    /// Helper to create a successful Result
    /// </summary>
    protected static Result<T> SuccessResult<T>(T value)
    {
        return Result.Success(value);
    }

    /// <summary>
    /// Helper to create a failed Result
    /// </summary>
    protected static Result<T> FailureResult<T>(Error error)
    {
        return Result.Failure<T>(error);
    }
}

テンプレート:コマンドハンドラーのテスト

// tests/{name}.Application.UnitTests/{Feature}/Create{Entity}/Create{Entity}CommandHandlerTests.cs
using FluentAssertions;
using NSubstitute;
using {name}.application.{feature}.Create{Entity};
using {name}.domain.{aggregate};
using {name}.domain.abstractions;
using {name}.Application.UnitTests.Abstractions;

namespace {name}.Application.UnitTests.{Feature}.Create{Entity};

public sealed class Create{Entity}CommandHandlerTests : BaseTest
{
    private readonly I{Entity}Repository _{entity}Repository;
    private readonly IUnitOfWork _unitOfWork;
    private readonly Create{Entity}CommandHandler _handler;

    public Create{Entity}CommandHandlerTests()
    {
        // Arrange - Setup mocks (runs before each test)
        _{entity}Repository = CreateMock<I{Entity}Repository>();
        _unitOfWork = CreateMock<IUnitOfWork>();

        _handler = new Create{Entity}CommandHandler(
            _{entity}Repository,
            _unitOfWork);
    }

    // ═══════════════════════════════════════════════════════════════
    // SUCCESS TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public async Task Handle_Should_ReturnSuccess_When_ValidRequest()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Test Entity",
            Description: "Test Description",
            OrganizationId: Guid.NewGuid());

        _{entity}Repository
            .GetByNameAsync(command.Name, CancellationToken)
            .Returns((Domain.{Aggregate}.{Entity}?)null);

        // Act
        var result = await _handler.Handle(command, CancellationToken);

        // Assert
        result.IsSuccess.Should().BeTrue();
        result.Value.Should().NotBeEmpty();
    }

    [Fact]
    public async Task Handle_Should_AddEntity_When_ValidRequest()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Test Entity",
            Description: "Test Description",
            OrganizationId: Guid.NewGuid());

        _{entity}Repository
            .GetByNameAsync(command.Name, CancellationToken)
            .Returns((Domain.{Aggregate}.{Entity}?)null);

        // Act
        await _handler.Handle(command, CancellationToken);

        // Assert
        _{entity}Repository
            .Received(1)
            .Add(Arg.Is<Domain.{Aggregate}.{Entity}>(e =>
                e.Name == command.Name &&
                e.OrganizationId == command.OrganizationId));
    }

    [Fact]
    public async Task Handle_Should_CallSaveChanges_When_ValidRequest()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Test Entity",
            Description: "Test Description",
            OrganizationId: Guid.NewGuid());

        _{entity}Repository
            .GetByNameAsync(command.Name, CancellationToken)
            .Returns((Domain.{Aggregate}.{Entity}?)null);

        // Act
        await _handler.Handle(command, CancellationToken);

        // Assert
        await _unitOfWork
            .Received(1)
            .SaveChangesAsync(CancellationToken);
    }

    // ═══════════════════════════════════════════════════════════════
    // FAILURE TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public async Task Handle_Should_ReturnFailure_When_NameAlreadyExists()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Existing Entity",
            Description: "Test Description",
            OrganizationId: Guid.NewGuid());

        var existing{Entity} = CreateTest{Entity}(command.Name);

        _{entity}Repository
            .GetByNameAsync(command.Name, CancellationToken)
            .Returns(existing{Entity});

        // Act
        var result = await _handler.Handle(command, CancellationToken);

        // Assert
        result.IsFailure.Should().BeTrue();
        result.Error.Should().Be({Entity}Errors.NameAlreadyExists);
    }

    [Fact]
    public async Task Handle_Should_NotAddEntity_When_NameAlreadyExists()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Existing Entity",
            Description: "Test Description",
            OrganizationId: Guid.NewGuid());

        var existing{Entity} = CreateTest{Entity}(command.Name);

        _{entity}Repository
            .GetByNameAsync(command.Name, CancellationToken)
            .Returns(existing{Entity});

        // Act
        await _handler.Handle(command, CancellationToken);

        // Assert
        _{entity}Repository
            .DidNotReceive()
            .Add(Arg.Any<Domain.{Aggregate}.{Entity}>());
    }

    [Fact]
    public async Task Handle_Should_NotCallSaveChanges_When_NameAlreadyExists()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Existing Entity",
            Description: "Test Description",
            OrganizationId: Guid.NewGuid());

        var existing{Entity} = CreateTest{Entity}(command.Name);

        _{entity}Repository
            .GetByNameAsync(command.Name, CancellationToken)
            .Returns(existing{Entity});

        // Act
        await _handler.Handle(command, CancellationToken);

        // Assert
        await _unitOfWork
            .DidNotReceive()
            .SaveChangesAsync(Arg.Any<CancellationToken>());
    }

    // ═══════════════════════════════════════════════════════════════
    // HELPER METHODS
    // ═══════════════════════════════════════════════════════════════

    private static Domain.{Aggregate}.{Entity} CreateTest{Entity}(string name)
    {
        // Use reflection or internal factory for testing
        // This assumes the entity has a factory method
        var result = Domain.{Aggregate}.{Entity}.Create(
            name,
            "Description",
            Guid.NewGuid());

        return result.Value;
    }
}

テンプレート:クエリハンドラーのテスト

// tests/{name}.Application.UnitTests/{Feature}/Get{Entity}ById/Get{Entity}ByIdQueryHandlerTests.cs
using FluentAssertions;
using NSubstitute;
using {name}.application.{feature}.Get{Entity}ById;
using {name}.application.abstractions.data;
using {name}.Application.UnitTests.Abstractions;

namespace {name}.Application.UnitTests.{Feature}.Get{Entity}ById;

public sealed class Get{Entity}ByIdQueryHandlerTests : BaseTest
{
    private readonly ISqlConnectionFactory _sqlConnectionFactory;
    private readonly Get{Entity}ByIdQueryHandler _handler;

    public Get{Entity}ByIdQueryHandlerTests()
    {
        _sqlConnectionFactory = CreateMock<ISqlConnectionFactory>();
        _handler = new Get{Entity}ByIdQueryHandler(_sqlConnectionFactory);
    }

    [Fact]
    public async Task Handle_Should_ReturnSuccess_When_EntityExists()
    {
        // Arrange
        var entityId = Guid.NewGuid();
        var query = new Get{Entity}ByIdQuery(entityId);

        var expected = new {Entity}Response
        {
            Id = entityId,
            Name = "Test Entity",
            Description = "Description"
        };

        // Setup Dapper mock (simplified - in practice, mock IDbConnection)
        SetupConnectionToReturn(expected);

        // Act
        var result = await _handler.Handle(query, CancellationToken);

        // Assert
        result.IsSuccess.Should().BeTrue();
        result.Value.Id.Should().Be(entityId);
    }

    [Fact]
    public async Task Handle_Should_ReturnFailure_When_EntityNotFound()
    {
        // Arrange
        var entityId = Guid.NewGuid();
        var query = new Get{Entity}ByIdQuery(entityId);

        SetupConnectionToReturn(null);

        // Act
        var result = await _handler.Handle(query, CancellationToken);

        // Assert
        result.IsFailure.Should().BeTrue();
        result.Error.Should().Be({Entity}Errors.NotFound);
    }

    private void SetupConnectionToReturn({Entity}Response? response)
    {
        // In practice, you'd use a library like Dapper.Contrib.Tests
        // or create a test double for IDbConnection
        // This is a simplified example
    }
}

テンプレート:バリデーターのテスト

// tests/{name}.Application.UnitTests/{Feature}/Create{Entity}/Create{Entity}CommandValidatorTests.cs
using FluentAssertions;
using FluentValidation.TestHelper;
using {name}.application.{feature}.Create{Entity};
using {name}.Application.UnitTests.Abstractions;

namespace {name}.Application.UnitTests.{Feature}.Create{Entity};

public sealed class Create{Entity}CommandValidatorTests : BaseTest
{
    private readonly Create{Entity}CommandValidator _validator;

    public Create{Entity}CommandValidatorTests()
    {
        _validator = new Create{Entity}CommandValidator();
    }

    // ═══════════════════════════════════════════════════════════════
    // NAME VALIDATION
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public void Validate_Should_HaveError_When_NameIsEmpty()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: string.Empty,
            Description: "Valid description",
            OrganizationId: Guid.NewGuid());

        // Act
        var result = _validator.TestValidate(command);

        // Assert
        result.ShouldHaveValidationErrorFor(x => x.Name)
            .WithErrorMessage("{Entity} name is required");
    }

    [Fact]
    public void Validate_Should_HaveError_When_NameTooLong()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: new string('a', 101),  // Exceeds 100 char limit
            Description: "Valid description",
            OrganizationId: Guid.NewGuid());

        // Act
        var result = _validator.TestValidate(command);

        // Assert
        result.ShouldHaveValidationErrorFor(x => x.Name)
            .WithErrorMessage("{Entity} name must not exceed 100 characters");
    }

    [Theory]
    [InlineData("A")]
    [InlineData("Valid Name")]
    [InlineData("Name with 100 characters padded................................")]
    public void Validate_Should_NotHaveError_When_NameIsValid(string name)
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: name,
            Description: "Valid description",
            OrganizationId: Guid.NewGuid());

        // Act
        var result = _validator.TestValidate(command);

        // Assert
        result.ShouldNotHaveValidationErrorFor(x => x.Name);
    }

    // ═══════════════════════════════════════════════════════════════
    // ORGANIZATION ID VALIDATION
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public void Validate_Should_HaveError_When_OrganizationIdIsEmpty()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Valid Name",
            Description: "Valid description",
            OrganizationId: Guid.Empty);

        // Act
        var result = _validator.TestValidate(command);

        // Assert
        result.ShouldHaveValidationErrorFor(x => x.OrganizationId);
    }

    [Fact]
    public void Validate_Should_NotHaveError_When_OrganizationIdIsValid()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Valid Name",
            Description: "Valid description",
            OrganizationId: Guid.NewGuid());

        // Act
        var result = _validator.TestValidate(command);

        // Assert
        result.ShouldNotHaveValidationErrorFor(x => x.OrganizationId);
    }

    // ═══════════════════════════════════════════════════════════════
    // FULL VALIDATION
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public void Validate_Should_BeValid_When_AllFieldsAreValid()
    {
        // Arrange
        var command = new Create{Entity}Command(
            Name: "Valid Name",
            Description: "Valid description",
            OrganizationId: Guid.NewGuid());

        // Act
        var result = _validator.TestValidate(command);

        // Assert
        result.ShouldNotHaveAnyValidationErrors();
    }
}

テンプレート:ドメインエンティティのテスト

// tests/{name}.Domain.UnitTests/{Aggregate}/{Entity}Tests.cs
using FluentAssertions;
using {name}.domain.{aggregate};
using {name}.domain.{aggregate}.events;

namespace {name}.Domain.UnitTests.{Aggregate};

public sealed class {Entity}Tests
{
    // ═══════════════════════════════════════════════════════════════
    // CREATE TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public void Create_Should_ReturnSuccess_When_ValidParameters()
    {
        // Arrange
        var name = "Test Entity";
        var description = "Test Description";
        var organizationId = Guid.NewGuid();

        // Act
        var result = {Entity}.Create(name, description, organizationId);

        // Assert
        result.IsSuccess.Should().BeTrue();
        result.Value.Name.Should().Be(name);
        result.Value.OrganizationId.Should().Be(organizationId);
        result.Value.IsActive.Should().BeTrue();
    }

    [Fact]
    public void Create_Should_ReturnFailure_When_NameIsEmpty()
    {
        // Arrange
        var name = string.Empty;
        var description = "Test Description";
        var organizationId = Guid.NewGuid();

        // Act
        var result = {Entity}.Create(name, description, organizationId);

        // Assert
        result.IsFailure.Should().BeTrue();
        result.Error.Should().Be({Entity}Errors.NameRequired);
    }

    [Fact]
    public void Create_Should_RaiseDomainEvent_When_Success()
    {
        // Arrange
        var name = "Test Entity";
        var description = "Test Description";
        var organizationId = Guid.NewGuid();

        // Act
        var result = {Entity}.Create(name, description, organizationId);

        // Assert
        result.Value.GetDomainEvents()
            .Should().ContainSingle()
            .Which.Should().BeOfType<{Entity}CreatedDomainEvent>();
    }

    // ═══════════════════════════════════════════════════════════════
    // UPDATE TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public void UpdateName_Should_ReturnSuccess_When_ValidName()
    {
        // Arrange
        var entity = Create{Entity}();
        var newName = "Updated Name";

        // Act
        var result = entity.UpdateName(newName);

        // Assert
        result.IsSuccess.Should().BeTrue();
        entity.Name.Should().Be(newName);
    }

    [Fact]
    public void UpdateName_Should_ReturnFailure_When_EmptyName()
    {
        // Arrange
        var entity = Create{Entity}();

        // Act
        var result = entity.UpdateName(string.Empty);

        // Assert
        result.IsFailure.Should().BeTrue();
        result.Error.Should().Be({Entity}Errors.NameRequired);
    }

    // ═══════════════════════════════════════════════════════════════
    // DEACTIVATE TESTS
    // ═══════════════════════════════════════════════════════════════

    [Fact]
    public void Deactivate_Should_SetIsActiveToFalse()
    {
        // Arrange
        var entity = Create{Entity}();
        entity.IsActive.Should().BeTrue();

        // Act
        entity.Deactivate();

        // Assert
        entity.IsActive.Should().BeFalse();
    }

    [Fact]
    public void Deactivate_Should_RaiseDomainEvent()
    {
        // Arrange
        var entity = Create{Entity}();
        entity.ClearDomainEvents();  // Clear create event

        // Act
        entity.Deactivate();

        // Assert
        entity.GetDomainEvents()
            .Should().ContainSingle()
            .Which.Should().BeOfType<{Entity}DeactivatedDomainEvent>();
    }

    // ═══════════════════════════════════════════════════════════════
    // HELPER METHODS
    // ═══════════════════════════════════════════════════════════════

    private static {Entity} Create{Entity}()
    {
        var result = {Entity}.Create(
            "Test Entity",
            "Test Description",
            Guid.NewGuid());

        return result.Value;
    }
}

テンプレート:テストデータビルダー

// tests/{name}.Application.UnitTests/TestData/{Entity}Builder.cs
using {name}.application.{feature}.Create{Entity};

namespace {name}.Application.UnitTests.TestData;

public sealed class {Entity}CommandBuilder
{
    private string _name = "Default Name";
    private string _description = "Default Description";
    private Guid _organizationId = Guid.NewGuid();

    public {Entity}CommandBuilder WithName(string name)
    {
        _name = name;
        return this;
    }

    public {Entity}CommandBuilder WithDescription(string description)
    {
        _description = description;
        return this;
    }

    public {Entity}CommandBuilder WithOrganizationId(Guid organizationId)
    {
        _organizationId = organizationId;
        return this;
    }

    public Create{Entity}Command Build()
    {
        return new Create{Entity}Command(_name, _description, _organizationId);
    }
}

// Usage in tests:
// var command = new {Entity}CommandBuilder()
//     .WithName("Custom Name")
//     .Build();

NSubstitute クイックリファレンス

// Create mock
var repository = Substitute.For<IRepository>();

// Setup return value
repository.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
    .Returns(entity);

// Setup return null
repository.GetByIdAsync(entityId, CancellationToken)
    .Returns((Entity?)null);

// Verify method was called
repository.Received(1).Add(Arg.Any<Entity>());

// Verify method was NOT called
repository.DidNotReceive().Add(Arg.Any<Entity>());

// Verify with argument matching
repository.Received().Add(Arg.Is<Entity>(e => e.Name == "Test"));

// Verify call order (advanced)
Received.InOrder(() =>
{
    repository.Add(Arg.Any<Entity>());
    unitOfWork.SaveChangesAsync(CancellationToken);
});

// Setup to throw exception
repository.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
    .ThrowsAsync(new Exception("Database error"));

FluentAssertions クイックリファレンス

// Basic assertions
result.Should().BeTrue();
result.Should().BeFalse();
result.Should().BeNull();
result.Should().NotBeNull();

// Equality
result.Should().Be(expected);
result.Should().NotBe(unexpected);
result.Should().BeEquivalentTo(expected);

// Collections
list.Should().BeEmpty();
list.Should().NotBeEmpty();
list.Should().HaveCount(3);
list.Should().Contain(item);
list.Should().ContainSingle();
list.Should().ContainSingle().Which.Should().BeOfType<MyType>();

// Types
result.Should().BeOfType<MyType>();
result.Should().BeAssignableTo<IMyInterface>();

// Strings
name.Should().StartWith("Test");
name.Should().Contain("Entity");
name.Should().BeNullOrEmpty();

// Exceptions
action.Should().Throw<InvalidOperationException>()
    .WithMessage("*not found*");

action.Should().NotThrow();

// Result pattern
result.IsSuccess.Should().BeTrue();
result.Error.Should().Be(ExpectedError);

重要なルール

  1. テストごとにアサーションの観点は1つ - 単一の動作に焦点を当てます
  2. 説明的なテスト名 - Should_{ExpectedBehavior}_When_{Condition}
  3. Arrange-Act-Assert - すべてのテストで明確な構成にします
  4. 依存関係のみをモックする - SUT はモックしません
  5. 実装ではなく動作をテストする - 結果に焦点を当てます
  6. データ駆動テストには Theory を使う - テストロジックの重複を避けます
  7. エッジケースをテストする - 空、null、境界値
  8. 高速なテストにする - I/O やデータベースを使いません
  9. 独立したテストにする - 状態を共有しません
  10. 意味のあるアサーションにする - 重要な内容をテストします

カバレッジの徹底:状態、ライフサイクル、境界

以下の4つのルールは、システム内の別のハンドラーが生成している識別フィールドの値を、ハンドラーが黙って無視する種類のバグを検出します。各ステータス値についてハンドラーを単独でテストするだけでは不十分です。プロデューサーを経由して一連の処理を行う必要があります。

1. 識別フィールドのマトリックスは必須です

ステータス/種別/文字列としての列挙値/フラグフィールドに基づいて分岐するハンドラーでは、テストスイートで正規の定数クラス(例:OrderStatuses.All)に宣言されたすべての値を必ずパラメーター化してテストします。このクラスへの新しい値の追加は、カバレッジ上のイベントです。各コンシューマーは新しい値に対する想定動作を明示する必要があります。明示されなければ、テストスイートは仕様として機能しなくなります。

[Theory]
[InlineData("pending",   false)]
[InlineData("shipped",   true)]
[InlineData("delivered", true)]   // <-- often the missed row
[InlineData("cancelled", false)]
public async Task Handle_RespectsStatus(string status, bool shouldSend)
{
    await SeedAsync(NewOrder(status, jobs: [new OrderEmailJob { Sent = false }]));
    var result = await _sut.Handle(new SendDueOrderEmailsCommand(), CancellationToken.None);
    result.Sent.ShouldBe(shouldSend ? 1 : 0);
}

アンチパターン: Should_NotSend_When_NonShipped という Fact を1つと、「正常系以外」の値ごとに [InlineData] を1つずつ書くことです。上記のテーブル形式では各行で意図した動作を肯定的にアサートするため、負の形式のように行を忘れることがありません。

2. 同じテスト内でプロデューサーとコンシューマーを実行する

ハンドラー A が識別フィールドを書き込み、ハンドラー B がそのフィールドを読み取る場合、少なくとも1つのテストで、実際のストアを経由して A の後に B を順に実行する必要があります。Arrange ステップでフィールドを直接変更しないでください。書き込みを行う本番コードの経路を迂回して、検出しようとしているバグそのものを隠してしまいます。

[Fact]
public async Task SendDueEmails_FiresOnDeliveredOrders_AfterPromoterRuns()
{
    // Arrange: a Shipped order with an unsent job
    var order = NewOrder(OrderStatuses.Shipped, jobs: [new OrderEmailJob { Sent = false }]);
    await SeedAsync(order);

    // Act 1: the OTHER handler runs first and promotes Status to Delivered
    var promoter = new PromoteShippedToDeliveredCommandHandler(_context);
    await promoter.Handle(new PromoteShippedToDeliveredCommand(), CancellationToken.None);

    // Act 2: now the handler under test runs
    var result = await _sut.Handle(new SendDueOrderEmailsCommand(), CancellationToken.None);

    // Assert: business-level question — should a Delivered order still flush its email queue?
    // Answer this against the spec, not against the current Where(...) clause.
    result.Sent.ShouldBe(1);
}

これは譲れない理由: CRUD システムで最も厄介なバグは、誰も一緒に作業しなかった2つのハンドラー間の暗黙的な結合に潜んでいます。Arrange ステップで直接値を変更する(order.Status = "delivered")と、その結合が見過ごされたままテストに通ってしまいます。プロデューサーを経由した一連の処理によって、その結合が実際に動作することを確認できます。

このテストを書くタイミング: コードベース内で、.Where(...) が読み取るフィールドと同じフィールドに書き込む別のハンドラーを見つけたときです。そのフィールドへの代入を grep で検索し、書き込みを行う各ハンドラーについて、プロデューサーとコンシューマーのテストを追加します。

3. クロックを注入し、ハンドラーでの DateTime.UtcNow の直接使用を禁止する

ハンドラー、サービス、バリデーターで DateTime.UtcNow(または DateTime.Now)を直接呼び出すことは禁止です。TimeProvider(.NET 8 以降)を注入してください。テストでは Microsoft.Extensions.Time.Testing.FakeTimeProvider を使うと、1つのテスト内で状態遷移に合わせて時刻を進められます。

public class SendDueOrderEmailsCommandHandler
{
    private readonly TimeProvider _clock;
    // ctor injects TimeProvider

    public async Task<...> Handle(...)
    {
        var utcNow = _clock.GetUtcNow().UtcDateTime;
        // ...
    }
}

// In tests:
private readonly FakeTimeProvider _clock = new(startDateTime: new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero));
// Construct the SUT with _clock instead of TimeProvider.System.

[Fact]
public async Task Job_Fires_AfterWindowElapses()
{
    await SeedDueJobAt(_clock.GetUtcNow().UtcDateTime.AddMinutes(60));

    _clock.Advance(TimeSpan.FromMinutes(59));
    (await _sut.Handle(...)).Sent.ShouldBe(0);  // not yet

    _clock.Advance(TimeSpan.FromMinutes(2));
    (await _sut.Handle(...)).Sent.ShouldBe(1);  // crossed the threshold
}

必須である理由: 時刻に基づく状態遷移は、前述の #1 に次いで、本番環境で気付かれない不具合が発生する最も一般的な原因です。本番コードがシステムクロックを直接読み取る状態では、テストできません。「現在」の時刻を読み取るハンドラーを初めて書くときに TimeProvider のコンストラクターパラメーターを追加してください。後から追加するのはコストがかかります。

4. しきい値の前後を確認する3つのテスト

しきい値(イベント終了時刻+オフセット、猶予期間の境界、登録締め切り、定員、再試行までの遅延)で動作が変わる場合は、しきい値の直前、ちょうどその時点、直後の3つの明示的なテストが必要です。

[Theory]
[InlineData(59,  false)]   // just before
[InlineData(60,  true)]    // exactly at
[InlineData(61,  true)]    // just after
public async Task Job_FiresOnceWindowReached(int minutesElapsed, bool shouldFire)
{
    await SeedDueJobAt(_clock.GetUtcNow().UtcDateTime.AddMinutes(60));
    _clock.Advance(TimeSpan.FromMinutes(minutesElapsed));
    (await _sut.Handle(...)).Sent.ShouldBe(shouldFire ? 1 : 0);
}

[InlineData] を3行書くほうが、境界値のずれによる不具合を防ぐよりも手間はかかりません。

合理化の言い訳一覧 — 次のように考えていることに気付いたら、作業を止めてください
言い訳実情
「ほかのステータスは明らかに該当しないので、このフィルターで正しい」本当に「明らかに」該当しないなら、InlineData の行を追加しても5秒で確認できます。追加してください。
「ステータスは Create のときにしか設定されないので、Status = X を直接シードします」後から別のハンドラーが設定する可能性があります。そう思い込む前に、コードベース全体で \.Status = を grep してください。
「Promoter は別のテストで確認すべきです」Promoter と Reader の結合こそが、この不具合の種類です。往復テストを1つ追加すれば、その結合が明示されます。
「この単純なハンドラーに TimeProvider を使うのはやりすぎです」UtcNow を読み取るすべてのハンドラーは、「将来、別のエンジニアが上流の状態を変更する」だけでテスト不能になります。最初にコストをかけてください。
「境界値テストは細かすぎます」しきい値の境界値のずれは、ステータスのフィルタリングに次いで、本番環境で気付かれない不具合が発生する第1の原因です。InlineData を3行追加してください。
「理論上、アサートする値だけあれば十分です」この理論は更新され続ける仕様です。行の欠落 = 仕様の欠落に気付かない = 将来のリグレッションです。
注意事項 — 該当したら作業を止めて再テストしてください
  • Field のすべての値を網羅するパラメーター化テストがない .Where(x => x.Field == constant)
  • その値を生成するハンドラーを実行せず、entity.Field = "X" として判別フィールドを設定しているテスト
  • TimeProvider のコンストラクターパラメーターがない状態で、DateTime.UtcNow を直接呼び出すハンドラー
  • 動作のしきい値(オフセット、締め切り、上限)を3つではなく、1つの値だけでテストしている

PR でこれらのいずれかに気付いた場合は、承認する前に不足しているテストの追加を依頼してください。


避けるべきアンチパターン

// ❌ WRONG: Multiple unrelated assertions
[Fact]
public void Test_Everything()
{
    // Tests too many things at once
    result.IsSuccess.Should().BeTrue();
    result.Value.Name.Should().Be("Test");
    repository.Received(1).Add(Arg.Any<Entity>());
    unitOfWork.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
}

// ✅ CORRECT: Focused tests
[Fact]
public void Handle_Should_ReturnSuccess_When_ValidRequest() { }

[Fact]
public void Handle_Should_AddEntity_When_ValidRequest() { }

[Fact]
public void Handle_Should_CallSaveChanges_When_ValidRequest() { }

// ❌ WRONG: Testing implementation details
repository.Received(1).GetByIdAsync(entityId, CancellationToken);
repository.Received(1).Add(Arg.Any<Entity>());
// ... testing every single call

// ✅ CORRECT: Testing outcomes
result.IsSuccess.Should().BeTrue();
result.Value.Id.Should().NotBeEmpty();

// ❌ WRONG: Shared mutable state
private Entity _sharedEntity;  // Modified by tests, causes flaky tests

// ✅ CORRECT: Fresh setup per test
[Fact]
public void Test()
{
    var entity = CreateEntity();  // Fresh instance
}

関連スキル

  • dotnet-cqrs-command-generator - テスト対象のコマンド
  • dotnet-cqrs-query-generator - テスト対象のクエリ
  • dotnet-domain-entity-generator - テスト対象のドメインエンティティ
  • dotnet-integration-testing - エンドツーエンドテスト

コメント