Spensara Spensara Knowledge Base & Documentation Repository

Repository Docs & Knowledge Base

Consolidated Spensara documentation repository (722 Markdown & architecture documents in ../repository)
Built Word Documents (.docx): Spensara Overview (.docx) Capability Assessment (.docx) Brink POS Ingestion Analysis (.docx) The Borg Ingestion Architecture (.docx)

Agent Tooling (71)

Automation workflows, orchestration references, and developer tooling guides.
agent-tooling/.agents/skills/tunit-testing/SKILL.md 13,628 bytes · Updated Oct 23, 2026 10:44 AM EDT
Raw File

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.

For ASP.NET Core integration testing patterns with TUnit.AspNetCore, see references/aspnetcore-integration.md.

For Aspire distributed application testing patterns with TUnit.Aspire, see references/aspire-integration.md.

For shared infrastructure orchestration, live-test opt-in patterns, and common troubleshooting notes, see 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.

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 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: