Table of Contents

Testing Guide

This guide covers testing practices and tools used in the Book Store project.

Testing Framework

The project uses TUnit v1.6.28, a modern testing framework for .NET that provides:

  • Source-Generated Tests - Compile-time test discovery for faster execution
  • Parallel Execution - Tests run in parallel by default for improved performance
  • Built-in Code Coverage - No need for additional packages like Coverlet
  • Fluent Assertions - Modern async-first assertion syntax
  • Dependency Injection - Native support for injecting dependencies into test methods
  • Microsoft.Testing.Platform - Integration with .NET 10's new testing platform

Running Tests

Command Line

# Run all tests (recommended)
dotnet test

# Run tests for specific project
dotnet test --project src/ApiService/BookStore.ApiService.Tests/BookStore.ApiService.Tests.csproj

# Run tests directly (alternative method)
dotnet run --project src/ApiService/BookStore.ApiService.Tests/BookStore.ApiService.Tests.csproj

IDE Support

TUnit works with all major .NET IDEs:

  • Visual Studio 2022 (17.13+) - Fully supported, no additional configuration needed
  • JetBrains Rider - Enable "Testing Platform support" in Settings → Build, Execution, Deployment → Unit Testing
  • Visual Studio Code - Install C# Dev Kit and enable "Use Testing Platform Protocol"

Test Structure

Test Files

src/ApiService/BookStore.ApiService.Tests/
├── Handlers/
│   └── BookHandlerTests.cs          # Command handler tests
└── JsonSerializationTests.cs        # JSON standards verification

Test Anatomy

using TUnit.Core;
using TUnit.Assertions;

public class BookHandlerTests
{
    [Test]
    public async Task CreateBookHandler_ShouldStartStreamWithBookAddedEvent()
    {
        // Arrange
        var command = new CreateBook(...);
        var session = Substitute.For<IDocumentSession>();
        
        // Act
        var result = BookHandlers.Handle(command, session);
        
        // Assert
        _ = await Assert.That(result).IsNotNull();
        session.Events.Received(1).StartStream<BookAggregate>(...);
    }
}

TUnit Assertions

TUnit uses a fluent, async-first assertion syntax:

Basic Assertions

// Equality
_ = await Assert.That(actual).IsEqualTo(expected);
_ = await Assert.That(actual).IsNotEqualTo(unexpected);

// Null checks
_ = await Assert.That(value).IsNotNull();
_ = await Assert.That(value).IsNull();

// Boolean
_ = await Assert.That(condition).IsTrue();
_ = await Assert.That(condition).IsFalse();

// Type checks
_ = await Assert.That(result).IsTypeOf<ExpectedType>();
_ = await Assert.That(result).IsNotTypeOf<UnexpectedType>();

Collection Assertions

// Contains
_ = await Assert.That(collection).Contains(item);
_ = await Assert.That(collection).DoesNotContain(item);

// Empty/Not Empty
_ = await Assert.That(collection).IsEmpty();
_ = await Assert.That(collection).IsNotEmpty();

// Count
_ = await Assert.That(collection).Count().IsEqualTo(3);

String Assertions

// Contains
_ = await Assert.That(text).Contains("substring");
_ = await Assert.That(text).DoesNotContain("missing");

// Starts/Ends With
_ = await Assert.That(text).StartsWith("prefix");
_ = await Assert.That(text).EndsWith("suffix");

// Regex
_ = await Assert.That(text).Matches(@"pattern");

Exception Assertions

// Synchronous
await Assert.ThrowsAsync<ArgumentException>(() => 
    throw new ArgumentException("message"));

// Asynchronous
await Assert.ThrowsAsync<InvalidOperationException>(async () => 
    await SomeAsyncMethod());

Test Categories

1. Unit Tests (Handler Tests)

Test individual command handlers in isolation using mocked dependencies.

Example: BookHandlerTests.cs

[Test]
public async Task UpdateBookHandler_WithMissingBook_ShouldReturnNotFound()
{
    // Arrange
    var command = new UpdateBook(...);
    var session = Substitute.For<IDocumentSession>();
    
    // Stream doesn't exist
    session.Events.FetchStreamStateAsync(command.Id)
        .Returns(Task.FromResult<StreamState?>(null));
    
    // Act
    var result = await BookHandlers.Handle(command, session, context);
    
    // Assert
    _ = await Assert.That(result).IsTypeOf<NotFound>();
}

2. JSON Serialization Tests

Verify that the API follows JSON standards (ISO 8601, camelCase, etc.).

Example: JsonSerializationTests.cs

[Test]
public async Task DateTimeOffset_Should_Serialize_As_ISO8601_With_UTC()
{
    var testObject = new { Timestamp = new DateTimeOffset(2025, 12, 26, 17, 16, 9, 123, TimeSpan.Zero) };
    var json = JsonSerializer.Serialize(testObject, _options);
    
    _ = await Assert.That(json).Contains("\"timestamp\":\"2025-12-26T17:16:09.123+00:00\"");
}

3. Integration Tests

Test the full application stack using Aspire.Hosting.Testing.

Example: WebTests.cs

[Test]
public async Task GetWebResourceRootReturnsOkStatusCode(CancellationToken cancellationToken)
{
    var appHost = await DistributedApplicationTestingBuilder
        .CreateAsync<Projects.BookStore_AppHost>(cancellationToken);
    
    await using var app = await appHost.BuildAsync(cancellationToken);
    await app.StartAsync(cancellationToken);
    
    var httpClient = app.CreateHttpClient("webfrontend");
    var response = await httpClient.GetAsync("/", cancellationToken);
    
    _ = await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
}

Dependency Injection in Tests

TUnit supports dependency injection for test methods:

[Test]
public async Task MyTest(CancellationToken cancellationToken)
{
    // CancellationToken is automatically injected by TUnit
    await SomeAsyncOperation(cancellationToken);
}

Mocking with NSubstitute

The project uses NSubstitute for mocking, which works seamlessly with TUnit:

// Create mock
var session = Substitute.For<IDocumentSession>();

// Setup return values
session.CorrelationId.Returns("test-correlation-id");
session.Events.FetchStreamStateAsync(id).Returns(Task.FromResult<StreamState?>(null));

// Verify calls
session.Events.Received(1).StartStream<BookAggregate>(
    command.Id,
    Arg.Is<BookAdded>(e => e.Title == "Clean Code"));

Code Coverage

TUnit includes built-in code coverage without requiring additional packages:

# Run tests with coverage (built-in)
dotnet test

# Coverage is automatically collected and reported

Coverage reports are generated in standard formats (Cobertura, lcov) and can be viewed in:

  • Visual Studio Code (Coverage Gutters extension)
  • JetBrains Rider (built-in coverage viewer)
  • CI/CD pipelines (GitHub Actions, Azure DevOps)

CI/CD Integration

GitHub Actions

TUnit includes built-in GitHub Actions reporting that automatically generates test summaries in workflow runs.

Automatic Detection

When running in GitHub Actions, TUnit automatically:

  • Detects the GITHUB_ACTIONS environment variable
  • Generates a test summary in the workflow run summary
  • Uses collapsible style by default for clean, navigable results
  • Shows only failed/skipped tests in details (passed tests are counted but not listed)

Workflow Configuration

- name: Cache NuGet packages
  uses: actions/cache@v4
  with:
    path: ~/.nuget/packages
    key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
    restore-keys: |
      ${{ runner.os }}-nuget-

- name: Restore dependencies
  run: dotnet restore

- name: Build
  run: dotnet build --no-restore --configuration Release

- name: Run tests
  run: dotnet test --project src/ApiService/BookStore.ApiService.Tests/BookStore.ApiService.Tests.csproj --configuration Release --no-build ${{ github.event_name == 'pull_request' && '--fail-fast' || '' }}
  # TUnit automatically generates GitHub Actions test summary
  # Results appear in the workflow run summary with collapsible details
  # --fail-fast: Stop on first failure in PRs for quick feedback

Key Features:

  • NuGet Caching - Speeds up builds by caching packages
  • Separated Steps - Restore → Build → Test for clarity and efficiency
  • --no-build - Tests use already-built assemblies (faster)
  • --fail-fast - Stops on first failure in PRs for quick feedback
  • Automatic Reporting - TUnit creates summary without configuration

Reporter Styles

