Documentation

how-to/run-tests.md

Testing Guide for Dynaplex Architecture

This guide covers testing strategies, patterns, and best practices for the Acsis Core Dynaplex architecture.

๐ŸŽฏ Testing Philosophy

The Dynaplex architecture requires a multi-layered testing approach:

  • Unit Tests: Test individual components in isolation
  • Integration Tests: Test service interactions
  • Contract Tests: Validate API contracts between services
  • End-to-End Tests: Test complete user scenarios
  • Performance Tests: Ensure scalability and responsiveness

๐Ÿ—๏ธ Testing Architecture

tests/
โ”œโ”€โ”€ unit/                    # Component unit tests
โ”œโ”€โ”€ integration/            # Service integration tests
โ”œโ”€โ”€ contract/              # API contract tests
โ”œโ”€โ”€ e2e/                   # End-to-end tests
โ””โ”€โ”€ performance/           # Load and performance tests

๐Ÿงช Unit Testing

Component Testing Structure

Each Dynaplex component should have its own test project:

engines/your-component/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ Acsis.Dynaplex.Engines.YourComponent.Abstractions/
โ”‚   โ””โ”€โ”€ Acsis.Dynaplex.Engines.YourComponent/
โ””โ”€โ”€ test/
    โ”œโ”€โ”€ Acsis.Dynaplex.Engines.YourComponent.Tests/
    โ””โ”€โ”€ Acsis.Dynaplex.Engines.YourComponent.Abstractions.Tests/

Unit Test Patterns

Testing Service Logic

public class AssetServiceTests
{
    private readonly Mock<IAssetRepository> _repositoryMock;
    private readonly Mock<ILogger<AssetService>> _loggerMock;
    private readonly AssetService _sut; // System Under Test

    public AssetServiceTests()
    {
        _repositoryMock = new Mock<IAssetRepository>();
        _loggerMock = new Mock<ILogger<AssetService>>();
        _sut = new AssetService(_repositoryMock.Object, _loggerMock.Object);
    }

    [Fact]
    public async Task GetAssetById_ValidId_ReturnsAsset()
    {
        // Arrange
        var assetId = Guid.NewGuid();
        var expectedAsset = new Asset { Id = assetId, Name = "Test Asset" };
        _repositoryMock.Setup(r => r.GetByIdAsync(assetId))
            .ReturnsAsync(expectedAsset);

        // Act
        var result = await _sut.GetAssetByIdAsync(assetId);

        // Assert
        Assert.NotNull(result);
        Assert.Equal(expectedAsset.Id, result.Id);
        Assert.Equal(expectedAsset.Name, result.Name);
        _repositoryMock.Verify(r => r.GetByIdAsync(assetId), Times.Once);
    }

    [Fact]
    public async Task GetAssetById_InvalidId_ThrowsNotFoundException()
    {
        // Arrange
        var invalidId = Guid.NewGuid();
        _repositoryMock.Setup(r => r.GetByIdAsync(invalidId))
            .ReturnsAsync((Asset)null);

        // Act & Assert
        await Assert.ThrowsAsync<NotFoundException>(
            () => _sut.GetAssetByIdAsync(invalidId)
        );
    }
}

Testing Controllers

public class AssetControllerTests
{
    private readonly Mock<IAssetService> _serviceMock;
    private readonly AssetController _controller;

    public AssetControllerTests()
    {
        _serviceMock = new Mock<IAssetService>();
        _controller = new AssetController(_serviceMock.Object);
    }

    [Fact]
    public async Task Get_ReturnsOkResult_WithAssetList()
    {
        // Arrange
        var assets = new List<AssetDto>
        {
            new() { Id = Guid.NewGuid(), Name = "Asset 1" },
            new() { Id = Guid.NewGuid(), Name = "Asset 2" }
        };
        _serviceMock.Setup(s => s.GetAllAsync()).ReturnsAsync(assets);

        // Act
        var result = await _controller.Get();

        // Assert
        var okResult = Assert.IsType<OkObjectResult>(result.Result);
        var returnedAssets = Assert.IsAssignableFrom<IEnumerable<AssetDto>>(okResult.Value);
        Assert.Equal(2, returnedAssets.Count());
    }
}

Mocking Strategies

Using Test Doubles

// In-memory repository for testing
public class InMemoryAssetRepository : IAssetRepository
{
    private readonly List<Asset> _assets = new();

    public Task<Asset> GetByIdAsync(Guid id)
    {
        return Task.FromResult(_assets.FirstOrDefault(a => a.Id == id));
    }

    public Task<Asset> CreateAsync(Asset asset)
    {
        _assets.Add(asset);
        return Task.FromResult(asset);
    }
}

