---
name: tunit-testing
description: Guide for creating tests using TUnit framework in backend/tests/. Use when the user asks to create tests, add test coverage, write unit tests, integration tests, or when working with test files in the backend test projects. Covers TUnit syntax, assertions, setup/cleanup hooks, data-driven tests, and project-specific patterns.
---

# TUnit Testing

## Overview

Write tests using the TUnit testing framework for the backend .NET projects. TUnit is a modern, high-performance testing framework built on the Microsoft Testing Platform with source generation for speed.

## Project Structure

Tests are located in `backend/tests/`:
- `Kds.Ai.ApiService.Tests/` - Integration tests for the API service
  - `Endpoints/` - Endpoint-specific tests
  - `Infrastructure/` - Test infrastructure (base classes, factories, test containers)

## Quick Start

```csharp
namespace Kds.Ai.ApiService.Tests.Endpoints;

public class MyFeatureTests : TestsBase
{
    [Test]
    public async Task GetEndpoint_WithNoData_ReturnsEmptyList()
    {
        // Arrange
        var client = Factory.CreateClient();
        
        // Act
        var response = await client.GetAsync("/my-endpoint");
        
        // Assert
        await Assert.That(response).IsOk();
        await Assert.That(response).HasJsonContent();
    }
}
```

**Key points:**
- Inherit from `TestsBase` for API integration tests
- Use `[Test]` attribute (no `[TestClass]` needed)
- Methods must be `async Task`
- Always `await` assertions
- Naming: `Method_Scenario_ExpectedOutcome`

## Test Infrastructure

The project provides infrastructure in `Infrastructure/`:

**TestsBase** - Base class inheriting from `WebApplicationTest<AppFactory, Program>`
- Provides `Factory` property for accessing the test app

**AppFactory** - Configures test application:
- Sets up PostgreSQL testcontainer
- Runs EF migrations on initialization
- Overrides connection strings for test environment

**PostgresDatabase** - Shared PostgreSQL container (`SharedType.PerTestSession`)

## Using Factory

The `Factory` property provides access to the test application:

```csharp
// Create HTTP client
var client = Factory.CreateClient();

// Access DI container (Factory.Services is per-test scoped)
await using var scope = Factory.Services.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<IMyService>();

// Access WebApplicationFactory directly
var webApp = Factory; // WebApplicationFactory<Program>
```

## Core Assertions

All assertions use `Assert.That()` and must be awaited:

```csharp
// Equality & comparisons
await Assert.That(actual).IsEqualTo(expected);
await Assert.That(score).IsGreaterThan(70);
await Assert.That(temp).IsBetween(20, 30);

// Null & booleans
await Assert.That(result).IsNotNull();
await Assert.That(flag).IsTrue();

// Strings
await Assert.That(message).Contains("error");
await Assert.That(email).Matches(@"^[\w\.-]+@[\w\.-]+\.\w+$");

// Collections
await Assert.That(items).IsEmpty();
await Assert.That(numbers).Contains(42);
await Assert.That(list).Count().IsEqualTo(5);

// Chaining
await Assert.That(username)
    .IsNotNull()
    .And.IsNotEmpty()
    .And.Length().IsGreaterThan(3);
```

## HTTP Response Assertions

TUnit provides built-in HTTP assertions:

```csharp
var response = await client.GetAsync("/endpoint");

// Status codes
await Assert.That(response).IsOk();                  // 200
await Assert.That(response).IsCreated();            // 201
await Assert.That(response).IsNoContent();          // 204
await Assert.That(response).IsBadRequest();         // 400
await Assert.That(response).IsUnauthorized();       // 401
await Assert.That(response).IsForbidden();          // 403
await Assert.That(response).IsNotFound();           // 404
await Assert.That(response).IsConflict();           // 409
await Assert.That(response).HasStatusCode(HttpStatusCode.Created);

// Range checks
await Assert.That(response).IsSuccessStatusCode();
await Assert.That(response).IsRedirectStatusCode();
await Assert.That(response).IsClientErrorStatusCode();
await Assert.That(response).IsServerErrorStatusCode();

// Content & headers
await Assert.That(response).HasJsonContent();
await Assert.That(response).HasContentType("application/json");
await Assert.That(response).HasHeader("X-Request-Id");

// Read and assert on content
var data = await response.Content.ReadFromJsonAsync<MyResponse>();
await Assert.That(data).IsNotNull();
```

## Test Attributes

```csharp
[Test]  // Marks method as test
public async Task MyTest() { }

[NotInParallel]  // Prevents parallel execution
[Test]
public async Task SequentialTest() { }

[NotInParallel(Order = 1)]  // Controls execution order
[Test]
public async Task FirstTest() { }
```

## Lifecycle Hooks

For detailed hook patterns, see [references/lifecycle-hooks.md](references/lifecycle-hooks.md).

For ASP.NET Core integration testing patterns with `TUnit.AspNetCore`, see [references/aspnetcore-integration.md](references/aspnetcore-integration.md).

For Aspire distributed application testing patterns with `TUnit.Aspire`, see [references/aspire-integration.md](references/aspire-integration.md).

For shared infrastructure orchestration, live-test opt-in patterns, and common troubleshooting notes, see [references/infrastructure-and-troubleshooting.md](references/infrastructure-and-troubleshooting.md).

Basic example:

```csharp
public class MyTests : TestsBase
{
    private MyService _service = null!;

    [Before(Test)]
    public async Task Setup()
    {
        _service = new MyService();
        await _service.InitializeAsync();
    }

    [After(Test)]
    public async Task Cleanup()
    {
        await _service.DisposeAsync();
    }
}
```

## Data-Driven Tests

For comprehensive patterns, see [references/data-driven.md](references/data-driven.md).

Basic example:

```csharp
[Test]
[Arguments(2, 3, 5)]
[Arguments(10, 20, 30)]
public async Task Add_ReturnsSum(int a, int b, int expected)
{
    var result = Calculator.Add(a, b);
    await Assert.That(result).IsEqualTo(expected);
}
```

## Common Patterns

### API Endpoint Test

```csharp
public class AlertEndpointsTests : TestsBase
{
    [Test]
    public async Task GetAlerts_WithNoData_ReturnsEmptyPage()
    {
        // Arrange
        var client = Factory.CreateClient();

        // Act
        var response = await client.GetAsync("/alerts");

        // Assert
        await Assert.That(response).IsOk();
        await Assert.That(response).HasJsonContent();
        
        var alerts = await response.Content.ReadFromJsonAsync<AlertPageResponse>();
        await Assert.That(alerts).IsNotNull();
        await Assert.That(alerts.Items).IsEmpty();
    }
}
```

### Database Test

```csharp
[Test]
public async Task DatabaseTest()
{
    await using var scope = Factory.Services.CreateAsyncScope();
    var dbContext = scope.ServiceProvider.GetRequiredService<KdsAiDbContext>();
    
    var entity = new MyEntity { Name = "Test" };
    dbContext.MyEntities.Add(entity);
    await dbContext.SaveChangesAsync();
    
    var saved = await dbContext.MyEntities.FirstOrDefaultAsync(e => e.Name == "Test");
    await Assert.That(saved).IsNotNull();
}
```

### Naming Conventions

- Test class: `{Feature}Tests` (e.g., `AlertEndpointsTests`)
- Test method: `{Method}_{Scenario}_{ExpectedOutcome}`
  - `GetAlerts_WithNoData_ReturnsEmptyPage`
  - `CreateAlert_WithValidData_ReturnsCreatedAlert`
  - `DeleteAlert_WhenNotFound_Returns404`

### Test Organization

```
backend/tests/Kds.Ai.ApiService.Tests/
├── Endpoints/
│   ├── AlertEndpointsTests.cs
│   └── ...
└── Infrastructure/
    ├── TestsBase.cs
    ├── AppFactory.cs
    └── PostgresDatabase.cs
```

## Important Concepts

### Test Instance Isolation

Each test gets a new instance of the test class. Instance fields are NOT shared:

```csharp
public class MyTests
{
    private int _value;  // Different for each test

    [Test]
    public void Test1() { _value = 99; }

    [Test]
    public async Task Test2()
    {
        await Assert.That(_value).IsEqualTo(0);  // Still 0, not 99
    }
}
```

Use `static` to share state (but be cautious about isolation):
```csharp
private static int _sharedValue;  // Shared across tests
```

#### Isolating Shared Resources

When tests share resources like database tables, message queues, or cache keys, use the built-in isolation helpers:

```csharp
protected override async Task SetupAsync()
{
    // GetIsolatedName(baseName) - returns "Test_{UniqueId}_{baseName}"
    var tableName = GetIsolatedName("todos");      // "Test_42_todos"
    var queueName = GetIsolatedName("events");     // "Test_42_events"
    
    // GetIsolatedPrefix(separator) - returns "test{separator}{UniqueId}{separator}"
    var cachePrefix = GetIsolatedPrefix();         // "test_42_"
    var dotPrefix = GetIsolatedPrefix(".");        // "test.42."
    
    await CreateTableAsync(tableName);
    await CreateQueueAsync(queueName);
}
```

**Why this matters**: TUnit runs tests in parallel by default. Without proper isolation, tests will interfere with each other and produce flaky failures.