TUnit supports two output styles:

Collapsible (Default) - Clean summary with expandable details:

- name: Run tests
  run: dotnet test
  # Uses collapsible style by default

Full Style - All details shown directly:

- name: Run tests
  run: dotnet test -- --github-reporter-style full

Or via environment variable:

- name: Run tests
  env:
    TUNIT_GITHUB_REPORTER_STYLE: full
  run: dotnet test

Benefits

  • No artifact uploads needed - Results appear directly in workflow summary
  • Automatic detection - Works without configuration
  • Clean output - Collapsible details keep summaries navigable
  • Focused results - Only shows failed/skipped tests in details
  • File size aware - Respects GitHub's 1MB summary limit

See .github/workflows/ci.yml for the complete workflow.

Configuration

global.json

The project includes a global.json file that configures Microsoft.Testing.Platform as the test runner:

{
    "test": {
        "runner": "Microsoft.Testing.Platform"
    }
}

This enables dotnet test to work with TUnit on .NET 10+.

Project Configuration

The test project includes:

<PropertyGroup>
  <IsTestProject>true</IsTestProject>
  <EnableMicrosoftTestingPlatform>true</EnableMicrosoftTestingPlatform>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="TUnit" Version="1.6.28" />
  <PackageReference Include="TUnit.Assertions" Version="1.6.28" />
  <PackageReference Include="NSubstitute" Version="5.3.0" />
  <PackageReference Include="Aspire.Hosting.Testing" Version="13.1.0" />
</ItemGroup>

Best Practices

1. Use Async/Await

All TUnit assertions are async, so tests should be async Task:

[Test]
public async Task MyTest()  // ✓ Correct
{
    _ = await Assert.That(result).IsNotNull();
}

[Test]
public void MyTest()  // ✗ Incorrect
{
    Assert.That(result).IsNotNull();  // Won't compile
}

2. Use Fluent Assertions

TUnit's fluent syntax is more readable:

// ✓ TUnit style
_ = await Assert.That(result).IsEqualTo(expected);

// ✗ Old xUnit style (don't use)
Assert.Equal(expected, result);

3. Inject Dependencies

Use TUnit's dependency injection instead of accessing static contexts:

// ✓ Correct - inject CancellationToken
[Test]
public async Task MyTest(CancellationToken cancellationToken)
{
    await SomeOperation(cancellationToken);
}

// ✗ Incorrect - accessing TestContext
[Test]
public async Task MyTest()
{
    var token = TestContext.Current.CancellationToken;  // Don't do this
}

4. Keep Tests Focused

Each test should verify one specific behavior:

// ✓ Good - tests one thing
[Test]
public async Task CreateBook_WithValidData_ShouldReturnSuccess()

Use [NotInParallel] When Needed

Some tests can't run in parallel (database tests, file system tests). Use [NotInParallel]:

// Tests that modify shared state
[Test, NotInParallel]
public async Task Updates_configuration_file()
{
    await ConfigurationManager.SetAsync("key", "value");
    var result = await ConfigurationManager.GetAsync("key");
    _ = await Assert.That(result).IsEqualTo("value");
}

Control Execution Order with [DependsOn]

When tests need to run in a specific order, use [DependsOn]:

// ✓ Good: Use DependsOn for ordering while maintaining parallelism
[Test]
public async Task Step1_CreateBook()
{
    // Runs first
}

[Test]
[DependsOn(nameof(Step1_CreateBook))]
public async Task Step2_UpdateBook()
{
    // Runs after Step1_CreateBook completes
    // Other unrelated tests can still run in parallel
}

[Test]
[DependsOn(nameof(Step2_UpdateBook))]
public async Task Step3_DeleteBook()
{
    // Runs after Step2_UpdateBook completes
}

Why [DependsOn] is better than [NotInParallel] with Order:

  • More intuitive: explicitly declares dependencies between tests
  • More flexible: tests can depend on multiple other tests
  • Maintains parallelism: unrelated tests still run in parallel
  • Better for complex workflows: clear dependency chains
Important

If tests need ordering, they might be too tightly coupled. Consider:

  • Refactoring into a single test
  • Using proper setup/teardown
  • Making tests truly independent

Test Organization

One Test Class Per Production Class

