Skip to content

Testing Guide - Essert.MF

This guide explains the testing strategy, organization, and best practices for the Essert.MF project.

Table of Contents

Testing Philosophy

The Essert.MF project follows hexagonal architecture principles, and the test suite is organized to reflect this architecture. Tests are separated by architectural layer and test type, ensuring:

  1. Fast Feedback: Domain and application tests run in milliseconds without external dependencies
  2. Isolation: Each layer tests its responsibilities in isolation using mocks/stubs
  3. Integration Confidence: Integration tests verify that adapters work with real external systems
  4. Performance Validation: Performance tests ensure the system meets non-functional requirements

Test Pyramid

           ┌─────────────┐
           │   E2E/API   │  ← Few, slow, expensive
           │   (123)     │     (Essert.MF.API.Rest.Tests)
           └─────────────┘
         ┌─────────────────┐
         │  Integration    │  ← Some, slower, DB required
         │    Tests        │     (Infrastructure.Tests/Integration)
         └─────────────────┘
       ┌───────────────────────┐
       │     Unit Tests        │  ← Many, fast, isolated
       │ Domain + Application  │     (Domain.Tests + Application.Tests)
       │       (65+)           │
       └───────────────────────┘

Test Organization

By Architectural Layer

Tests are organized to match the hexagonal architecture layers:

Tests/
├── Essert.MF.Domain.Tests/         # Core business logic (pure unit tests)
├── Essert.MF.Application.Tests/    # Use cases (unit tests with mocks)
├── Essert.MF.Infrastructure.Tests/ # Infrastructure adapters (unit + integration)
└── Essert.MF.API.Rest.Tests/      # REST API adapter (integration tests)

Infrastructure Tests by Type

Infrastructure tests are further organized by test type:

Essert.MF.Infrastructure.Tests/
├── Unit/                           # Fast, isolated, use mocks
│   ├── Repositories/               # Repository pattern logic
│   ├── Mapping/                    # AutoMapper configurations
│   ├── Extensions/                 # DI setup
│   └── Services/                   # Infrastructure services
├── Integration/                    # Slow, use real dependencies
│   ├── Persistence/                # Database operations
│   │   ├── DbContext/              # Database connectivity
│   │   ├── Operations/             # CRUD operations
│   │   └── Repositories/           # Repository implementations
│   └── Mapping/                    # Complex mapping scenarios
└── Performance/                    # Benchmarks and performance validation
    ├── Persistence/                # Database performance
    ├── Repository/                 # Repository pattern performance
    ├── Service/                    # Service layer performance
    └── Tools/                      # Performance measurement utilities

Test Types

1. Domain Tests (Pure Unit Tests)

Location: Essert.MF.Domain.Tests/

Purpose: Test business logic in isolation

Characteristics: - No external dependencies (no database, no file system, no network) - Fast execution (< 1 second for entire suite) - Test domain entities, value objects, and domain services - Use plain objects and assertions

Example:

public class ProcessStateTests
{
    [Fact]
    public void Active_State_Should_Allow_Transition_To_Completed()
    {
        // Arrange
        var state = ProcessState.Active;

        // Act
        var canTransition = state.CanTransitionTo(ProcessState.Completed);

        // Assert
        canTransition.Should().BeTrue();
    }
}

When to write: - Testing business rules - Testing value object validation - Testing entity behavior - Testing domain calculations

2. Application Tests (Use Case Tests)

Location: Essert.MF.Application.Tests/

Purpose: Test application use cases with mocked dependencies

Characteristics: - Use mocks for infrastructure dependencies (repositories, external services) - Fast execution (< 2 seconds for entire suite) - Test command/query handlers - Verify correct orchestration of domain logic

Example:

public class CreateProductHandlerTests
{
    [Fact]
    public async Task Handle_ValidCommand_CreatesProduct()
    {
        // Arrange
        var mockRepo = new Mock<IProductRepository>();
        var mockUnitOfWork = new Mock<IUnitOfWork>();
        var handler = new CreateProductHandler(mockRepo.Object, mockUnitOfWork.Object);

        var command = new CreateProductCommand("TestProduct", "1.0");

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

        // Assert
        result.Should().NotBeNull();
        mockRepo.Verify(r => r.AddAsync(It.IsAny<Product>(), It.IsAny<CancellationToken>()), Times.Once);
        mockUnitOfWork.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
    }
}

When to write: - Testing use case handlers - Testing application service logic - Testing validation rules - Testing error handling

3. Infrastructure Unit Tests

Location: Essert.MF.Infrastructure.Tests/Unit/

Purpose: Test infrastructure components in isolation

