Imported from andyandsmoothies-ship-it/Checkly (
.agents/skills/testing-strategy/SKILL.md). Install upstream withnpx skills add andyandsmoothies-ship-it/Checkly --skill testing-strategy. Copyright stays with the author.
Testing (.NET 10)
Core Principles
- Integration tests are the highest-value tests — A single
WebApplicationFactorytest covers routing, binding, validation, business logic, and persistence in one shot. Start here before writing unit tests. - Real databases in tests — Use Testcontainers to spin up real PostgreSQL/SQL Server instances. In-memory providers hide real bugs (transactions, constraints, SQL generation).
- AAA pattern is mandatory — Every test has three clearly separated sections: Arrange, Act, Assert. No mixing.
- Test behavior, not implementation — Tests should survive refactoring. Test what the system does, not how it does it.
Testing is Architecture
- Test Behavior, Not State: Tests must be decoupled from implementation details. If changing the internal code (like extracting a new class) breaks 100 tests, your tests are too delicate. Use Detroit-school (Classical) TDD by avoiding heavy mocking.
- Liskov Substitution in Tests: Changes to the client (the implementation) should be irrelevant to the tests as long as the behavior remains the same.
- Isolate Business Logic from the GUI: Tests, like all other system components, cannot depend on volatile parts of the system. Do not test business logic indirectly through a volatile GUI (e.g., Widget Tests). Test the business layer (Managers/Services) directly.
- Secure Test APIs: You may define APIs expressly for testing (e.g., programmatic test login helpers or DB seeders), but they must remain physically bounded to the test project. Never expose a "test backdoor" in the production API layer.
Patterns
Property-Based Testing (PBT)
Property-Based Testing (PBT) prevents the "Enterprise Developer From Hell" (EDFH) from gaming your tests with hardcoded values or naive implementations. Instead of writing example-based tests (Assert.Equal(4, Add(2, 2))), you define mathematical properties that hold true for all inputs. Tools like FsCheck (for .NET) generate hundreds of randomized, adversarial inputs to try and falsify your assumptions.
When to use PBT (The 7 Core Patterns):
- Different paths, same destination (Commutativity): Changing the order of operations yields the same result (e.g.,
Add(a, b) == Add(b, a)). - There and back again (Invertibility): Applying an operation and its inverse returns the original state (e.g.,
Deserialize(Serialize(x)) == x). Essential for DTO mapping and parsers. - Some things never change (Invariants): A property remains constant after transformation (e.g.,
Mapshouldn't change the size of a collection; a balanced tree stays balanced). - The more things change, the more they stay the same (Idempotence): Doing it twice is the same as doing it once (e.g.,
Distinct(Distinct(x)) == Distinct(x)). Excellent for validating REST APIPUT/DELETEendpoints or sync engines. - Solve a smaller problem first (Structural Induction): Recursive verification where proving it for a smaller sub-component proves the larger whole.
- Hard to prove, easy to verify: The logic to find the answer is complex, but verifying the answer is trivial (e.g., verifying a solved maze path).
- The Test Oracle: Cross-check a highly-optimized, complex algorithm against a slow, brute-force, but obviously correct implementation (e.g.,
OptimizedQuery(x) == BruteForceQuery(x)).
Example in .NET using FsCheck.Xunit:
public class OrderMappingTests
{
// Property-Based Test Pattern: "There and back again"
[Property]
public Property DtoMapping_IsInvertible(Order original)
{
var dto = OrderMapper.ToDto(original);
var mappedBack = OrderMapper.FromDto(dto);
// FsCheck will generate hundreds of random Order objects to break this
return mappedBack.Equals(original).ToProperty();
}
}
xUnit v3 Basics
public class OrderServiceTests
{
[Fact]
public async Task CreateOrder_WithValidItems_ReturnsSuccessResult()
{
// Arrange
var db = CreateInMemoryDb();
var clock = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 0, 0, 0, TimeSpan.Zero));
var service = new OrderService(db, clock);
var request = new CreateOrderRequest("customer-1", [new("product-1", 2)]);
// Act
var result = await service.CreateAsync(request);
// Assert
Assert.True(result.IsSuccess);
Assert.NotEqual(Guid.Empty, result.Value.Id);
Assert.Equal(clock.GetUtcNow(), result.Value.CreatedAt);
}
[Theory]
[InlineData("")]
[InlineData(null)]
public async Task CreateOrder_WithInvalidCustomerId_ReturnsFailure(string? customerId)
{
// Arrange
var service = CreateService();
// Act
var result = await service.CreateAsync(new CreateOrderRequest(customerId!, []));
// Assert
Assert.False(result.IsSuccess);
}
}
Integration Tests with WebApplicationFactory
The highest-value test pattern. Tests the full HTTP pipeline.
// Fixtures/ApiFixture.cs
public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:17")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Replace the real DB with Testcontainers
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString()));
});
}
public async Task InitializeAsync()
{
await _postgres.StartAsync();
// Apply migrations
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
public new async Task DisposeAsync()
{
await _postgres.DisposeAsync();
await base.DisposeAsync();
}
}
// Tests/Orders/CreateOrderTests.cs
public class CreateOrderTests(ApiFixture fixture) : IClassFixture<ApiFixture>
{
private readonly HttpClient _client = fixture.CreateClient();
[Fact]
public async Task CreateOrder_ReturnsCreated_WithValidRequest()
{
// Arrange
var request = new CreateOrderRequest("customer-1", [new("product-1", 2)]);
// Act
var response = await _client.PostAsJsonAsync("/api/orders", request);
// Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var order = await response.Content.ReadFromJsonAsync<OrderResponse>();
Assert.NotNull(order);
Assert.NotEqual(Guid.Empty, order.Id);
Assert.Contains("/api/orders/", response.Headers.Location?.ToString());
}
[Fact]
public async Task CreateOrder_ReturnsValidationProblem_WithEmptyItems()
{
// Arrange
var request = new CreateOrderRequest("customer-1", []);
// Act
var response = await _client.PostAsJsonAsync("/api/orders", request);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
Testcontainers for Real Database Testing
// For SQL Server
private readonly MsSqlContainer _mssql = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
// For PostgreSQL
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:17")
.Build();
// For Redis
private readonly RedisContainer _redis = new RedisBuilder()
.WithImage("redis:7")
.Build();
Verify Snapshot Testing
Use Verify for complex response objects where manual assertions would be fragile.
[Fact]
public async Task GetOrder_MatchesSnapshot()
{
// Arrange
await SeedOrder(fixture);
// Act
var response = await _client.GetAsync("/api/orders/known-id");
var content = await response.Content.ReadAsStringAsync();
// Assert — compares against a stored .verified.txt file
await Verify(content);
}
On first run, Verify creates a .verified.txt file. On subsequent runs, it compares output. If the output changes, the test fails and shows a diff.
Test Data Builders
public class OrderBuilder
{
private string _customerId = "default-customer";
private List<OrderItem> _items = [new("product-1", 1, 9.99m)];
private OrderStatus _status = OrderStatus.Pending;
public OrderBuilder WithCustomer(string customerId)
{
_customerId = customerId;
return this;
}
public OrderBuilder WithItems(params OrderItem[] items)
{
_items = [..items];
return this;
}
public OrderBuilder WithStatus(OrderStatus status)
{
_status = status;
return this;
}
public Order Build() => Order.Create(_customerId, _items, _status);
}
// Usage in tests
var order = new OrderBuilder()
.WithCustomer("vip-customer")
.WithStatus(OrderStatus.Confirmed)
.Build();
Testing Time-Dependent Code
Use TimeProvider (built into .NET 8+) and FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing.
[Fact]
public async Task ExpireOrders_MarksOldPendingOrdersAsExpired()
{
// Arrange
var clock = new FakeTimeProvider(new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero));
var db = CreateDb();
var order = Order.Create("customer-1", items, clock.GetUtcNow());
db.Orders.Add(order);
await db.SaveChangesAsync();
// Advance time past expiry threshold
clock.Advance(TimeSpan.FromDays(31));
var handler = new ExpireOrders.Handler(db, clock);
// Act
await handler.Handle(new ExpireOrders.Command(), CancellationToken.None);
// Assert
var updated = await db.Orders.FindAsync(order.Id);
Assert.Equal(OrderStatus.Expired, updated!.Status);
}
Test Naming Convention
Use the pattern: MethodName_StateUnderTest_ExpectedBehavior
[Fact] public async Task CreateOrder_WithValidItems_ReturnsSuccessResult() { }
[Fact] public async Task CreateOrder_WithEmptyItems_ReturnsValidationError() { }
[Fact] public async Task GetOrder_WithNonExistentId_ReturnsNotFound() { }
[Fact] public async Task CancelOrder_WhenAlreadyShipped_ReturnsConflict() { }
Test File Slicing (Anti-Collision Rule)
Rule: 1 Feature Slice = 1 Dedicated Test File.
Do not append tests to massive, generic files (e.g., UserServiceTests). Instead, create tightly scoped files (e.g., User_PasswordReset_Tests).
Why:
- Prevents Git merge conflicts in team environments.
- Allows AI agents to work natively in parallel without file locking issues.
- Easier to locate failing features at a glance.
Anti-patterns
Don't Use In-Memory Database for Integration Tests
// BAD — hides real SQL behavior, transactions, constraints
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
// GOOD — Testcontainers with real database
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(testContainer.GetConnectionString()));
Don't Test Implementation Details
// BAD — testing that a specific repository method was called
mock.Verify(x => x.AddAsync(It.IsAny<Order>()), Times.Once);
mock.Verify(x => x.SaveChangesAsync(), Times.Once);
// GOOD — test the observable outcome
var order = await db.Orders.FindAsync(orderId);
Assert.NotNull(order);
Assert.Equal(OrderStatus.Created, order.Status);
Don't Share Mutable State Between Tests
// BAD — static shared state
private static readonly AppDbContext SharedDb = CreateDb();
// GOOD — fresh state per test (or use IAsyncLifetime for shared fixtures)
private AppDbContext CreateDb() => new(new DbContextOptionsBuilder<AppDbContext>()...);
Don't Write Assertion-Free Tests
// BAD — no assertion, only checks it doesn't throw
[Fact]
public async Task CreateOrder_Works()
{
await service.CreateAsync(request);
// "it didn't throw, so it works!" — NO
}
// GOOD — assert the expected outcome
[Fact]
public async Task CreateOrder_PersistsOrderToDatabase()
{
var result = await service.CreateAsync(request);
var persisted = await db.Orders.FindAsync(result.Value.Id);
Assert.NotNull(persisted);
Assert.Equal(request.CustomerId, persisted.CustomerId);
}
Decision Guide
| Scenario | Recommendation |
|---|---|
| Testing an API endpoint | WebApplicationFactory integration test |
| Testing business logic in isolation | Unit test with fakes/stubs |
| Database-dependent tests | Testcontainers (real DB) |
| Complex response validation | Verify snapshot testing |
| Time-dependent logic | FakeTimeProvider |
| External API dependency | WireMock.Net or HttpMessageHandler stub |
| Parameterized test cases | [Theory] with [InlineData] or [MemberData] |
| Test data setup | Builder pattern |
| Shared expensive fixture | IClassFixture<T> with IAsyncLifetime |
Hard Verification Gate (MANDATORY)
After writing or modifying any *Tests.cs files, run EXACTLY:
cmd /c python .agents/skills/testing-strategy/verify_aaa_pattern.py server\Tests
- Exit 0 (PASS): All
[Fact]and[Theory]methods have exactly one// Actcomment. - Exit 1 (FAIL): Missing or duplicate
// Actmarkers found. Fix every listed violation. - 2-Strike Rule: Same violation fails twice → STOP. Write
handoff_tests.mdand escalate.
Pass a specific test file path instead of the directory to check only the files you just wrote.