Using WebApplicationFactory

public class AssetApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;

    public AssetApiTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace real services with test doubles
                services.RemoveAll<IAssetRepository>();
                services.AddSingleton<IAssetRepository, InMemoryAssetRepository>();
            });
        });
        _client = _factory.CreateClient();
    }

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

        // Assert
        response.EnsureSuccessStatusCode();
        Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType);
    }
}

๐Ÿ”— Integration Testing

Service-to-Service Testing

public class ServiceIntegrationTests : IAsyncLifetime
{
    private DistributedApplication _app;
    private HttpClient _coreDataClient;
    private HttpClient _catalogClient;

    public async Task InitializeAsync()
    {
        var appHost = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.Acsis_Dynaplex_Projects_BbuRfid>();

        _app = await appHost.BuildAsync();
        await _app.StartAsync();

        _coreDataClient = _app.CreateHttpClient("core-data");
        _catalogClient = _app.CreateHttpClient("catalog");
    }

    [Fact]
    public async Task CatalogService_CanRetrieveDataFromCoreData()
    {
        // Create test data in CoreData
        var asset = new { Name = "Test Asset", Type = "Equipment" };
        var createResponse = await _coreDataClient.PostAsJsonAsync("/api/assets", asset);
        createResponse.EnsureSuccessStatusCode();
        var createdAsset = await createResponse.Content.ReadFromJsonAsync<AssetDto>();

        // Verify Catalog service can access it
        var catalogResponse = await _catalogClient.GetAsync($"/api/catalog/assets/{createdAsset.Id}");
        catalogResponse.EnsureSuccessStatusCode();

        var catalogAsset = await catalogResponse.Content.ReadFromJsonAsync<CatalogAssetDto>();
        Assert.Equal(createdAsset.Name, catalogAsset.Name);
    }

    public async Task DisposeAsync()
    {
        await _app.StopAsync();
        await _app.DisposeAsync();
    }
}

Database Integration Tests

public class DatabaseIntegrationTests : IDisposable
{
    private readonly SqlConnection _connection;
    private readonly string _databaseName;

    public DatabaseIntegrationTests()
    {
        _databaseName = $"TestDb_{Guid.NewGuid():N}";
        var connectionString = $"Server=(localdb)\\mssqllocaldb;Database={_databaseName};Trusted_Connection=True;";
        _connection = new SqlConnection(connectionString);

        // Run migrations
        var migrator = new DbMigrator(connectionString);
        migrator.MigrateUp();
    }

    [Fact]
    public async Task AssetRepository_CanPersistAndRetrieve()
    {
        // Arrange
        var repository = new AssetRepository(_connection);
        var asset = new Asset
        {
            Id = Guid.NewGuid(),
            Name = "Test Asset",
            CreatedAt = DateTime.UtcNow
        };

        // Act
        await repository.CreateAsync(asset);
        var retrieved = await repository.GetByIdAsync(asset.Id);

        // Assert
        Assert.NotNull(retrieved);
        Assert.Equal(asset.Name, retrieved.Name);
    }

    public void Dispose()
    {
        _connection?.Dispose();
        // Clean up test database
        using var masterConnection = new SqlConnection("Server=(localdb)\\mssqllocaldb;Database=master;Trusted_Connection=True;");
        masterConnection.Execute($"DROP DATABASE IF EXISTS [{_databaseName}]");
    }
}

๐Ÿ“ Contract Testing

API Contract Validation

public class ApiContractTests
{
    [Fact]
    public async Task AssetApi_ConformsToOpenApiSpec()
    {
        // Load OpenAPI spec
        var specPath = "openapi/asset-service.yaml";
        var spec = await OpenApiDocument.LoadAsync(specPath);

        // Create test client
        using var app = new WebApplicationFactory<Program>();
        var client = app.CreateClient();

        // Validate all endpoints
        foreach (var path in spec.Paths)
        {
            foreach (var operation in path.Value.Operations)
            {
                var response = await client.SendAsync(
                    new HttpRequestMessage(
                        new HttpMethod(operation.Key.ToString()),
                        path.Key
                    )
                );

                // Verify response matches spec
                Assert.Contains(
                    response.StatusCode.ToString(),
                    operation.Value.Responses.Keys
                );
            }
        }
    }
}

Generated Client Testing