// BookHandlers.cs → BookHandlerTests.cs
public class BookHandlerTests
{
    [Test]
    public async Task CreateBookHandler_ShouldStartStreamWithBookAddedEvent() { }
    
    [Test]
    public async Task UpdateBookHandler_WithMissingBook_ShouldReturnNotFound() { }
}

Use nested classes or separate files to group related tests:

public class BookHandlerTests
{
    public class CreateBookTests
    {
        [Test]
        public async Task WithValidData_ShouldSucceed() { }
        
        [Test]
        public async Task WithInvalidIsbn_ShouldFail() { }
    }
    
    public class UpdateBookTests
    {
        [Test]
        public async Task WithMissingBook_ShouldReturnNotFound() { }
        
        [Test]
        public async Task WithValidData_ShouldSucceed() { }
    }
}

Common Anti-Patterns to Avoid

✗ Avoid Test Interdependence

Tests should not depend on each other's state or execution order (unless using [DependsOn] intentionally).

✗ Avoid Shared Instance State

// ✗ Bad: Shared state between tests
public class BadTests
{
    private int counter = 0; // Shared state!
    
    [Test]
    public async Task Test1()
    {
        counter++;
        _ = await Assert.That(counter).IsEqualTo(1); // May fail if tests run in parallel
    }
}

// ✓ Good: Each test is independent
public class GoodTests
{
    [Test]
    public async Task Test1()
    {
        var counter = 0; // Local state
        counter++;
        _ = await Assert.That(counter).IsEqualTo(1);
    }
}

✗ Avoid Testing Implementation Details

Test behavior, not implementation:

// ✗ Bad: Testing implementation details
[Test]
public async Task CreateBook_CallsRepositorySaveMethod()
{
    // Don't test that a specific method was called
}

// ✓ Good: Testing behavior
[Test]
public async Task CreateBook_PersistsBookToDatabase()
{
    var book = await bookService.CreateBook(...);
    var retrieved = await bookService.GetBook(book.Id);
    _ = await Assert.That(retrieved).IsNotNull();
}

Performance Best Practices

Following performance best practices ensures your test suite runs efficiently and provides fast feedback.

Optimize Test Setup

Use Static Lazy Initialization for Shared Resources

For expensive objects that can be safely shared across tests, use static lazy initialization:

public class JsonSerializationTests
{
    // ✓ Good: Static lazy initialization - shared across all tests
    private static readonly Lazy<JsonSerializerOptions> _options = new(() => new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
    }, LazyThreadSafetyMode.ExecutionAndPublication);
    
    [Test]
    public async Task MyTest()
    {
        var options = _options.Value; // Only created once
        var json = JsonSerializer.Serialize(data, options);
        // ...
    }
}

// ✗ Bad: Creating expensive object per test
public class IneffcientTests
{
    readonly JsonSerializerOptions _options;
    
    public IneffcientTests()
    {
        _options = new JsonSerializerOptions { ... }; // Created for each test
    }
}

Benefits:

  • Reduces allocations
  • Faster test execution
  • Thread-safe initialization
  • Lazy evaluation (only created when needed)

Test Categories for CI Optimization

Use [Category] attributes to organize tests by speed and enable selective execution:

// Fast unit tests
[Test]
[Category("Unit")]
public async Task CreateBook_WithValidData_ShouldSucceed()
{
    // Fast, isolated test
}

// Slower integration tests
[Test]
[Category("Integration")]
public async Task GetWebResourceRootReturnsOkStatusCode(CancellationToken cancellationToken)
{
    // Slower test requiring full application stack
}

CI Workflow Optimization:

# Run fast unit tests first for quick feedback
- name: Run unit tests (fast feedback)
  run: dotnet test --treenode-filter "/**[Category=Unit]" --fail-fast

# Run integration tests after unit tests pass
- name: Run integration tests
  run: dotnet test --treenode-filter "/**[Category=Integration]"

Benefits:

  • Fast feedback in PRs (unit tests run first)
  • Fail fast on unit test failures
  • Better CI resource utilization
  • Clear test organization

Parallel Execution

Tests Run in Parallel By Default

TUnit runs tests in parallel for better performance. Ensure tests are independent:

