Testing Guide¶
This guide covers how to run tests, generate coverage reports, and add new tests to the Tempo project.
Overview¶
The Tempo API uses xUnit for testing with coverlet for code coverage collection. Tests are organized into:
- Unit Tests: Test individual services in isolation (located in
api/Tempo.Api.Tests/Services/) - Integration Tests: Test endpoints and full request/response cycles (located in
api/Tempo.Api.Tests/IntegrationTests/)
Running Tests¶
Run All Tests¶
Run Tests with Coverage¶
This generates a Cobertura XML coverage report in the TestResults/ directory.
Run Specific Tests¶
# Run tests in a specific class
dotnet test --filter "FullyQualifiedName~AuthEndpointsTests"
# Run tests matching a pattern
dotnet test --filter "FullyQualifiedName~ServiceTests"
Verbose Output¶
Code Coverage¶
Generating Coverage Reports¶
-
Run tests with coverage collection:
-
Install ReportGenerator (optional, for HTML reports):
-
Generate HTML report:
The HTML report will be in the coverage-report/ directory. Open index.html in a browser.
Coverage Configuration¶
Coverage is configured in api/Tempo.Api.Tests/coverlet.runsettings:
- Includes:
[Tempo.Api]*- Only API code is measured - Excludes:
[Tempo.Api.Tests]*- Test code is excluded**/Program.cs- Startup code is excluded**/Migrations/**- Database migrations are excluded- Generated code (via attributes)
Coverage Threshold¶
The CI pipeline enforces a minimum 45% code coverage threshold. If coverage drops below 45%, the CI build will fail. This threshold will be gradually increased as test coverage improves.
Viewing Coverage Locally¶
- Cobertura XML: Generated in
TestResults/*/coverage.cobertura.xml - HTML Report: Generate using ReportGenerator (see above)
- IDE Integration: Many IDEs (Visual Studio, Rider) can display coverage inline
Interpreting Coverage¶
- Line Coverage: Percentage of code lines executed during tests
- Branch Coverage: Percentage of conditional branches (if/else, switch) executed
- Overall Coverage: Weighted average of line and branch coverage
What to test: - ✅ Happy paths (normal operation) - ✅ Error paths (validation failures, exceptions) - ✅ Edge cases (boundary conditions, null values) - ✅ Configuration variations - ✅ Security-sensitive code (authentication, authorization)
Adding New Tests¶
Test Structure¶
Follow the existing patterns in the codebase:
Unit Test Example¶
using FluentAssertions;
using Tempo.Api.Services;
using Xunit;
namespace Tempo.Api.Tests.Services;
public class MyServiceTests
{
private readonly MyService _service;
public MyServiceTests()
{
_service = new MyService();
}
[Fact]
public void MyMethod_WithValidInput_ReturnsExpectedResult()
{
// Arrange
var input = "test";
// Act
var result = _service.MyMethod(input);
// Assert
result.Should().Be("expected");
}
}
Integration Test Example¶
using System.Net;
using FluentAssertions;
using Tempo.Api.Tests.Infrastructure;
using Xunit;
namespace Tempo.Api.Tests.IntegrationTests;
[Collection("Integration Tests")]
public class MyEndpointsTests : IClassFixture<TempoWebApplicationFactory>
{
private readonly TempoWebApplicationFactory _factory;
public MyEndpointsTests(TempoWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task GetEndpoint_ReturnsSuccess()
{
// Arrange
var client = await TestHttpClientFactory.CreateAuthenticatedClientAsync(_factory);
// Act
var response = await client.GetAsync("/my-endpoint");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
Test Conventions¶
- Naming:
MethodName_Scenario_ExpectedBehavior -
Example:
Login_WithInvalidPassword_ReturnsUnauthorized -
Organization: Use
#regionblocks to group related tests -
Assertions: Use FluentAssertions for readable assertions
-
Test Data: Use
TestDataSeederfor consistent test data -
Database: Use in-memory SQLite for unit tests
private readonly SqliteConnection _connection; private readonly TempoDbContext _db; public MyServiceTests() { _connection = new SqliteConnection("Data Source=:memory:"); _connection.Open(); var options = new DbContextOptionsBuilder<TempoDbContext>() .UseSqlite(_connection) .Options; _db = new TempoDbContext(options); _db.Database.EnsureCreated(); } -
Cleanup: Implement
IDisposablefor test cleanup
Test Collections¶
Integration tests use test collections to ensure proper isolation:
[Collection("Integration Tests")]
public class MyIntegrationTests : IClassFixture<TempoWebApplicationFactory>
{
// ...
}
This ensures tests run sequentially and don't interfere with each other.
Test Infrastructure¶
TestHttpClientFactory¶
Helper for creating authenticated HTTP clients:
// Create authenticated client
var client = await TestHttpClientFactory.CreateAuthenticatedClientAsync(_factory);
// Create authenticated client with custom user
var client = await TestHttpClientFactory.CreateAuthenticatedClientAsync(_factory, "username", "password");
// Create unauthenticated client
var client = TestHttpClientFactory.CreateUnauthenticatedClient(_factory);
TestDataSeeder¶
Helper for seeding test data:
// Seed user
var user = await TestDataSeeder.SeedUserAsync(_db, "username", "password");
// Seed workout
var workout = await TestDataSeeder.SeedWorkoutAsync(_db, distanceM: 5000);
// Seed workout with time series
var workout = await TestDataSeeder.SeedWorkoutCompleteAsync(_db, distanceM: 10000, includeTimeSeries: true);
TempoWebApplicationFactory¶
Factory for creating test web applications. Automatically handles: - Database setup (in-memory SQLite) - Service configuration - Authentication setup
CI Coverage Gate¶
The CI pipeline automatically:
- Runs all tests with coverage collection
- Generates coverage reports
- Checks if coverage meets the 45% threshold
- Fails the build if coverage is below 45%
Fixing Coverage Failures¶
If coverage fails:
- Identify uncovered code: Check the coverage report to see which files/methods are uncovered
- Add tests: Write tests for uncovered code paths
- Focus on critical code: Prioritize endpoints, services, and security-sensitive code
- Test edge cases: Don't just test happy paths - test error conditions, validation, and edge cases
Coverage Exclusions¶
Some code is intentionally excluded from coverage:
Program.cs- Application startup codeMigrations/**- Database migrations- Generated code (via attributes)
- Third-party libraries (FitSDK)
If you need to exclude additional code, update coverlet.runsettings.
Best Practices¶
- Test Independence: Each test should be independent and not rely on other tests
- Clean State: Ensure tests start with a clean database state
- Meaningful Names: Test names should clearly describe what is being tested
- Arrange-Act-Assert: Structure tests with clear Arrange, Act, and Assert sections
- Test One Thing: Each test should verify one specific behavior
- Avoid Test Interdependence: Don't rely on test execution order
- Mock External Dependencies: Use Moq for external services (HTTP clients, file system, etc.)
- Fast Tests: Keep tests fast - use in-memory databases, avoid I/O when possible
Troubleshooting¶
Tests Fail Locally But Pass in CI¶
- Check database state - ensure clean database between tests
- Verify test isolation - tests shouldn't depend on each other
- Check for timing issues - add delays if needed for async operations
Coverage Not Generating¶
- Ensure
coverlet.collectorpackage is installed - Check that
coverlet.runsettingsis correctly referenced - Verify test execution completed successfully
Coverage Too Low¶
- Review coverage report to identify gaps
- Add tests for uncovered methods
- Focus on critical paths first (endpoints, services)
- Test error conditions and edge cases