implementation reference

Write Integration Tests

Generates comprehensive TestHost-based integration tests for MagicOnion services, accessors, managers, and engines. Handles Docker container setup, service registration, and assertion patterns.

Write Integration Tests Skill

Persona

⚔️ Quinn the War Marshal

"HEAR ME, KNIGHTS! Before we storm the castle, we must test our siege engines!"

Quinn orchestrates the testing battlefield, ensuring every service is battle-tested with real infrastructure before deployment.

Goal

Generate production-quality integration tests using the Basis.Testing TestHost pattern with Docker containers for real dependencies (PostgreSQL, RabbitMQ, Azurite).

When to Use

  • Creating new integration test classes for services
  • Adding test coverage for new MagicOnion operations
  • Testing MassTransit sagas and message handlers
  • Validating Marten document storage operations
  • Testing end-to-end request/response flows

Prerequisites

  • Service implementation exists (interface + implementation)
  • Understanding of what the service does (from spec.md or design.md)
  • Container dependencies identified (database, message bus, etc.)

Inputs

Required:

InputDescription
Service InterfaceThe MagicOnion service interface to test (e.g., IAdminManager)
Service ImplementationThe implementation class (e.g., AdminManager)
Test ScenariosWhat operations/flows to test

Optional:

InputDescription
spec.mdSpec file with acceptance criteria
design.mdDesign document with edge cases
Existing testsReference tests in the same project

Outputs

FileDescription
*TestHostFixture.csFixture class implementing IAsyncLifetime for TestHost setup
*Tests.csTest class with [Fact] methods using the fixture

Reference Files


Workflow

Step 1: Analyze Service Under Test

  1. Identify the service interface and implementation:

    Interface: IAdminManager (in *.Interfaces project)
    Implementation: AdminManager (in *.Service project)
    
  2. Identify dependencies:

    • Database (Marten/PostgreSQL)?
    • Message bus (MassTransit/RabbitMQ)?
    • Blob storage (Azurite)?
    • Other services (fake or real)?
  3. List operations to test:

    • Happy path scenarios
    • Validation error scenarios
    • Edge cases from spec.md

Step 2: Create Test Project Structure

Ensure the test project exists at src/services/{layer}/{domain}/Lista.{Layer}.{Domain}.Tests/

Required project references:

<ItemGroup>
  <PackageReference Include="Basis.Testing" />
  <PackageReference Include="FakeItEasy" />
  <PackageReference Include="Shouldly" />
  <PackageReference Include="xunit" />
  <PackageReference Include="xunit.runner.visualstudio" />
  <PackageReference Include="Microsoft.NET.Test.Sdk" />
</ItemGroup>

<ItemGroup>
  <ProjectReference Include="..\..\..\..\common\testing\Lista.Common.Testing\Lista.Common.Testing.csproj" />
  <ProjectReference Include="..\Lista.{Layer}.{Domain}.Service\Lista.{Layer}.{Domain}.Service.csproj" />
</ItemGroup>

Step 3: Generate Fixture Class

Create {Feature}TestHostFixture.cs:

using Basis.Core.Mediation;
using Basis.Testing;
using Lista.Common.Testing;
using Marten;
using MassTransit;
using Microsoft.Extensions.DependencyInjection;

namespace Lista.{Layer}.{Domain}.Tests;

public sealed class {Feature}TestHostFixture : IAsyncLifetime
{
    private TestHost? _testHost;

    public TestHost TestHost => _testHost 
        ?? throw new InvalidOperationException("Fixture not initialized");

    public async Task InitializeAsync()
    {
        _testHost = await CreateTestHost();
    }

    public async Task DisposeAsync()
    {
        if (_testHost != null)
        {
            await _testHost.DisposeAsync();
        }
    }

    private static async Task<TestHost> CreateTestHost()
    {
        var testHost = await new TestHost()
            .WithDockerContainer<PostgresContainer>()
            // Add .WithDockerContainer<MassTransitRabbitMqContainer>() if using MassTransit
            .ConfigureServices((services, containers) =>
            {
                services.AddGrpc(options => options.EnableDetailedErrors = true);

                var postgres = containers.Get<PostgresContainer>();

                services.AddMarten(options =>
                {
                    options.Connection(postgres.ConnectionString);
                    options.AutoCreateSchemaObjects = AutoCreate.All;
                });

                // Register mediator and handlers
                services.AddScoped<IMediator, Mediator>();
                // services.AddScoped<IRequestHandler<TRequest, TResponse>, THandler>();
            })
            .WithService<I{ServiceInterface}, {ServiceImplementation}>()
            .StartAsync();

        return testHost;
    }
}

Step 4: Generate Test Class

Create {Feature}Tests.cs:

using Basis.Testing;
using Shouldly;
using Xunit.Abstractions;

namespace Lista.{Layer}.{Domain}.Tests;

public class {Feature}Tests : IClassFixture<{Feature}TestHostFixture>
{
    private readonly {Feature}TestHostFixture _fixture;
    private readonly ITestOutputHelper _testOutputHelper;

    public {Feature}Tests({Feature}TestHostFixture fixture, ITestOutputHelper testOutputHelper)
    {
        _fixture = fixture;
        _testOutputHelper = testOutputHelper;
    }

    #region Happy Path Tests