Characteristics: - Use in-memory databases or mocks - Fast execution - Test mapping configurations, DI setup, repository patterns

Example:

public class DomainToEfMappingProfileTests
{
    [Fact]
    public void Mapping_Configuration_Should_Be_Valid()
    {
        // Arrange
        var config = new MapperConfiguration(cfg =>
            cfg.AddProfile<DomainToEfMappingProfile>());

        // Act & Assert
        config.AssertConfigurationIsValid();
    }
}

When to write: - Testing AutoMapper profiles - Testing DI configuration - Testing repository patterns without database - Testing infrastructure service logic

4. Infrastructure Integration Tests

Location: Essert.MF.Infrastructure.Tests/Integration/

Purpose: Test infrastructure adapters with real dependencies

Characteristics: - Use real databases (requires MySQL/MariaDB running) - Slower execution (10-30 seconds) - Test actual database operations, connectivity, relationships

Example:

public class WpcDbContextTests : IDisposable
{
    private readonly WpcDbContext _context;

    public WpcDbContextTests()
    {
        var config = new ConfigurationBuilder()
            .AddJsonFile("appsettings.test.json")
            .Build();

        var connectionString = config.GetConnectionString("Wpc");
        var options = new DbContextOptionsBuilder<WpcDbContext>()
            .UseMySql(connectionString, ServerVersion.AutoDetect(connectionString))
            .Options;

        _context = new WpcDbContext(options);
    }

    [Fact]
    public async Task WpcDbContext_CanConnect_To_Database()
    {
        // Act
        var canConnect = await _context.Database.CanConnectAsync();

        // Assert
        canConnect.Should().BeTrue();
    }

    public void Dispose() => _context?.Dispose();
}

When to write: - Testing database connectivity - Testing CRUD operations with real database - Testing complex queries - Testing relationships and foreign keys - Testing transactions

5. Performance Tests

Location: Essert.MF.Infrastructure.Tests/Performance/

Purpose: Validate performance requirements

Characteristics: - Measure execution time, memory usage - Establish performance baselines - Use real database for realistic measurements

Example:

public class RepositoryPerformanceTests
{
    [Fact]
    public async Task GetAll_Products_Should_Complete_Within_Threshold()
    {
        // Arrange
        var stopwatch = Stopwatch.StartNew();
        var repository = CreateProductRepository();

        // Act
        var products = await repository.GetAllAsync();
        stopwatch.Stop();

        // Assert
        products.Should().NotBeEmpty();
        stopwatch.ElapsedMilliseconds.Should().BeLessThan(5000); // 5 second threshold
    }
}

When to write: - Establishing performance baselines - Verifying query optimization - Testing bulk operations - Measuring memory usage

6. API Integration Tests

Location: Essert.MF.API.Rest.Tests/

Purpose: Test REST API endpoints end-to-end

Characteristics: - Use TestServer or WebApplicationFactory - Test HTTP requests/responses - Verify serialization, status codes, error handling - May use real or in-memory database

Example:

public class ProductsControllerTests : IClassFixture<TestWebApplicationFactory>
{
    private readonly HttpClient _client;

    public ProductsControllerTests(TestWebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task GetProducts_ReturnsOkResponse()
    {
        // Act
        var response = await _client.GetAsync("/api/products");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        var content = await response.Content.ReadAsStringAsync();
        content.Should().NotBeNullOrEmpty();
    }
}

When to write: - Testing API contracts - Testing HTTP status codes - Testing request/response serialization - Testing authentication/authorization - Testing error responses

Running Tests

# Fast tests only (< 3 seconds) - Run on every save
dotnet test Essert.MF.Domain.Tests && \
dotnet test Essert.MF.Application.Tests && \
dotnet test --filter "FullyQualifiedName~Infrastructure.Tests.Unit"

# With integration tests (~30 seconds) - Run before commit
dotnet test --filter "FullyQualifiedName~Integration"

# Full suite (~2 minutes) - Run before push
dotnet test

By Architectural Layer

# Domain Layer
dotnet test Essert.MF.Domain.Tests

# Application Layer
dotnet test Essert.MF.Application.Tests

# Infrastructure Layer
dotnet test Essert.MF.Infrastructure.Tests

# API Layer
dotnet test Essert.MF.API.Rest.Tests

By Test Type

# Unit tests only (fast)
dotnet test --filter "FullyQualifiedName~Unit"

# Integration tests only (slow, requires DB)
dotnet test --filter "FullyQualifiedName~Integration"

# Performance tests only
dotnet test --filter "FullyQualifiedName~Performance"

By Feature Area

# Manufacturing domain tests
dotnet test --filter "FullyQualifiedName~Manufacturing"

# Product domain tests
dotnet test --filter "FullyQualifiedName~Product"

# WPC-specific tests
dotnet test --filter "FullyQualifiedName~Wpc"

Best Practices

General Principles

