dotnet-unit-testing について
xUnit、NSubstitute、FluentAssertionsを使う、.NET Clean Architecture向けのユニットテストガイドとテンプレートです。ハンドラーやバリデーターに加え、状態遷移、時刻依存の処理、境界値の網羅を重視します。
主な機能
- 01ハンドラー、クエリ、バリデーターのテストテンプレート
- 02xUnit、NSubstitute、FluentAssertionsの利用
- 03Arrange-Act-Assert形式のテスト構成
- 04状態値と生成側から利用側へのフローの網羅
- 05TimeProviderによる時刻と境界値のテスト
活用シーン
- 01Clean Architectureのコマンドハンドラーのテスト作成
- 02クエリ、入力検証、副作用の確認
- 03状態値ごとの動作確認
- 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つ - 単一の動作に焦点を当てます
- 説明的なテスト名 -
Should_{ExpectedBehavior}_When_{Condition} - Arrange-Act-Assert - すべてのテストで明確な構成にします
- 依存関係のみをモックする - SUT はモックしません
- 実装ではなく動作をテストする - 結果に焦点を当てます
- データ駆動テストには Theory を使う - テストロジックの重複を避けます
- エッジケースをテストする - 空、null、境界値
- 高速なテストにする - I/O やデータベースを使いません
- 独立したテストにする - 状態を共有しません
- 意味のあるアサーションにする - 重要な内容をテストします
カバレッジの徹底:状態、ライフサイクル、境界
以下の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- エンドツーエンドテスト