Common resources that need isolation:
- **Database tables**: Use `GetIsolatedName("tablename")`
- **Message queues/topics**: Use `GetIsolatedName("queue")`
- **Cache keys**: Use `GetIsolatedPrefix()` as a key prefix
- **Blob storage paths**: Use `GetIsolatedPrefix()` as a path prefix
- **Redis keys**: Use `GetIsolatedPrefix()` as a key prefix

See the [TUnit ASP.NET Core docs](https://tunit.dev/docs/examples/aspnet#getisolatedname) for more details.

### Parallelization

Tests run in parallel by default. Use `[NotInParallel]` to prevent race conditions.

## Running Tests

```bash
# Run all tests
dotnet test

# Run specific project
dotnet test --project backend/tests/Kds.Ai.ApiService.Tests/Kds.Ai.ApiService.Tests/Kds.Ai.ApiService.Tests.csproj

# Filter by test class
dotnet test --project backend/tests/Kds.Ai.ApiService.Tests/Kds.Ai.ApiService.Tests/Kds.Ai.ApiService.Tests.csproj -- --treenode-filter "/*/*/TenantMonitorEndpointsTests/*"

# Filter by test name
dotnet test --project backend/tests/Kds.Ai.ApiService.Tests/Kds.Ai.ApiService.Tests/Kds.Ai.ApiService.Tests.csproj -- --treenode-filter "/*/*/*/GetEndpoint_WithNoData_ReturnsEmptyList"

# Verbose output
dotnet test --verbosity detailed
```

## Test Filters

TUnit runs on Microsoft.Testing.Platform, not VSTest.

- Do not use `dotnet test --filter ...` for TUnit tests.
- Use `--treenode-filter` instead.
- In this repo, prefer passing the TUnit filter after `--` when using `dotnet test`:
  - `dotnet test --project <csproj> -- --treenode-filter "..."`

Filter path syntax:

```text
/<Assembly>/<Namespace>/<Class name>/<Test name>
```

Useful examples:

- Class filter: `/*/*/TenantMonitorEndpointsTests/*`
- Test filter: `/*/*/*/SetAsync_WhenEnableRequestedAndAssignmentMissing_CreatesEnabledAssignment`
- Namespace filter: `/*/Kds.Ai.ApiService.Tests.Endpoints/*/*`

Rules:

- Use `*` as wildcard.
- `--filter` is silently rejected by MTP and often ends with `Zero tests ran`.
- On SDKs that accept it, `dotnet test --treenode-filter "..."` also works, but `-- --treenode-filter` is safer across environments.

## Best Practices

1. **Always await assertions** - Required for assertions to execute
2. **Use descriptive names** - Follow `Method_Scenario_Outcome` pattern
3. **One logical assertion per test** - Focus on single behavior
4. **Inherit from TestsBase** - For API integration tests
5. **Use Factory.CreateClient()** - For HTTP testing
6. **Clean up resources** - Use `[After]` hooks
7. **Avoid test interdependence** - Each test should be independent
8. **Use `[NotInParallel]` sparingly** - Only when absolutely necessary
9. **Prefer per-test isolation helpers** - Use `SetupAsync()`, `GetIsolatedName(...)`, and `GetIsolatedPrefix(...)` for schemas, tables, queues, and keys when tests run in parallel
10. **Gate live tests with config, not manual-only attributes** - Default runs should skip live coverage with `Skip.Test(...)` unless an explicit opt-in such as `KDSAI_RUN_LIVE_TESTS=1` is enabled
11. **Use startup-time configuration for startup-time settings** - If the SUT reads configuration during app startup, prefer `ConfigureStartupConfiguration(...)`, AppHost args, or host settings instead of later configuration hooks
12. **Prefer TUnit-managed fixture chains for shared infrastructure** - Use `ClassDataSource<T>` and property injection instead of manually creating and disposing dependent shared fixtures
13. **Use collection-equivalence assertions for buffers** - For `byte[]` and similar payload buffers, prefer `IsEquivalentTo(...)` over `IsEqualTo(...)`

## Additional Resources

For advanced topics, see:
- [references/lifecycle-hooks.md](references/lifecycle-hooks.md) - Setup/cleanup patterns
- [references/data-driven.md](references/data-driven.md) - Data-driven testing
- [references/assertions.md](references/assertions.md) - Complete assertion reference
- [references/aspnetcore-integration.md](references/aspnetcore-integration.md) - `WebApplicationTest`, `TestWebApplicationFactory`, and per-test configuration
- [references/aspire-integration.md](references/aspire-integration.md) - `AspireFixture`, shared AppHost lifecycle, resource waiting, and distributed app diagnostics
- [references/infrastructure-and-troubleshooting.md](references/infrastructure-and-troubleshooting.md) - Shared dependency orchestration, live-test gating, and package/version pitfalls
- [TUnit Documentation](https://tunit.dev/docs/intro) - Official docs