public class ApiClientContractTests
{
    [Fact]
    public async Task GeneratedClient_MatchesServerContract()
    {
        // Arrange
        var server = new WireMockServer();
        server.Given(Request.Create().WithPath("/api/assets"))
              .RespondWith(Response.Create()
                  .WithStatusCode(200)
                  .WithBodyAsJson(new[] { new { Id = "123", Name = "Asset" } }));

        var httpClient = new HttpClient { BaseAddress = new Uri(server.Urls[0]) };
        var apiClient = new AssetApiClient(httpClient);

        // Act
        var assets = await apiClient.Assets.GetAsync();

        // Assert
        Assert.NotNull(assets);
        Assert.Single(assets);

        // Verify the request matched expected format
        var requests = server.LogEntries;
        Assert.Single(requests);
        Assert.Equal("/api/assets", requests[0].RequestMessage.Path);
    }
}

๐Ÿš€ End-to-End Testing

User Scenario Testing

public class AssetManagementE2ETests : IAsyncLifetime
{
    private IWebDriver _driver;
    private DistributedApplication _app;
    private string _appUrl;

    public async Task InitializeAsync()
    {
        // Start the application
        var appHost = await DistributedApplicationTestingBuilder
            .CreateAsync<Projects.Acsis_Dynaplex_Projects_BbuRfid>();
        _app = await appHost.BuildAsync();
        await _app.StartAsync();

        _appUrl = _app.GetEndpoint("acsis-assettrak-ui");

        // Setup Selenium
        _driver = new ChromeDriver();
        _driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(10);
    }

    [Fact]
    public async Task CreateAndEditAsset_CompleteWorkflow()
    {
        // Navigate to assets page
        _driver.Navigate().GoToUrl($"{_appUrl}/assets");

        // Click create button
        _driver.FindElement(By.Id("create-asset-btn")).Click();

        // Fill in asset details
        _driver.FindElement(By.Id("asset-name")).SendKeys("Test Equipment");
        _driver.FindElement(By.Id("asset-type")).SendKeys("Machinery");
        _driver.FindElement(By.Id("save-btn")).Click();

        // Verify asset was created
        await Task.Delay(1000); // Wait for navigation
        Assert.Contains("Test Equipment", _driver.PageSource);

        // Edit the asset
        _driver.FindElement(By.CssSelector("[data-asset-name='Test Equipment'] .edit-btn")).Click();
        _driver.FindElement(By.Id("asset-name")).Clear();
        _driver.FindElement(By.Id("asset-name")).SendKeys("Updated Equipment");
        _driver.FindElement(By.Id("save-btn")).Click();

        // Verify update
        await Task.Delay(1000);
        Assert.Contains("Updated Equipment", _driver.PageSource);
    }

    public async Task DisposeAsync()
    {
        _driver?.Quit();
        await _app.StopAsync();
        await _app.DisposeAsync();
    }
}

๐Ÿƒ Performance Testing

Load Testing with NBomber

public class PerformanceTests
{
    [Fact]
    public void AssetApi_HandlesExpectedLoad()
    {
        var scenario = Scenario.Create("asset_api_load_test", async context =>
        {
            var client = new HttpClient { BaseAddress = new Uri("https://localhost:40443") };

            var response = await client.GetAsync("/api/assets");

            return response.IsSuccessStatusCode ? Response.Ok() : Response.Fail();
        })
        .WithLoadSimulations(
            Simulation.InjectPerSec(rate: 100, during: TimeSpan.FromSeconds(30))
        );

        var stats = NBomberRunner
            .RegisterScenarios(scenario)
            .Run();

        // Assert performance requirements
        Assert.True(stats.AllOkCount > 0);
        Assert.True(stats.AllFailCount < stats.AllOkCount * 0.01); // Less than 1% failure
        Assert.True(stats.ScenarioStats[0].Ok.Latency.Mean < 200); // Mean latency < 200ms
    }
}

Stress Testing

public class StressTests
{
    [Fact]
    public async Task System_RecoversFr gracomOverload()
    {
        var tasks = new List<Task<HttpResponseMessage>>();
        var client = new HttpClient();

        // Generate excessive load
        for (int i = 0; i < 10000; i++)
        {
            tasks.Add(client.GetAsync("https://localhost:40443/api/assets"));
        }

        var responses = await Task.WhenAll(tasks);

        // Some requests may fail
        var successCount = responses.Count(r => r.IsSuccessStatusCode);
        var failureCount = responses.Length - successCount;

        // Wait for system to recover
        await Task.Delay(5000);

        // Verify system recovers
        var recoveryResponse = await client.GetAsync("https://localhost:40443/health");
        Assert.True(recoveryResponse.IsSuccessStatusCode);
    }
}

๐Ÿ› ๏ธ Test Infrastructure

Test Data Management

public class TestDataBuilder
{
    public Asset CreateAsset(string name = null)
    {
        return new Asset
        {
            Id = Guid.NewGuid(),
            Name = name ?? $"Asset_{Guid.NewGuid():N}",
            CreatedAt = DateTime.UtcNow,
            Status = AssetStatus.Active
        };
    }