  1. Test Behavior, Not Implementation
  2. Focus on what the code does, not how it does it
  3. Avoid testing private methods directly
  4. Test through public interfaces

  5. Follow AAA Pattern

    [Fact]
    public void Test_Name_Should_Express_Expected_Behavior()
    {
        // Arrange - Set up test data and dependencies
        var input = CreateTestInput();
    
        // Act - Execute the code under test
        var result = SystemUnderTest.Execute(input);
    
        // Assert - Verify the outcome
        result.Should().Be(expectedValue);
    }
    

  6. One Assertion Per Test (when reasonable)

  7. Each test should verify one specific behavior
  8. Related assertions can be grouped
  9. Use FluentAssertions for expressive assertions

  10. Test Names Should Be Descriptive

    // Good
    [Fact]
    public void CreateProduct_WithDuplicateName_ThrowsDomainException()
    
    // Bad
    [Fact]
    public void Test1()
    

Domain Layer Tests

  1. No Mocks Needed
  2. Domain tests should use real objects
  3. No infrastructure dependencies to mock

  4. Test Business Rules Explicitly

    [Theory]
    [InlineData(ProcessState.Active, ProcessState.Completed, true)]
    [InlineData(ProcessState.Completed, ProcessState.Active, false)]
    public void State_Transitions_Should_Follow_Business_Rules(
        ProcessState from, ProcessState to, bool expected)
    {
        var canTransition = from.CanTransitionTo(to);
        canTransition.Should().Be(expected);
    }
    

  5. Test Value Object Validation

    [Fact]
    public void VersionNumber_With_Invalid_Format_Should_Throw()
    {
        // Act
        Action act = () => VersionNumber.Create("invalid");
    
        // Assert
        act.Should().Throw<DomainException>()
           .WithMessage("*version number*");
    }
    

Application Layer Tests

  1. Always Mock Infrastructure Dependencies

    private readonly Mock<IProductRepository> _mockRepository;
    private readonly Mock<IUnitOfWork> _mockUnitOfWork;
    
    public CreateProductHandlerTests()
    {
        _mockRepository = new Mock<IProductRepository>();
        _mockUnitOfWork = new Mock<IUnitOfWork>();
    }
    

  2. Verify Repository Interactions

    // Verify method was called with specific parameters
    _mockRepository.Verify(
        r => r.AddAsync(
            It.Is<Product>(p => p.Name == "TestProduct"),
            It.IsAny<CancellationToken>()),
        Times.Once);
    

  3. Test Error Handling

    [Fact]
    public async Task Handle_RepositoryThrowsException_PropagatesException()
    {
        // Arrange
        _mockRepository
            .Setup(r => r.AddAsync(It.IsAny<Product>(), It.IsAny<CancellationToken>()))
            .ThrowsAsync(new InvalidOperationException("Database error"));
    
        var handler = new CreateProductHandler(_mockRepository.Object, _mockUnitOfWork.Object);
    
        // Act
        Func<Task> act = async () => await handler.Handle(command, CancellationToken.None);
    
        // Assert
        await act.Should().ThrowAsync<InvalidOperationException>();
    }
    

Infrastructure Layer Tests

Unit Tests

  1. Use In-Memory Databases When Possible

    var options = new DbContextOptionsBuilder<TestDbContext>()
        .UseInMemoryDatabase(databaseName: "TestDatabase")
        .Options;
    

  2. Test Mapping Configurations

    [Fact]
    public void Should_Map_Domain_Entity_To_EF_Entity()
    {
        // Arrange
        var mapper = CreateMapper();
        var domainEntity = CreateDomainEntity();
    
        // Act
        var efEntity = mapper.Map<EfEntity>(domainEntity);
    
        // Assert
        efEntity.Should().NotBeNull();
        efEntity.Name.Should().Be(domainEntity.Name);
    }
    

Integration Tests

  1. Use Real Database Connections

    var config = new ConfigurationBuilder()
        .AddJsonFile("appsettings.test.json")
        .Build();
    var connectionString = config.GetConnectionString("DatabaseName");
    

  2. Clean Up After Tests

    public class DatabaseTests : IDisposable
    {
        private readonly DbContext _context;
    
        public void Dispose()
        {
            _context?.Dispose();
        }
    }
    

  3. Use Test Transactions (Rollback After Test)