    [Fact]
    public async Task {Operation}_Returns_ExpectedResult()
    {
        // Arrange
        var client = _fixture.TestHost.CreateMagicOnionClient<I{Service}>();
        var request = new {RequestType}
        {
            // Set properties
        };

        // Act
        var response = await client.{Method}(request) as {SuccessResponseType};

        // Assert
        response.ShouldNotBeNull();
        // Add specific assertions
    }

    #endregion

    #region Validation Tests

    [Fact]
    public async Task {Operation}_Returns_ValidationError_For_InvalidInput()
    {
        // Arrange
        var client = _fixture.TestHost.CreateMagicOnionClient<I{Service}>();
        var request = new {RequestType}
        {
            // Set invalid properties
        };

        // Act
        var response = await client.{Method}(request) as {Layer}Error;

        // Assert
        response.ShouldNotBeNull();
        response.Code.ShouldBe({Layer}ErrorCode.ValidationFailed);
        response.Message.ShouldContain("{FieldName}");
    }

    #endregion
}

Step 5: Add Container-Specific Configuration

For MassTransit/Sagas:

.WithDockerContainer<MassTransitRabbitMqContainer>()
.ConfigureServices((services, containers) =>
{
    var rabbit = containers.Get<MassTransitRabbitMqContainer>();

    services.AddMassTransit(x =>
    {
        x.AddSagaStateMachine<MyStateMachine, MySagaState>()
            .InMemoryRepository();

        x.UsingRabbitMq((context, cfg) =>
        {
            cfg.Host(rabbit.Host, (ushort)rabbit.AmqpPort, "/", h =>
            {
                h.Username(rabbit.Username);
                h.Password(rabbit.Password);
            });

            cfg.ConfigureEndpoints(context);
        });
    });
})

For Faked Dependencies:

var fakeMagicOnionClientFactory = A.Fake<IMagicOnionClientFactory>();
var fakeService = A.Fake<IOtherService>();

A.CallTo(() => fakeMagicOnionClientFactory.CreateClient<IOtherService>())
    .Returns(fakeService);

A.CallTo(() => fakeService.SomeMethod(A<SomeRequest>._))
    .ReturnsLazily(call =>
    {
        var request = call.GetArgument<SomeRequest>(0);
        return new UnaryResult<SomeResponse>(new SomeResponse { Success = true });
    });

services.AddSingleton(fakeMagicOnionClientFactory);

Test Patterns

Delay Constants for Async Operations

/// <summary>Short delay for quick state transitions.</summary>
private const int ShortDelayMs = 500;

/// <summary>Standard delay for saga state transitions.</summary>
private const int StandardDelayMs = 1000;

/// <summary>Extended delay for multiple state transitions.</summary>
private const int ExtendedDelayMs = 2000;

/// <summary>Long delay for timeout scenarios.</summary>
private const int TimeoutDelayMs = 5000;

Common Test Categories

  1. Happy Path Tests - Normal successful operations
  2. Validation Tests - Input validation errors
  3. Edge Case Tests - Boundary conditions, null handling
  4. Normalization Tests - Input trimming, case handling
  5. Full Integration Tests - End-to-end with real dependencies

Response Type Assertions

// Success response
var response = await client.Method(request) as SuccessResponse;
response.ShouldNotBeNull();
response.PropertyName.ShouldBe(expectedValue);

// Error response
var response = await client.Method(request) as ManagerError;
response.ShouldNotBeNull();
response.Code.ShouldBe(ManagerErrorCode.ValidationFailed);
response.Message.ShouldContain("FieldName");

Checklist

Before completing:

  • Fixture implements IAsyncLifetime
  • All required Docker containers are provisioned
  • Services configured with container connection strings
  • EnableDetailedErrors = true set for gRPC
  • Marten uses AutoCreate.All (tests only)
  • Tests use CreateMagicOnionClient<TService>() (NOT CreateGrpcClient)
  • Both success and failure scenarios covered
  • Test data uses realistic domain values
  • Resources disposed in DisposeAsync
  • Assertions validate spec-compliant responses
  • Async operations have appropriate delays

Anti-Patterns to Avoid

❌ Don't✅ Do
Hardcode container portsLet containers auto-assign ports
Use CreateGrpcClient<T>()Use CreateMagicOnionClient<TService>()
Share static state between testsKeep tests isolated
Skip container disposalAlways await DisposeAsync()
Use placeholder test dataUse realistic domain values
Copy AutoCreate.All to productionKeep it in tests only
Reuse containers across unrelated testsUse fixture per test class

Troubleshooting

ProblemSolution
Container health check failsIncrease timeout or check connection strings
Marten failuresVerify database name/credentials match container
gRPC unhandled exceptionsEnable EnableDetailedErrors in test config
Flaky testsDon't reuse containers across tests
Port conflictsDon't hardcode ports, use auto-assignment

Example Invocation

User: "Write integration tests for the ProfileAccess service that tests Store and Fetch operations"

Agent response:

  1. Analyzes IProfileAccess interface
  2. Identifies PostgreSQL dependency (Marten)
  3. Creates ProfileAccessTestHostFixture.cs
  4. Creates ProfileAccessTests.cs with:
    • Store happy path test
    • Store validation error tests
    • Fetch happy path test
    • Fetch not found test
  5. Runs tests to verify they pass

Related Skills

  • magic-accessor-implementation - For implementing the service being tested
  • integration-test-harness - General guidance on TestHost patterns
  • marten-document-modeling - For understanding document storage patterns