    public List<Asset> CreateAssets(int count)
    {
        return Enumerable.Range(0, count)
            .Select(_ => CreateAsset())
            .ToList();
    }
}

Test Fixtures

public class DatabaseFixture : IDisposable
{
    public SqlConnection Connection { get; }
    public string ConnectionString { get; }

    public DatabaseFixture()
    {
        ConnectionString = $"Server=(localdb)\\mssqllocaldb;Database=TestDb_{Guid.NewGuid():N};Trusted_Connection=True;";
        Connection = new SqlConnection(ConnectionString);

        // Initialize database
        MigrateDatabase();
        SeedTestData();
    }

    private void MigrateDatabase()
    {
        var migrator = new DbMigrator(ConnectionString);
        migrator.MigrateUp();
    }

    private void SeedTestData()
    {
        // Add common test data
    }

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

๐Ÿ“Š Test Coverage

Measuring Coverage

# Run tests with coverage
dotnet test --collect:"XPlat Code Coverage" --results-directory ./TestResults

# Generate report
reportgenerator -reports:TestResults/**/coverage.cobertura.xml -targetdir:coveragereport -reporttypes:Html

# View report
open coveragereport/index.html

Coverage Requirements

  • Unit Tests: Minimum 80% code coverage
  • Critical Paths: 100% coverage for authentication, authorization, data access
  • New Code: All new code must include tests
  • Bug Fixes: Include regression tests

๐Ÿ”ง Testing Best Practices

General Guidelines

  1. Test One Thing: Each test should verify a single behavior
  2. Clear Names: Test names should describe what is being tested
  3. Arrange-Act-Assert: Follow the AAA pattern consistently
  4. Independent Tests: Tests should not depend on execution order
  5. Fast Tests: Unit tests should run in milliseconds
  6. Deterministic: Tests should produce the same result every time

Dynaplex-Specific Practices

  1. Mock External Services: Use WireMock or similar for external dependencies
  2. Test Service Boundaries: Validate API contracts thoroughly
  3. Test Migrations: Ensure database migrations work up and down
  4. Test Generated Clients: Verify Kiota-generated clients match server contracts
  5. Test Health Checks: Ensure services report health correctly
  6. Test Resilience: Verify retry policies and circuit breakers

Anti-Patterns to Avoid

โŒ Don't:

  • Test implementation details
  • Use production databases
  • Ignore flaky tests
  • Skip error scenarios
  • Hardcode test data
  • Share state between tests

โœ… Do:

  • Test behavior and outcomes
  • Use isolated test databases
  • Fix or remove flaky tests
  • Test error handling
  • Use test data builders
  • Ensure test isolation

๐Ÿšฆ Continuous Testing

CI/CD Pipeline

# Azure DevOps Pipeline
trigger:
  - main
  - develop

stages:
  - stage: Test
    jobs:
      - job: UnitTests
        steps:
          - task: DotNetCoreCLI@2
            inputs:
              command: 'test'
              projects: '**/test/**/*.Tests.csproj'
              arguments: '--collect:"XPlat Code Coverage"'

      - job: IntegrationTests
        steps:
          - task: DockerCompose@0
            inputs:
              action: 'Run services'
              dockerComposeFile: 'docker-compose.test.yml'

          - task: DotNetCoreCLI@2
            inputs:
              command: 'test'
              projects: '**/test/**/*.IntegrationTests.csproj'

      - job: ContractTests
        steps:
          - task: DotNetCoreCLI@2
            inputs:
              command: 'test'
              projects: '**/test/**/*.ContractTests.csproj'

Test Reporting

Configure test reporting in your CI/CD:

  • Unit test results
  • Code coverage reports
  • Performance test results
  • Failed test analysis
  • Trend analysis over time

๐Ÿ“š Resources

๐Ÿ†˜ Troubleshooting Tests

Common issues and solutions:

Test Discovery Issues

# Clear test cache
dotnet clean
dotnet build
dotnet test --no-build

Database Connection Issues

// Use retry logic for database tests
[Fact]
public async Task DatabaseTest()
{
    await Policy
        .Handle<SqlException>()
        .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)))
        .ExecuteAsync(async () =>
        {
            // Database test code
        });
}

Flaky Service Tests

// Add delays for service startup
public async Task InitializeAsync()
{
    await _app.StartAsync();

    // Wait for services to be ready
    await Policy
        .HandleResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode)
        .WaitAndRetryAsync(10, _ => TimeSpan.FromSeconds(1))
        .ExecuteAsync(() => httpClient.GetAsync("/health"));
}