Skip to main content

Overview

The SpecKit Ticketing Platform follows an ATDD (Acceptance Test Driven Development) approach with a comprehensive testing pyramid: unit tests, integration tests, and smoke tests. This guide explains the testing strategy, tools, and best practices.

Testing Pyramid

  • Unit Tests (70%): Fast, isolated tests for domain logic and application layer
  • Integration Tests (20%): Test infrastructure adapters with real dependencies (Testcontainers)
  • Smoke Tests (10%): End-to-end tests with Docker Compose

ATDD Workflow

The platform follows a Red-Green-Refactor cycle starting from Gherkin acceptance criteria:
1

Write Acceptance Criteria (Gherkin)

Define features in Gherkin format:
Feature: Seat Reservation
  As a Customer
  I want to reserve a seat for 15 minutes
  So that I can complete my purchase without losing my seat

  Scenario: Successful seat reservation
    Given a seat is available
    When I request to reserve the seat
    Then the reservation should be created
    And the seat should be locked in Redis for 15 minutes
    And a reservation-created event should be published to Kafka
2

RED Phase - Write Failing Tests

Write tests that fail because the implementation doesn’t exist yet:
[Fact]
public async Task Handle_ValidRequest_ShouldCreateReservation()
{
    // ARRANGE: Setup test data
    var seatId = Guid.NewGuid();
    var customerId = Guid.NewGuid();
    var command = new CreateReservationCommand(seatId, customerId);

    // ACT: Execute the command (will fail - handler doesn't exist)
    var result = await _handler.Handle(command, CancellationToken.None);

    // ASSERT: Verify reservation created
    result.Should().NotBeNull();
    result.ReservationId.Should().NotBeEmpty();
}
Run test: ❌ Test fails (CreateReservationHandler doesn’t exist)
3

GREEN Phase - Implement Minimal Code

Implement just enough code to make the test pass:
public class CreateReservationHandler : IRequestHandler<CreateReservationCommand, ReservationDto>
{
    public async Task<ReservationDto> Handle(CreateReservationCommand request, CancellationToken ct)
    {
        var reservation = new Reservation
        {
            Id = Guid.NewGuid(),
            SeatId = request.SeatId,
            CustomerId = request.CustomerId,
            ExpiresAt = DateTime.UtcNow.AddMinutes(15),
            Status = ReservationStatus.Active
        };

        await _repository.AddAsync(reservation);
        return MapToDto(reservation);
    }
}
Run test: ✅ Test passes
4

REFACTOR Phase - Improve Code Quality

Refactor the implementation while keeping tests green:
  • Extract Redis locking logic to a separate service
  • Add Kafka event publishing
  • Improve error handling
  • Add validation
Run tests again: ✅ All tests still pass

Unit Testing

Domain Layer Tests

Test domain entities and value objects in isolation:
public class ReservationTests
{
    [Fact]
    public void Create_WithValidData_ShouldSucceed()
    {
        // Arrange
        var seatId = Guid.NewGuid();
        var customerId = Guid.NewGuid();

        // Act
        var reservation = new Reservation
        {
            Id = Guid.NewGuid(),
            SeatId = seatId,
            CustomerId = customerId,
            CreatedAt = DateTime.UtcNow,
            ExpiresAt = DateTime.UtcNow.AddMinutes(15),
            Status = ReservationStatus.Active
        };

        // Assert
        reservation.Id.Should().NotBeEmpty();
        reservation.SeatId.Should().Be(seatId);
        reservation.CustomerId.Should().Be(customerId);
        reservation.Status.Should().Be(ReservationStatus.Active);
        reservation.IsActive().Should().BeTrue();
    }

    [Fact]
    public void IsExpired_WhenExpirationPassed_ShouldReturnTrue()
    {
        // Arrange
        var reservation = new Reservation
        {
            ExpiresAt = DateTime.UtcNow.AddMinutes(-1)
        };

        // Act & Assert
        reservation.IsExpired().Should().BeTrue();
    }
}
Location: services/inventory/tests/Inventory.UnitTests/Domain/ReservationTests.cs

Application Layer Tests