    await using var transaction = await _context.Database.BeginTransactionAsync();
    try
    {
        // Perform test operations
        await _context.SaveChangesAsync();
    
        // Assertions
    
        // Rollback (don't commit)
        await transaction.RollbackAsync();
    }
    finally
    {
        await transaction.DisposeAsync();
    }
    

API Tests

  1. Use TestWebApplicationFactory

    public class CustomWebApplicationFactory : WebApplicationFactory<Program>
    {
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureTestServices(services =>
            {
                // Override services for testing
            });
        }
    }
    

  2. Test HTTP Status Codes

    response.StatusCode.Should().Be(HttpStatusCode.OK);
    response.StatusCode.Should().Be(HttpStatusCode.Created);
    response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    

  3. Test Response Content

    var content = await response.Content.ReadAsStringAsync();
    var result = JsonSerializer.Deserialize<ProductDto>(content);
    result.Should().NotBeNull();
    result.Name.Should().Be("ExpectedName");
    

Common Patterns

Test Data Builders

Use builder pattern for complex test data:

public class ProductBuilder
{
    private string _name = "Default Product";
    private VersionNumber _version = VersionNumber.Create("1.0");

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

    public ProductBuilder WithVersion(string version)
    {
        _version = VersionNumber.Create(version);
        return this;
    }

    public Product Build()
    {
        return Product.Create(_name, _version);
    }
}

// Usage
var product = new ProductBuilder()
    .WithName("CustomProduct")
    .WithVersion("2.0")
    .Build();

Test Fixtures (xUnit)

Share expensive setup across tests:

public class DatabaseFixture : IDisposable
{
    public DbContext Context { get; }

    public DatabaseFixture()
    {
        // Expensive setup
        Context = CreateDatabaseContext();
    }

    public void Dispose()
    {
        Context?.Dispose();
    }
}

public class MyTests : IClassFixture<DatabaseFixture>
{
    private readonly DbContext _context;

    public MyTests(DatabaseFixture fixture)
    {
        _context = fixture.Context;
    }
}

Theory Tests for Multiple Scenarios

Use [Theory] for testing multiple inputs:

[Theory]
[InlineData("v1.0", true)]
[InlineData("v2.0.1", true)]
[InlineData("invalid", false)]
[InlineData("", false)]
public void VersionNumber_Validation(string input, bool isValid)
{
    // Act
    var result = VersionNumber.TryCreate(input, out var version);

    // Assert
    result.Should().Be(isValid);
}

Custom Assertions

Create custom assertions for domain-specific validation:

public static class ProductAssertions
{
    public static void ShouldBeValidProduct(this Product product)
    {
        product.Should().NotBeNull();
        product.Name.Should().NotBeNullOrEmpty();
        product.Version.Should().NotBeNull();
        product.Parameters.Should().NotBeEmpty();
    }
}

// Usage
product.ShouldBeValidProduct();

CI/CD Integration

  1. On Every Commit (< 5 seconds)

    dotnet test Essert.MF.Domain.Tests && \
    dotnet test Essert.MF.Application.Tests
    

  2. On Pull Request (< 30 seconds)

    dotnet test --filter "FullyQualifiedName~Integration"
    

  3. Nightly Build (full suite, ~2 minutes)

    dotnet test
    dotnet test --filter "FullyQualifiedName~Performance"
    

Test Coverage

Aim for: - Domain Layer: 90%+ coverage (critical business logic) - Application Layer: 80%+ coverage (use cases) - Infrastructure Layer: 70%+ coverage (focus on critical paths) - API Layer: 80%+ coverage (contracts)

Check coverage with:

dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura

Troubleshooting

Common Issues

  1. Tests fail with "Database connection error"
  2. Ensure MySQL/MariaDB is running on localhost
  3. Check connection strings in appsettings.test.json
  4. Verify database user has appropriate permissions

  5. Tests are slow

  6. Ensure you're running unit tests, not integration tests
  7. Check for unnecessary database operations
  8. Use --filter to run specific test categories

  9. AutoMapper configuration errors

  10. Run DomainToEfMappingProfileTests first
  11. Verify all mappings are registered
  12. Check for circular dependencies

  13. Flaky tests

  14. Avoid dependencies on external state
  15. Use test transactions that rollback
  16. Avoid time-based assertions (use timeouts instead)

Additional Resources

Summary

  • Organize tests by architectural layer
  • Write fast unit tests for domain and application layers
  • Use integration tests for infrastructure adapters
  • Follow AAA pattern and write descriptive test names
  • Run fast tests frequently, integration tests before commits
  • Aim for high coverage on business-critical code