Testing Guide
This document explains how to run the test suite, what each test project covers, and how to add new tests.
Running the tests
From the solution root:
dotnet test
That’s it. All test projects run in one go. You should see output like:
Passed! - Failed: 0, Passed: 1150, Skipped: 0
To run a single project:
dotnet test InventoryFramework.Domain.Tests
To filter by test name:
dotnet test --filter "FullyQualifiedName~ItemStack"
Test projects at a glance
| Project | What it covers |
|---|---|
Domain.Tests | Business rules: item stacks, containers, aggregates, affixes |
Application.Tests | Use-case services: grant, transfer, craft, lock slot, split stack, drop items, sort, progression |
Infrastructure.Tests | JSON loaders, recipe registry, station/visibility policies |
Persistence.Tests | Snapshot mapper, file repositories, SQL repositories |
Server.Tests | gRPC mapping, auth interceptor, license validation, SignalR |
SDK.Tests | Client options, gRPC mapper, result types |
GodotAdapter.Tests | Godot facade and session controller |
UnityAdapter.Tests | Unity facade and session controller |
UnrealAdapter.Tests | Unreal facade and session controller |
IntegrationTests | Full server stack via in-process WebApplicationFactory |
Domain tests
These test the core business logic with no external dependencies at all, just pure C#.
Location: InventoryFramework.Domain.Tests/
A typical domain test looks like this:
[Fact]
public void AddStack_WhenSlotIsEmpty_PlacesItemInSlot()
{
var container = ContainerBuilder.WithCapacity(10).Build();
var item = ItemStack.Create("wood", 5);
var result = container.AddStack(item);
Assert.True(result.Succeeded);
Assert.Equal(1, container.OccupiedSlotCount);
}
No mocks, no setup. If a domain test needs a dependency, the design is probably off.
Application tests
These test the use-case services (GrantItemsApplicationService, CraftItemsApplicationService, etc.). Instead of hitting a real database they use the in-memory repository from InventoryFramework.Persistence.
Location: InventoryFramework.Application.Tests/
Helper classes in TestData/ build ready-to-use aggregates:
// From TestData/TestInventoryFactory.cs
var aggregate = TestInventoryFactory.CreateWithItems("wood", 10);
A typical application test:
[Fact]
public async Task GrantItems_WhenContainerHasSpace_AddsItemToSlot()
{
var repo = new InMemoryInventoryAggregateRepository();
var aggregate = TestInventoryFactory.CreateEmpty();
await repo.SaveAsync(aggregate, ...);
var service = new GrantItemsApplicationService(repo, itemRegistry, ...);
var result = await service.ExecuteAsync(new GrantItemsCommand
{
AggregateId = aggregate.Id.Value,
ContainerId = aggregate.Containers[0].Id.Value,
ItemDefinitionId = "wood",
Quantity = 5
});
Assert.True(result.Succeeded);
}
Infrastructure tests
These test JSON file loading and policy evaluation.
Location: InventoryFramework.Infrastructure.Tests/
JSON source tests write temp files to disk and clean them up in Dispose:
public class JsonItemDefinitionSourceTests : IDisposable
{
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
[Fact]
public async Task LoadAsync_ValidFile_ReturnsDefinitions() { ... }
public void Dispose() => Directory.Delete(_tempDir, recursive: true);
}
Persistence tests
These cover the snapshot mapper and the file-based repositories.
Location: InventoryFramework.Persistence.Tests/
The SQL repository tests (Persistence.Tests/Sql/) use an in-memory SQLite database, no database server needed:
var options = new DbContextOptionsBuilder<InventoryDbContext>()
.UseSqlite("Data Source=:memory:")
.Options;
Each test gets a fresh database. Tests are independent and can run in any order.
Server tests
These test the gRPC layer in isolation (mapping, auth, licensing) without starting a real HTTP server.
Location: InventoryFramework.Server.Tests/
The auth interceptor tests build a minimal gRPC ServerCallContext by hand:
var context = TestServerCallContext.Create(method: "GrantItems",
requestHeaders: new Metadata { { "x-api-key", "valid-key" } });
Integration tests
These spin up the full server in-process and send real gRPC requests through it.
Location: InventoryFramework.IntegrationTests/
The key class is InventoryWebApplicationFactory:
public class InventoryWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Swap the real repository for an in-memory one
services.AddSingleton<IInventoryAggregateRepository,
InMemoryInventoryAggregateRepository>();
});
}
}
A full integration test:
public class GrpcInventoryClientIntegrationTests : IClassFixture<InventoryWebApplicationFactory>
{
private readonly IInventoryClient _client;
public GrpcInventoryClientIntegrationTests(InventoryWebApplicationFactory factory)
{
_client = factory.CreateGrpcClient();
}
[Fact]
public async Task GrantItems_ReturnsSuccess()
{
var created = await _client.CreateInventoryAsync(...);
var granted = await _client.GrantItemsAsync(created.InventoryId, ...);
Assert.True(granted.Succeeded);
}
}
The server starts fresh for each test class (IClassFixture). Tests within the same class share one server instance.
Writing a new test
Domain or Application test: just add an [Fact] or [Theory] to the relevant test project. No configuration needed.
Integration test: inject InventoryWebApplicationFactory via IClassFixture. Use factory.CreateGrpcClient() to get a connected client.
Naming convention:
MethodName_WhenCondition_ExpectedOutcome
// Examples:
AddStack_WhenContainerIsFull_ReturnsFailed
CraftItems_WhenIngredientsAreMissing_ReturnsInsufficientIngredients
GetInventory_WhenAggregateDoesNotExist_ReturnsNotFound
This makes failing tests immediately readable in the output; you know what broke and under what condition without opening the file.
Code coverage
To generate a coverage report locally:
dotnet test --collect:"XPlat Code Coverage" --results-directory ./TestResults
This creates coverage.cobertura.xml files under TestResults/. To view them as HTML, install the report generator tool:
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" -targetdir:"TestResults/html"
Then open TestResults/html/index.html in a browser.
Coverage is also uploaded automatically on every CI run (see .github/workflows/ci.yml).
A few notes
- Never share state between tests. Each test must set up its own data. If a test depends on the order of execution, it will break in CI.
- In-memory SQLite resets between tests. Each
SqlInventoryAggregateRepositoryTestsinstance gets a clean database. - Integration tests are slower (~5 seconds total) because they start an HTTP server. Keep the heavy setup in
IClassFixtureshared across tests in the same class. - Don’t mock the domain. The domain has no external dependencies; test it directly. Mocking
InventoryAggregatewill mask bugs that real usage would catch.