Test use cases with mocked dependencies:
public class CreateReservationHandlerTests
{
    private readonly Mock<IReservationRepository> _repositoryMock;
    private readonly Mock<IRedisLockService> _lockServiceMock;
    private readonly Mock<IKafkaProducer> _kafkaProducerMock;
    private readonly CreateReservationHandler _handler;

    public CreateReservationHandlerTests()
    {
        _repositoryMock = new Mock<IReservationRepository>();
        _lockServiceMock = new Mock<IRedisLockService>();
        _kafkaProducerMock = new Mock<IKafkaProducer>();

        _handler = new CreateReservationHandler(
            _repositoryMock.Object,
            _lockServiceMock.Object,
            _kafkaProducerMock.Object
        );
    }

    [Fact]
    public async Task Handle_WhenSeatAvailable_ShouldCreateReservation()
    {
        // Arrange
        var seatId = Guid.NewGuid();
        var command = new CreateReservationCommand(seatId, Guid.NewGuid());

        _lockServiceMock
            .Setup(x => x.AcquireLockAsync(It.IsAny<string>(), It.IsAny<TimeSpan>()))
            .ReturnsAsync(true);

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

        // Assert
        result.Should().NotBeNull();
        _repositoryMock.Verify(x => x.AddAsync(It.IsAny<Reservation>()), Times.Once);
        _kafkaProducerMock.Verify(x => x.PublishAsync("reservation-created", It.IsAny<object>()), Times.Once);
    }

    [Fact]
    public async Task Handle_WhenSeatAlreadyReserved_ShouldThrowException()
    {
        // Arrange
        var command = new CreateReservationCommand(Guid.NewGuid(), Guid.NewGuid());

        _lockServiceMock
            .Setup(x => x.AcquireLockAsync(It.IsAny<string>(), It.IsAny<TimeSpan>()))
            .ReturnsAsync(false);

        // Act & Assert
        await Assert.ThrowsAsync<SeatAlreadyReservedException>(
            () => _handler.Handle(command, CancellationToken.None)
        );
    }
}
Location: services/inventory/tests/Inventory.Application.UnitTests/UseCases/CreateReservationHandlerTests.cs

Running Unit Tests

# Run all unit tests
dotnet test

# Run tests for specific service
dotnet test services/inventory/tests/Inventory.UnitTests

# Run with code coverage
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=lcov

# Watch mode (re-run on file changes)
dotnet watch test

Integration Testing

Testcontainers Setup

Integration tests use Testcontainers to spin up real PostgreSQL, Redis, and Kafka instances:
public class IntegrationTestFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgresContainer;
    private readonly RedisContainer _redisContainer;
    private readonly KafkaContainer _kafkaContainer;

    public IntegrationTestFixture()
    {
        _postgresContainer = new PostgreSqlBuilder()
            .WithImage("postgres:17")
            .WithDatabase("ticketing_test")
            .WithUsername("postgres")
            .WithPassword("postgres")
            .Build();

        _redisContainer = new RedisBuilder()
            .WithImage("redis:7")
            .Build();

        _kafkaContainer = new KafkaBuilder()
            .WithImage("confluentinc/cp-kafka:7.5.0")
            .Build();
    }

    public async Task InitializeAsync()
    {
        await _postgresContainer.StartAsync();
        await _redisContainer.StartAsync();
        await _kafkaContainer.StartAsync();
    }

    public async Task DisposeAsync()
    {
        await _postgresContainer.DisposeAsync();
        await _redisContainer.DisposeAsync();
        await _kafkaContainer.DisposeAsync();
    }

    public string GetPostgresConnectionString() => _postgresContainer.GetConnectionString();
    public string GetRedisConnectionString() => _redisContainer.GetConnectionString();
    public string GetKafkaBootstrapServers() => _kafkaContainer.GetBootstrapAddress();
}

Integration Test Example

public class ReservationIntegrationTests : IClassFixture<IntegrationTestFixture>
{
    private readonly IntegrationTestFixture _fixture;
    private readonly IServiceProvider _serviceProvider;

    public ReservationIntegrationTests(IntegrationTestFixture fixture)
    {
        _fixture = fixture;

        var services = new ServiceCollection();
        services.AddDbContext<InventoryDbContext>(options =>
            options.UseNpgsql(_fixture.GetPostgresConnectionString()));
        services.AddStackExchangeRedisCache(options =>
            options.Configuration = _fixture.GetRedisConnectionString());
        services.AddScoped<IReservationRepository, ReservationRepository>();

        _serviceProvider = services.BuildServiceProvider();
    }

    [Fact]
    public async Task CreateReservation_ShouldPersistToDatabase()
    {
        // Arrange
        using var scope = _serviceProvider.CreateScope();
        var repository = scope.ServiceProvider.GetRequiredService<IReservationRepository>();

        var reservation = new Reservation
        {
            Id = Guid.NewGuid(),
            SeatId = Guid.NewGuid(),
            CustomerId = Guid.NewGuid(),
            CreatedAt = DateTime.UtcNow,
            ExpiresAt = DateTime.UtcNow.AddMinutes(15),
            Status = ReservationStatus.Active
        };

        // Act
        await repository.AddAsync(reservation);

        // Assert
        var retrieved = await repository.GetByIdAsync(reservation.Id);
        retrieved.Should().NotBeNull();
        retrieved.SeatId.Should().Be(reservation.SeatId);
    }

    [Fact]
    public async Task RedisLock_ShouldPreventDoubleBooking()
    {
        // Arrange
        using var scope = _serviceProvider.CreateScope();
        var redis = scope.ServiceProvider.GetRequiredService<IDistributedCache>();
        var seatId = Guid.NewGuid();
        var lockKey = $"lock:seat:{seatId}";

        // Act
        await redis.SetStringAsync(lockKey, "locked", new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30)
        });

        var existingLock = await redis.GetStringAsync(lockKey);

        // Assert
        existingLock.Should().Be("locked");
    }
}
Location: services/inventory/tests/Inventory.IntegrationTests/

Running Integration Tests

# Run all integration tests
dotnet test --filter Category=Integration

# Run with Testcontainers logs
dotnet test --logger "console;verbosity=detailed"
Integration tests require Docker to be running on your machine for Testcontainers.

Smoke Testing

Docker Compose Smoke Test

The platform includes a comprehensive smoke test script that validates the entire end-to-end workflow:
#!/bin/bash
# docker-smoke-test.sh

set -e

echo "🚀 Starting smoke test..."

# Step 1: Start all services
echo "📦 Starting Docker Compose services..."
cd infra
docker compose up -d
echo "⏳ Waiting 30 seconds for services to initialize..."
sleep 30

# Step 2: Verify health endpoints
echo "🏥 Checking service health..."
curl -f http://localhost:50001/health || { echo "❌ Catalog service failed"; exit 1; }
curl -f http://localhost:50002/health || { echo "❌ Inventory service failed"; exit 1; }
curl -f http://localhost:5003/health || { echo "❌ Ordering service failed"; exit 1; }
echo "✅ All services are healthy"

