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
- Test One Thing: Each test should verify a single behavior
- Clear Names: Test names should describe what is being tested
- Arrange-Act-Assert: Follow the AAA pattern consistently
- Independent Tests: Tests should not depend on execution order
- Fast Tests: Unit tests should run in milliseconds
- Deterministic: Tests should produce the same result every time
Dynaplex-Specific Practices
- Mock External Services: Use WireMock or similar for external dependencies
- Test Service Boundaries: Validate API contracts thoroughly
- Test Migrations: Ensure database migrations work up and down
- Test Generated Clients: Verify Kiota-generated clients match server contracts
- Test Health Checks: Ensure services report health correctly
- 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
- xUnit Documentation
- Moq Framework
- Shouldly
- WireMock.Net
- NBomber
- Selenium WebDriver
- .NET Aspire Testing
๐ 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"));
}