Use when writing unit or integration tests - provides comprehensive patterns for xUnit v3, AwesomeAssertions, Testcontainers, handler testing, and test infrastructure setup
This skill provides patterns for writing unit and integration tests using:
Core principle: Test handlers (use cases), not infrastructure. Focus on behaviour, not implementation.
| Dependency Type | Approach | Example |
|---|---|---|
| Domain services | Fakes | FakeClock, FakeUnitOfWork |
| External boundaries | NSubstitute | IEmailService, IBlobStorage |
| Repositories | Real + Testcontainers | ISpaceHubDbContext |
public class CreateRoomHandlerTests
{
private readonly ISpaceHubDbContext _context;
private readonly CreateRoomHandler _handler;
public CreateRoomHandlerTests()
{
// Constructor runs before each test
_context = Substitute.For<ISpaceHubDbContext>();
_handler = new CreateRoomHandler(_context, Substitute.For<ILogger<CreateRoomHandler>>());
}
[Fact]
public async Task Handle_WithValidCommand_ReturnsSuccess()
{
// Arrange
var command = new CreateRoomCommand("R001", "Meeting Room", TestData.ProjectId);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
}
}
public class RoomIntegrationTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres;
private SpaceHubDbContext _context = null!;
public RoomIntegrationTests()
{
_postgres = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
}
public async Task InitializeAsync()
{
await _postgres.StartAsync();
_context = CreateContext();
await _context.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await _context.DisposeAsync();
await _postgres.DisposeAsync();
}
}
[Theory]
[InlineData("")]
[InlineData(null)]
[InlineData(" ")]
public async Task Handle_WithInvalidNumber_ReturnsValidationError(string? invalidNumber)
{
var command = new CreateRoomCommand(invalidNumber!, "Name", TestData.ProjectId);
var result = await _handler.Handle(command, CancellationToken.None);
result.IsSuccess.Should().BeFalse();
result.ValidationErrors.Should().NotBeEmpty();
}
[Theory]
[MemberData(nameof(GetRoomTestCases))]
public async Task Handle_WithVariousInputs_ReturnsExpectedResult(
string number, string name, bool expectedSuccess) { /* ... */ }
public static IEnumerable<object[]> GetRoomTestCases()
{
yield return new object[] { "R001", "Valid Room", true };
yield return new object[] { "", "No Number", false };
}
For detailed patterns, see the reference files in this skill's directory:
| Reference | Contents |
|---|---|
references/assertions.md | AwesomeAssertions patterns (equality, collections, exceptions, Result) |
references/testcontainers.md | Testcontainers setup, WebApplicationFactory, shared fixtures |
references/builders.md | TestDataBuilder, RoomBuilder, ProjectBuilder patterns |
references/fakes.md | FakeClock, FakeUnitOfWork, mocking strategy |
references/handlers.md | LiteBus command/query handler testing, multitenancy tests |
For Syncfusion Blazor component testing (SfGrid, SfDialog, EditForm), use the Radberi-BlazorTesting skill.
public static class TestData
{
// Use deterministic UUIDs for predictable test data
public static readonly Guid OrgAId = Guid.Parse("01945a3b-0001-7000-0000-000000000001");
public static readonly Guid ProjectId = Guid.Parse("01945a3b-0002-7000-0000-000000000001");
public static readonly Guid RoomId = Guid.Parse("01945a3b-0003-7000-0000-000000000001");
}
Reload from repository or use AsNoTracking() for read-only operations.
Shared state between tests. Ensure fresh setup per test via constructor/IAsyncLifetime.
Use FakeClock for deterministic time, or compare with tolerance:
result.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
Deadlock from .Result or .Wait(). Always use await and pass CancellationToken.None.
Ensure interface is mocked (not concrete class), use Arg.Any<T>() correctly.
Follow Method_Scenario_ExpectedResult:
[Fact] public async Task Handle_WithValidCommand_ReturnsSuccess()
[Fact] public async Task Handle_WithInvalidProject_ReturnsNotFound()
[Fact] public void Create_WithNegativeArea_ThrowsArgumentOutOfRangeException()
| Task | Pattern |
|---|---|
| Setup per test | Constructor or IAsyncLifetime.InitializeAsync |
| Teardown | IDisposable or IAsyncLifetime.DisposeAsync |
| Single test | [Fact] |
| Parameterised | [Theory] + [InlineData] or [MemberData] |
| Shared fixture | IClassFixture<T> |
| Mock interface | Substitute.For<IService>() |
| Setup return | .Returns(value) |
| Verify call | .Received(1).Method() |
| Assert equality | .Should().Be(expected) |
| Assert null | .Should().BeNull() / .NotBeNull() |
| Assert collection | .Should().HaveCount(n) |
| Assert exception | .Should().Throw<TException>() |
| Concern | Skill |
|---|---|
| Syncfusion Blazor component testing | Radberi-BlazorTesting |
| TDD methodology | Radberi-TDD |
| Avoiding testing anti-patterns | Radberi-TestingAntiPatterns |
| Flaky test elimination | Radberi-ConditionBasedWaitingCSharp |