# Step 3: Get event and seatmap
echo "📅 Fetching event seatmap..."
EVENT_ID="550e8400-e29b-41d4-a716-446655440000"
EVENT=$(curl -s http://localhost:50001/events/$EVENT_ID/seatmap)
SEAT_ID=$(echo $EVENT | jq -r '.seats[0].id')
PRICE=$(echo $EVENT | jq -r '.seats[0].price')
echo "✅ Event loaded. Seat ID: $SEAT_ID, Price: $PRICE"

# Step 4: Create reservation
echo "🎫 Creating reservation..."
RESERVATION=$(curl -s -X POST http://localhost:50002/reservations \
  -H "Content-Type: application/json" \
  -d "{
    \"seatId\": \"$SEAT_ID\",
    \"customerId\": \"smoke-test-customer\"
  }")
RESERVATION_ID=$(echo $RESERVATION | jq -r '.reservationId')
echo "✅ Reservation created: $RESERVATION_ID"

# Step 5: Wait for Kafka event
echo "⏳ Waiting 3 seconds for Kafka event propagation..."
sleep 3

# Step 6: Add to cart
echo "🛒 Adding to cart..."
ORDER=$(curl -s -X POST http://localhost:5003/cart/add \
  -H "Content-Type: application/json" \
  -d "{
    \"reservationId\": \"$RESERVATION_ID\",
    \"seatId\": \"$SEAT_ID\",
    \"price\": $PRICE,
    \"userId\": \"smoke-test-user\"
  }")
ORDER_ID=$(echo $ORDER | jq -r '.id')
echo "✅ Order created: $ORDER_ID"

# Step 7: Checkout
echo "💳 Checking out..."
FINAL_ORDER=$(curl -s -X POST http://localhost:5003/orders/checkout \
  -H "Content-Type: application/json" \
  -d "{
    \"orderId\": \"$ORDER_ID\",
    \"userId\": \"smoke-test-user\"
  }")
STATE=$(echo $FINAL_ORDER | jq -r '.state')
echo "✅ Checkout complete. Order state: $STATE"

if [ "$STATE" = "pending" ] || [ "$STATE" = "completed" ]; then
    echo "✅✅✅ SMOKE TEST PASSED ✅✅✅"
    exit 0
else
    echo "❌ SMOKE TEST FAILED: Unexpected order state: $STATE"
    exit 1
fi
Run smoke test:
chmod +x docker-smoke-test.sh
./docker-smoke-test.sh

CI/CD Integration

Add smoke tests to your GitHub Actions workflow:
name: CI/CD Pipeline

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'
      - name: Run unit tests
        run: dotnet test --filter Category!=Integration

  integration-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'
      - name: Run integration tests
        run: dotnet test --filter Category=Integration

  smoke-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run smoke tests
        run: |
          chmod +x docker-smoke-test.sh
          ./docker-smoke-test.sh

Test Coverage

Generate Coverage Report

# Install ReportGenerator
dotnet tool install -g dotnet-reportgenerator-globaltool

# Run tests with coverage
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura

# Generate HTML report
reportgenerator \
  -reports:"**/coverage.cobertura.xml" \
  -targetdir:"coverage-report" \
  -reporttypes:Html

# Open report
open coverage-report/index.html

Coverage Goals

  • Domain Layer: 90%+ (pure business logic)
  • Application Layer: 80%+ (use cases with mocked dependencies)
  • Infrastructure Layer: 60%+ (integration tests with real dependencies)
  • API Layer: 50%+ (mostly covered by smoke tests)

Best Practices

Arrange-Act-Assert (AAA): Structure every test with clear sections:
  • Arrange: Set up test data and mocks
  • Act: Execute the system under test
  • Assert: Verify the expected outcome

Naming Conventions

// ✅ Good: MethodName_StateUnderTest_ExpectedBehavior
Handle_WhenSeatAvailable_ShouldCreateReservation()
IsExpired_WhenExpirationPassed_ShouldReturnTrue()
AcquireLock_WhenLockExists_ShouldReturnFalse()

// ❌ Bad: Vague or unclear names
Test1()
HandleTest()
ShouldWork()

Test Data Builders

public class ReservationBuilder
{
    private Guid _id = Guid.NewGuid();
    private Guid _seatId = Guid.NewGuid();
    private Guid _customerId = Guid.NewGuid();
    private DateTime _expiresAt = DateTime.UtcNow.AddMinutes(15);

    public ReservationBuilder WithId(Guid id)
    {
        _id = id;
        return this;
    }

    public ReservationBuilder WithSeatId(Guid seatId)
    {
        _seatId = seatId;
        return this;
    }

    public ReservationBuilder Expired()
    {
        _expiresAt = DateTime.UtcNow.AddMinutes(-1);
        return this;
    }

    public Reservation Build()
    {
        return new Reservation
        {
            Id = _id,
            SeatId = _seatId,
            CustomerId = _customerId,
            ExpiresAt = _expiresAt,
            Status = ReservationStatus.Active
        };
    }
}

// Usage
var expiredReservation = new ReservationBuilder()
    .Expired()
    .Build();

Troubleshooting

Testcontainers Issues

Ensure Docker is running before executing integration tests.
# Check Docker status
docker ps

# View Testcontainers logs
export TESTCONTAINERS_RYUK_DISABLED=false
dotnet test --logger "console;verbosity=detailed"

Flaky Tests

  • Use deterministic test data: Avoid DateTime.Now, use fixed dates
  • Avoid Thread.Sleep: Use async waiting with timeouts
  • Isolate tests: Each test should clean up its own data

Next Steps

Deployment Guide

Learn how to deploy the platform to Docker, Kubernetes, or cloud providers

Kafka Events

Understand event schemas and how to produce/consume Kafka events

Build docs developers (and LLMs) love