// ✓ Good: Independent test with unique data
[Test]
public async Task Can_create_book()
{
    var bookId = Guid.CreateVersion7(); // Unique per test
    var command = new CreateBook(..., bookId, ...);
    // Test is isolated and can run in parallel
}

Use [ParallelLimiter] for Resource Constraints

Limit parallel execution for tests that share limited resources:

public class DatabaseConnectionLimit : IParallelLimit
{
    public int Limit => 5; // Max 5 concurrent database connections
}

[ParallelLimiter<DatabaseConnectionLimit>]
public class DatabaseIntegrationTests
{
    // All tests here respect the connection limit
}

Group tests that share resources to run sequentially within the group:

[ParallelGroup("DatabaseTests")]
public class UserRepositoryTests
{
    // These tests share database resources
}

[ParallelGroup("DatabaseTests")]
public class OrderRepositoryTests
{
    // These also share database resources
}

[ParallelGroup("ApiTests")]
public class ApiIntegrationTests
{
    // These can run in parallel with database tests
}

Memory Management

Dispose Resources Properly

Implement IAsyncDisposable for test classes that create disposable resources:

public class ResourceTests : IAsyncDisposable
{
    private readonly List<IDisposable> _disposables = new();
    
    [Test]
    public async Task TestWithResources()
    {
        var resource = new LargeResource();
        _disposables.Add(resource);
        
        await resource.ProcessAsync();
    }
    
    public async ValueTask DisposeAsync()
    {
        foreach (var disposable in _disposables)
        {
            disposable.Dispose();
        }
        _disposables.Clear();
    }
}

Avoid Memory Leaks in Static Fields

// ✗ Bad: Static collection that grows indefinitely
private static readonly List<TestResult> _allResults = new();

// ✓ Good: Bounded collection or proper cleanup
private static readonly Queue<TestResult> _recentResults = new();
private const int MaxResults = 100;

[After(HookType.Test)]
public void StoreResult()
{
    _recentResults.Enqueue(GetCurrentResult());
    while (_recentResults.Count > MaxResults)
    {
        _recentResults.Dequeue();
    }
}

Optimize Assertions

Avoid Expensive Operations in Assertions

// ✗ Bad: Expensive operation in assertion
_ = await Assert.That(await GetAllUsersFromDatabase())
    .Count()
    .IsEqualTo(1000);

// ✓ Good: Use efficient queries
var userCount = await GetUserCountFromDatabase();
_ = await Assert.That(userCount).IsEqualTo(1000);

CI/CD Performance

Our CI workflow implements several performance optimizations:

  1. NuGet Caching - Speeds up builds by caching packages
  2. Separated Test Execution - Unit tests run first for fast feedback
  3. Fail-Fast Mode - Stops on first failure in PRs
  4. Test Filtering - Runs unit and integration tests separately

See .github/workflows/ci.yml for the complete implementation.

Performance Monitoring

Track test performance over time:

# Run tests with timing information
dotnet test --logger "console;verbosity=detailed"

# Monitor slow tests
# TUnit automatically reports test durations in GitHub Actions summaries

Troubleshooting

Tests Not Discovered

Issue: Tests don't appear in Test Explorer

Solution:

  1. Rebuild the solution (dotnet build)
  2. Restart your IDE
  3. Ensure test methods are marked with [Test] attribute

CancellationToken Errors

Issue: TestContext does not contain a definition for 'CancellationToken'

Solution: Inject CancellationToken as a method parameter instead of accessing via TestContext

dotnet test Fails on .NET 10

Issue: "Testing with VSTest target is no longer supported"

Solution: Ensure global.json exists with the correct configuration (see Configuration section above)

Migration from xUnit

If you're familiar with xUnit, here are the key differences:

xUnit TUnit
[Fact] [Test]
[Theory] [Test]
[InlineData(...)] [Arguments(...)]
Assert.Equal(expected, actual) _ = await Assert.That(actual).IsEqualTo(expected)
Assert.NotNull(value) _ = await Assert.That(value).IsNotNull()
Assert.True(condition) _ = await Assert.That(condition).IsTrue()
Assert.Contains(item, collection) _ = await Assert.That(collection).Contains(item)

See the TUnit migration guide for more details.

Learn More