
Dotnet Testing
- 141 installs
- 228 repo stars
- Updated August 3, 2026
- novotnyllc/dotnet-artisan
Author xUnit or NUnit tests, integration tests with WebApplicationFactory, and mocking strategies for .NET services, controllers, and data access layers.
About
dotnet-testing from dotnet-artisan documents how to test .NET solutions effectively: unit tests for domain and service logic, HTTP integration tests with in-memory hosts, mocking external dependencies, and CI-ready test organization for reliable backend regression coverage.
- xUnit/NUnit test structure
- WebApplicationFactory integration tests
- Mocking with Moq or substitutes
- Test containers and DB fakes
- Assertion patterns for APIs
Dotnet Testing by the numbers
- 141 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #901 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/novotnyllc/dotnet-artisan --skill dotnet-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 141 |
|---|---|
| repo stars | ★ 228 |
| Last updated | August 3, 2026 |
| Repository | novotnyllc/dotnet-artisan ↗ |
What it does
Author xUnit or NUnit tests, integration tests with WebApplicationFactory, and mocking strategies for .NET services, controllers, and data access layers.
Files
dotnet-testing
Overview
Testing strategy, frameworks, and quality tooling for .NET applications. This consolidated skill spans 13 topic areas. Load the appropriate companion file from references/ based on the routing table below.
Baseline dependency: references/testing-strategy.md defines the unit vs integration vs E2E decision tree and test doubles selection that inform all testing decisions. Load it by default whenever a testing approach needs to be chosen.
Most-shared companion: references/xunit.md covers xUnit v3 framework features used by integration, snapshot, and UI testing companions.
Routing Table
| Topic | Keywords | Description | Companion File |
|---|---|---|---|
| Strategy | unit vs integration vs E2E, test doubles | Unit vs integration vs E2E decision tree, test doubles selection | references/testing-strategy.md |
| xUnit | Facts, Theories, fixtures, parallelism | xUnit v3 Facts, Theories, fixtures, parallelism, IAsyncLifetime | references/xunit.md |
| Integration | WebApplicationFactory, Testcontainers, Aspire | WebApplicationFactory, Testcontainers, Aspire, database fixtures | references/integration-testing.md |
| Snapshot | Verify, scrubbing, API responses | Verify library, scrubbing, custom converters, HTTP response snapshots | references/snapshot-testing.md |
| Playwright | E2E browser, CI caching, trace viewer | Playwright E2E browser automation, CI caching, trace viewer, codegen | references/playwright.md |
| BenchmarkDotNet | microbenchmarks, memory diagnosers | BenchmarkDotNet microbenchmarks, memory diagnosers, baselines | references/benchmarkdotnet.md |
| CI benchmarking | threshold alerts, baseline tracking | CI benchmark regression detection, threshold alerts, baseline tracking | references/ci-benchmarking.md |
| Test quality | Coverlet, Stryker.NET, flaky tests | Coverlet code coverage, Stryker.NET mutation testing, flaky tests | references/test-quality.md |
| Add testing | scaffold xUnit project, coverlet, layout | Scaffold xUnit project, coverlet setup, directory layout | references/add-testing.md |
| Slopwatch | LLM reward hacking detection | Slopwatch CLI for LLM reward hacking detection | references/slopwatch.md |
| AOT WASM | Blazor/Uno WASM AOT, size, lazy loading | Blazor/Uno WASM AOT compilation, size vs speed, lazy loading, Brotli | references/aot-wasm.md |
| UI testing core | page objects, selectors, async waits | Page object model, test selectors, async waits, accessibility testing | references/ui-testing-core.md |
| Aspire testing | DistributedApplicationTestingBuilder, Aspire test host | Aspire test host, service HTTP clients, resource health | references/aspire-testing.md |
Scope
- Test strategy and architecture (unit, integration, E2E)
- xUnit v3 test authoring
- Integration testing (WebApplicationFactory, Testcontainers)
- E2E browser testing (Playwright)
- Snapshot testing (Verify)
- Benchmarking (BenchmarkDotNet, CI gating)
- Quality (coverage, mutation testing)
- Cross-framework UI testing patterns
- Test scaffolding
Out of scope
- UI framework-specific testing (bUnit, Appium) -> [skill:dotnet-ui]
- CI/CD pipeline configuration -> [skill:dotnet-devops]
- Performance profiling -> [skill:dotnet-tooling]
interface:
display_name: "dotnet-testing"
short_description: "xUnit, integration, E2E, and benchmark strategy"
default_prompt: "Use $dotnet-advisor to route this testing request, then load $dotnet-testing for strategy and quality gates."
policy:
allow_implicit_invocation: true
Add Testing
Add test infrastructure scaffolding to an existing .NET project. Creates test projects with xUnit, configures code coverage with coverlet, and sets up the conventional directory structure.
Test Project Structure
Follow the convention of mirroring src/ project names under tests/:
MyApp/
├── src/
│ ├── MyApp.Core/
│ ├── MyApp.Api/
│ └── MyApp.Infrastructure/
└── tests/
├── MyApp.Core.UnitTests/
├── MyApp.Api.UnitTests/
├── MyApp.Api.IntegrationTests/
└── Directory.Build.props # Test-specific build settingsNaming conventions:
*.UnitTests-- isolated tests with no external dependencies*.IntegrationTests-- tests that use real infrastructure (database, HTTP, file system)*.FunctionalTests-- end-to-end tests through the full application stack
---
Step 1: Create the Test Project
# Create xUnit test project
dotnet new xunit -n MyApp.Core.UnitTests -o tests/MyApp.Core.UnitTests
# Add to solution
dotnet sln add tests/MyApp.Core.UnitTests/MyApp.Core.UnitTests.csproj
# Add reference to the project under test
dotnet add tests/MyApp.Core.UnitTests/MyApp.Core.UnitTests.csproj \
reference src/MyApp.Core/MyApp.Core.csprojClean Up Generated Project
Remove properties already defined in Directory.Build.props:
<!-- tests/MyApp.Core.UnitTests/MyApp.Core.UnitTests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MyApp.Core\MyApp.Core.csproj" />
</ItemGroup>
</Project>With CPM, Version attributes are managed in Directory.Packages.props. Remove them from the generated .csproj.
---
Step 2: Add Test-Specific Build Properties
Create tests/Directory.Build.props to customize settings for all test projects:
<!-- tests/Directory.Build.props -->
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Use Microsoft.Testing.Platform v2 runner (requires Microsoft.NET.Test.Sdk 17.13+/18.x) -->
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
<!-- Relax strictness for test projects -->
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
</Project>This imports the root Directory.Build.props (for shared settings like Nullable, ImplicitUsings, LangVersion) and overrides test-specific properties.
---
Step 3: Register Test Packages in CPM
Add test package versions to Directory.Packages.props:
<!-- In Directory.Packages.props -->
<ItemGroup>
<!-- Test packages -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="coverlet.collector" Version="8.0.0" />
</ItemGroup>Optional: Mocking Library
Add a mocking library if the project needs test doubles:
<PackageVersion Include="NSubstitute" Version="5.3.0" />---
Step 4: Configure Code Coverage
Coverlet (Collector Mode)
The coverlet.collector package integrates with dotnet test via the data collector. No additional configuration is needed for basic coverage.
Generate coverage reports:
# Collect coverage (Cobertura format by default)
dotnet test --collect:"XPlat Code Coverage"
# Results appear in TestResults/*/coverage.cobertura.xmlCoverage Thresholds
For CI enforcement, use coverlet.msbuild for threshold checks:
<!-- In test csproj or tests/Directory.Build.props -->
<PackageReference Include="coverlet.msbuild" /># Enforce minimum coverage threshold
dotnet test /p:CollectCoverage=true \
/p:CoverageOutputFormat=cobertura \
/p:Threshold=80 \
/p:ThresholdType=lineCoverage Report Generation
Use reportgenerator for human-readable HTML reports:
# Install globally
dotnet tool install -g dotnet-reportgenerator-globaltool
# Generate HTML report
reportgenerator \
-reports:"tests/**/coverage.cobertura.xml" \
-targetdir:coverage-report \
-reporttypes:Html---
Step 5: Add EditorConfig Overrides for Tests
In the root .editorconfig, add test-specific relaxations:
[tests/**.cs]
# Allow underscores in test method names (Given_When_Then or Should_Behavior)
dotnet_diagnostic.CA1707.severity = none
# Test parameters are validated by the framework
dotnet_diagnostic.CA1062.severity = none
# ConfigureAwait not relevant in test context
dotnet_diagnostic.CA2007.severity = none
# Tests often have intentionally unused variables for assertions
dotnet_diagnostic.IDE0059.severity = suggestion---
Step 6: Write a Starter Test
Replace the template-generated UnitTest1.cs with a properly structured test:
namespace MyApp.Core.UnitTests;
public class SampleServiceTests
{
[Fact]
public void Method_Condition_ExpectedResult()
{
// Arrange
var sut = new SampleService();
// Act
var result = sut.DoWork();
// Assert
Assert.NotNull(result);
}
[Theory]
[InlineData(1, 2, 3)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
public void Add_TwoNumbers_ReturnsSum(int a, int b, int expected)
{
var result = Calculator.Add(a, b);
Assert.Equal(expected, result);
}
}Test Naming Convention
Use the pattern Method_Condition_ExpectedResult:
CreateUser_WithValidInput_ReturnsUserGetById_WhenNotFound_ReturnsNullDelete_WithoutPermission_ThrowsUnauthorized
---
Verify
After adding test infrastructure, verify everything works:
# Restore (regenerate lock files if using CPM)
dotnet restore
# Build (verifies project references and analyzer config)
dotnet build --no-restore
# Run tests
dotnet test --no-build
# Run with coverage
dotnet test --collect:"XPlat Code Coverage"---
Adding Integration Test Projects
For integration tests that need WebApplicationFactory or database access:
dotnet new xunit -n MyApp.Api.IntegrationTests -o tests/MyApp.Api.IntegrationTests
dotnet sln add tests/MyApp.Api.IntegrationTests/MyApp.Api.IntegrationTests.csproj
dotnet add tests/MyApp.Api.IntegrationTests/MyApp.Api.IntegrationTests.csproj \
reference src/MyApp.Api/MyApp.Api.csprojAdd integration test packages to CPM (match the Microsoft.AspNetCore.Mvc.Testing major version to the target framework -- e.g., 8.x for net8.0, 9.x for net9.0, 10.x for net10.0):
<!-- Version must match the project's target framework major version -->
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageVersion Include="Testcontainers" Version="4.3.0" />Integration test depth (WebApplicationFactory patterns, test containers, database fixtures) -- see [skill:dotnet-testing] references/integration-testing.md.
---
What's Next
This skill covers test project scaffolding. For deeper testing guidance:
- xUnit v3 features and patterns -- [skill:dotnet-testing]
references/xunit.md - Integration testing with WebApplicationFactory -- [skill:dotnet-testing]
references/integration-testing.md - UI testing (Blazor, MAUI, Uno) -- [skill:dotnet-ui]
references/blazor-testing.md,references/maui-testing.md,references/uno-testing.md - Snapshot testing -- [skill:dotnet-testing]
references/snapshot-testing.md - Test quality and coverage enforcement -- [skill:dotnet-testing]
references/test-quality.md - CI test reporting -- [skill:dotnet-devops]
references/add-ci.mdfor starter,references/gha-build-test.mdandreferences/ado-build-test.mdfor advanced
---
References
AOT and WASM Testing
WebAssembly AOT compilation for Blazor WASM and Uno WASM applications: compilation pipeline, download size vs runtime speed tradeoffs, trimming interplay, lazy loading assemblies, and Brotli pre-compression for download optimization.
Version assumptions: .NET 8.0+ baseline. Blazor WASM AOT shipped in .NET 6 and has been refined through .NET 8-10. Uno WASM uses a similar compilation pipeline with Uno-specific tooling.
Important tradeoff: Trimming and AOT have opposite effects on WASM artifact size. Trimming reduces download size by removing unused code. AOT increases artifact size (native WASM code is larger than IL) but improves runtime execution speed. Use both together for the best balance.
Download Size vs Runtime Speed
Understanding the size/speed tradeoff is critical for WASM AOT decisions:
| Compilation Mode | Download Size | Runtime Speed | Startup Time |
|---|---|---|---|
| IL interpreter (no AOT) | Smallest | Slowest | Fastest startup |
| AOT (all assemblies) | Largest | Fastest | Slower startup |
| AOT (selective) + trimming | Balanced | Good | Moderate |
| Trimmed only (no AOT) | Small | Moderate (JIT interpretation) | Fast |
Key insight: Trimming reduces size by removing unused IL. AOT increases total artifact size because compiled native WASM code is larger than the equivalent IL bytecode. However, AOT-compiled code executes significantly faster because it skips IL interpretation at runtime.
When to Use WASM AOT
- CPU-intensive workloads: Image processing, complex calculations, data transformation
- Predictable performance: Consistent execution speed without JIT pauses
- Hot paths: AOT-compile only performance-critical assemblies (selective AOT)
When to Skip WASM AOT
- Bandwidth-constrained users: AOT increases download size significantly
- Simple CRUD apps: IL interpretation is fast enough for UI interactions and API calls
- Rapid iteration: AOT compilation adds significant publish time
---
Blazor WASM AOT
Enabling AOT
<!-- Blazor WASM .csproj -->
<PropertyGroup>
<RunAOTCompilation>true</RunAOTCompilation>
</PropertyGroup># Publish with AOT (required -- AOT only applies during publish)
dotnet publish -c ReleaseNote: RunAOTCompilation is the Blazor WASM property (not PublishAot which is for server-side Native AOT). AOT compilation only happens during dotnet publish, not during dotnet run or dotnet build.
Selective AOT via Lazy Loading
Blazor WASM AOT compiles all non-lazy-loaded assemblies. To control which assemblies are AOT-compiled, mark non-critical assemblies as lazy-loaded -- they will use IL interpretation instead:
<PropertyGroup>
<RunAOTCompilation>true</RunAOTCompilation>
</PropertyGroup>
<ItemGroup>
<!-- These assemblies are NOT AOT-compiled (loaded on demand via IL interpreter) -->
<BlazorWebAssemblyLazyLoad Include="MyApp.Reporting.wasm" />
<BlazorWebAssemblyLazyLoad Include="MyApp.Admin.wasm" />
<!-- All other assemblies (MyApp.Core, MyApp.Calculations, etc.) ARE AOT-compiled -->
</ItemGroup>Trimming + AOT Together
For the best balance, use both trimming and AOT:
<PropertyGroup>
<!-- Trimming reduces unused code (smaller download) -->
<PublishTrimmed>true</PublishTrimmed>
<!-- AOT compiles remaining code to native WASM (faster execution) -->
<RunAOTCompilation>true</RunAOTCompilation>
<!-- Detailed warnings during development -->
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
</PropertyGroup>The publish pipeline runs: trim unused IL first, then AOT-compile the remaining assemblies to native WASM. This produces an artifact that is larger than trimmed-only but smaller than AOT-without-trimming, with the best runtime performance.
---
Uno WASM AOT
Uno Platform 5+ with .NET 8+ uses the standard .NET WASM workload, so the AOT configuration is the same as Blazor WASM.
Enabling AOT (Uno 5+ / .NET 8+)
<!-- Uno WASM head .csproj -->
<PropertyGroup Condition="'$(TargetFramework)' == 'net8.0-browserwasm'">
<RunAOTCompilation>true</RunAOTCompilation>
</PropertyGroup>Older Uno versions using Uno.Wasm.Bootstrap had a separate WasmShellMonoRuntimeExecutionMode property with Interpreter, InterpreterAndAOT, and FullAOT modes. On .NET 8+, use RunAOTCompilation instead.
Trimming in Uno WASM
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>link</TrimMode>
</PropertyGroup>See [skill:dotnet-ui] references/uno-platform.md for Uno Platform architecture patterns.
---
Lazy Loading Assemblies
Lazy loading defers downloading assemblies until they are needed, reducing initial download size. This is especially effective when combined with AOT (which increases per-assembly size).
Blazor WASM Lazy Loading
<!-- Mark assemblies for lazy loading in .csproj -->
<ItemGroup>
<BlazorWebAssemblyLazyLoad Include="MyApp.Reporting.wasm" />
<BlazorWebAssemblyLazyLoad Include="MyApp.Admin.wasm" />
<BlazorWebAssemblyLazyLoad Include="ChartLibrary.wasm" />
</ItemGroup>// Load assemblies on demand in a component or router
@inject LazyAssemblyLoader LazyLoader
@code {
private List<Assembly> _lazyLoadedAssemblies = new();
private async Task LoadReportingModule()
{
var assemblies = await LazyLoader.LoadAssembliesAsync(new[]
{
"MyApp.Reporting.wasm"
});
_lazyLoadedAssemblies.AddRange(assemblies);
}
}Router-Based Lazy Loading
<!-- App.razor -->
@inject LazyAssemblyLoader LazyLoader
<Router AppAssembly="typeof(App).Assembly"
AdditionalAssemblies="@_lazyLoadedAssemblies"
OnNavigateAsync="@OnNavigateAsync">
<Navigating>
<div class="loading">Loading module...</div>
</Navigating>
</Router>
@code {
private List<Assembly> _lazyLoadedAssemblies = new();
private async Task OnNavigateAsync(NavigationContext context)
{
if (context.Path.StartsWith("admin"))
{
var assemblies = await LazyLoader.LoadAssembliesAsync(new[]
{
"MyApp.Admin.wasm"
});
_lazyLoadedAssemblies.AddRange(assemblies);
}
else if (context.Path.StartsWith("reports"))
{
var assemblies = await LazyLoader.LoadAssembliesAsync(new[]
{
"MyApp.Reporting.wasm"
});
_lazyLoadedAssemblies.AddRange(assemblies);
}
}
}Lazy Loading Strategy
| Strategy | Initial Load | Feature Load | Best For |
|---|---|---|---|
| No lazy loading | All at once | Instant | Small apps (<5 MB total) |
| Route-based lazy loading | Core only | On navigation | Multi-module apps |
| Feature-based lazy loading | Core only | On demand | Apps with optional features |
---
Brotli Pre-Compression
Brotli pre-compression reduces WASM download size by 60-80%. Blazor WASM automatically generates Brotli-compressed files during publish.
How It Works
During dotnet publish, Blazor WASM generates .br (Brotli) and .gz (gzip) compressed versions of all static files in _framework/. The web server serves the pre-compressed file when the browser supports it.
# After publish, check compressed sizes
ls -la bin/Release/net8.0/publish/wwwroot/_framework/
# You'll see:
# MyApp.wasm (original)
# MyApp.wasm.br (Brotli compressed, ~60-80% smaller)
# MyApp.wasm.gz (gzip compressed, ~50-70% smaller)Server Configuration
The web server must be configured to serve pre-compressed files. Most Blazor hosting setups handle this automatically.
ASP.NET Core hosting:
// In the server project hosting Blazor WASM
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
// Blazor framework files are served with compression headers automaticallyNginx:
location /_framework/ {
# Serve Brotli-compressed files when available
gzip_static on;
brotli_static on;
# Set correct MIME types
types {
application/wasm wasm;
}
# Cache aggressively (files are content-hashed)
add_header Cache-Control "public, max-age=31536000, immutable";
}Azure Static Web Apps / GitHub Pages:
Pre-compressed .br files are served automatically when the Accept-Encoding: br header is present.
Compression Impact
| Content | Original | Brotli (.br) | Reduction |
|---|---|---|---|
| .NET WASM runtime | ~2.5 MB | ~0.8 MB | ~68% |
| App assemblies (IL) | varies | ~70% smaller | ~70% |
| App assemblies (AOT) | varies | ~65% smaller | ~65% |
| JavaScript glue code | ~100 KB | ~25 KB | ~75% |
Disabling Compression (Rarely Needed)
<!-- Disable Brotli pre-compression -->
<PropertyGroup>
<BlazorEnableCompression>false</BlazorEnableCompression>
</PropertyGroup>---
WASM Size Optimization Checklist
1. Enable trimming -- removes unused IL before AOT compilation 2. Use lazy loading -- defer non-critical assemblies 3. Enable Brotli pre-compression -- 60-80% reduction in transfer size (on by default) 4. Use selective AOT -- only AOT-compile performance-critical assemblies 5. Enable invariant globalization if culture-specific formatting is not needed:
<PropertyGroup>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>6. Remove unused framework features:
<PropertyGroup>
<!-- Disable features you don't use -->
<EventSourceSupport>false</EventSourceSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
</PropertyGroup>7. Verify compression is served -- check browser DevTools Network tab for content-encoding: br
---
Agent Gotchas
1. Do not confuse `RunAOTCompilation` with `PublishAot`. Blazor WASM uses RunAOTCompilation for WASM AOT. PublishAot is for server-side Native AOT and produces a different kind of binary. 2. Do not assume AOT reduces WASM download size. AOT increases artifact size because native WASM code is larger than IL bytecode. Use trimming to reduce size and AOT to improve runtime speed. 3. Do not forget to publish when testing AOT. WASM AOT only runs during dotnet publish, not dotnet run. Debug builds always use IL interpretation. 4. Do not lazy-load assemblies that are needed at startup. Only lazy-load assemblies for features accessed after initial navigation. Loading a lazy assembly triggers a network request. 5. Do not skip Brotli compression verification. Ensure your web server serves .br files. Without compression, WASM downloads are 3-5x larger than necessary. Check browser DevTools for content-encoding: br header. 6. Do not AOT-compile all assemblies when download size matters. Use BlazorWebAssemblyLazyLoad to defer non-critical assemblies -- lazy-loaded assemblies use IL interpretation instead of AOT.
---
References
Aspire Testing
Integration testing for .NET Aspire distributed applications using DistributedApplicationTestingBuilder from Aspire.Hosting.Testing. Covers creating test hosts, getting HTTP clients, waiting for resource health, environment customization, container cleanup, and when to choose Aspire testing vs WebApplicationFactory.
Cross-references: references/integration-testing.md for WebApplicationFactory and Testcontainers, references/xunit.md for xUnit fixture lifecycle.
---
Package
<PackageReference Include="Aspire.Hosting.Testing" Version="9.*" />The test project must reference the AppHost project directly:
<ProjectReference Include="..\MyApp.AppHost\MyApp.AppHost.csproj" />Version follows the Aspire SDK release cadence. The API surface is stable from 9.0 onward.
---
Basic Test Lifecycle
public class AspireIntegrationTests
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
[Fact]
public async Task WebFrontend_ReturnsOk()
{
// Create builder from AppHost entry point
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyApp_AppHost>();
builder.Services.ConfigureHttpClientDefaults(http =>
http.AddStandardResilienceHandler());
// Build and start
await using var app = await builder.BuildAsync();
await app.StartAsync();
// Wait for resource health
await app.ResourceNotifications
.WaitForResourceHealthyAsync("webfrontend")
.WaitAsync(DefaultTimeout);
// Act + Assert
using var httpClient = app.CreateHttpClient("webfrontend");
using var response = await httpClient.GetAsync("/");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}app.CreateHttpClient("resource-name") returns an HttpClient with the correct base address via Aspire service discovery. The resource name must exactly match the string in AddProject/AddContainer in the AppHost.
---
Waiting for Resources
Always wait for health before making assertions. Always apply .WaitAsync(timeout) to prevent indefinite hangs.
// Single resource
await app.ResourceNotifications
.WaitForResourceHealthyAsync("apiservice")
.WaitAsync(TimeSpan.FromSeconds(60));
// Multiple resources in parallel
await Task.WhenAll(
app.ResourceNotifications.WaitForResourceHealthyAsync("apiservice")
.WaitAsync(TimeSpan.FromSeconds(60)),
app.ResourceNotifications.WaitForResourceHealthyAsync("postgres")
.WaitAsync(TimeSpan.FromSeconds(60)));---
Configuring the Test Host
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyApp_AppHost>();
// Logging -- control verbosity
builder.Services.AddLogging(logging =>
{
logging.SetMinimumLevel(LogLevel.Debug);
logging.AddFilter("Aspire.", LogLevel.Debug);
});
// HTTP resilience -- retries transient 503s during startup
builder.Services.ConfigureHttpClientDefaults(http =>
http.AddStandardResilienceHandler());For xUnit, use MartinCostello.Logging.XUnit to route logs to ITestOutputHelper.
---
xUnit Integration with IAsyncLifetime
Share an Aspire app across multiple tests in a class:
public class OrderServiceTests : IAsyncLifetime
{
private DistributedApplication _app = null!;
private HttpClient _apiClient = null!;
public async ValueTask InitializeAsync()
{
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyApp_AppHost>();
builder.Services.ConfigureHttpClientDefaults(http =>
http.AddStandardResilienceHandler());
_app = await builder.BuildAsync();
await _app.StartAsync();
await _app.ResourceNotifications
.WaitForResourceHealthyAsync("apiservice")
.WaitAsync(TimeSpan.FromSeconds(60));
_apiClient = _app.CreateHttpClient("apiservice");
}
[Fact]
public async Task GetOrders_ReturnsOk()
{
var response = await _apiClient.GetAsync("/api/orders");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task CreateOrder_Returns201()
{
var response = await _apiClient.PostAsJsonAsync("/api/orders",
new { CustomerId = "cust-1", Items = new[] { new { Sku = "X", Qty = 1 } } });
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
public async ValueTask DisposeAsync()
{
_apiClient.Dispose();
await _app.DisposeAsync();
}
}---
Testing Environment Variables
Validate orchestration wiring without starting resources:
[Fact]
public async Task WebFrontend_HasApiServiceReference()
{
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyApp_AppHost>();
var frontend = builder.CreateResourceBuilder<ProjectResource>("webfrontend");
var config = await ExecutionConfigurationBuilder
.Create(frontend.Resource)
.WithEnvironmentVariablesConfig()
.BuildAsync(new(DistributedApplicationOperation.Publish),
NullLogger.Instance, CancellationToken.None);
var envVars = config.EnvironmentVariables.ToDictionary();
Assert.Contains(envVars, kvp =>
kvp.Key == "APISERVICE_HTTPS" &&
kvp.Value == "{apiservice.bindings.https.url}");
}---
Testing with Database and Cache Resources
Aspire manages container lifecycle automatically. Tests wait for readiness and interact through service endpoints.
[Fact]
public async Task ApiWithPostgres_RoundTrip()
{
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyApp_AppHost>();
builder.Services.ConfigureHttpClientDefaults(http =>
http.AddStandardResilienceHandler());
await using var app = await builder.BuildAsync();
await app.StartAsync();
await Task.WhenAll(
app.ResourceNotifications.WaitForResourceHealthyAsync("apiservice")
.WaitAsync(TimeSpan.FromSeconds(60)),
app.ResourceNotifications.WaitForResourceHealthyAsync("postgres")
.WaitAsync(TimeSpan.FromSeconds(60)));
using var client = app.CreateHttpClient("apiservice");
var createResp = await client.PostAsJsonAsync("/api/orders",
new { CustomerId = "test-1", Total = 42.0 });
Assert.Equal(HttpStatusCode.Created, createResp.StatusCode);
var getResp = await client.GetAsync(createResp.Headers.Location!.ToString());
getResp.EnsureSuccessStatusCode();
}---
When to Use Aspire Testing vs WebApplicationFactory
| Scenario | Recommendation |
|---|---|
| Single API, no distributed dependencies | WebApplicationFactory -- lighter, no Docker |
| Multiple services with inter-service communication | Aspire testing -- validates full topology |
| Testing service discovery and resource wiring | Aspire testing |
| Unit-testing a single endpoint with mocks | WebApplicationFactory |
| CI without Docker available | WebApplicationFactory |
---
Container Cleanup and Test Isolation
DistributedApplication implements IAsyncDisposable. Disposing stops and removes all containers. Always use await using or explicit DisposeAsync. Each test run gets fresh containers with no shared state. To share across test classes, use ICollectionFixture<T> and manage data isolation at the application level.
---
Agent Gotchas
1. Do not forget that Docker is required. Aspire testing starts real containers. Tests fail immediately without Docker. Guard CI with availability checks. 2. Do not omit `.WaitAsync(timeout)` on resource waits. Without a timeout, WaitForResourceHealthyAsync hangs forever if a container fails to start. 3. Do not hardcode resource names. Names must exactly match the AppHost's AddProject/AddContainer string. A typo causes CreateHttpClient to throw with an unhelpful error. 4. Do not skip `ConfigureHttpClientDefaults` with resilience. Without the standard resilience handler, first requests often hit 503 or connection refused during startup. 5. Do not assume fast container startup. Database containers take 10-30 seconds; Redis is 2-5 seconds. Set timeouts accordingly to avoid flaky CI. 6. Do not forget to dispose `DistributedApplication`. Undisposed apps leave orphaned containers. Always use await using. 7. Do not run Aspire tests in parallel without isolation. Port conflicts and data races occur. Use [Collection] to serialize test classes sharing the same AppHost.
---
References
BenchmarkDotNet
Microbenchmarking guidance for .NET using BenchmarkDotNet v0.14+. Covers benchmark class setup, memory and disassembly diagnosers, exporters for CI artifact collection, baseline comparisons, and common pitfalls that invalidate measurements.
Version assumptions: BenchmarkDotNet v0.14+ on .NET 8.0+ baseline. Examples use current stable APIs.
Package Setup
<!-- Benchmarks.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.*" />
</ItemGroup>
</Project>Keep benchmark projects separate from production code. Use a benchmarks/ directory at the solution root.
---
Benchmark Class Setup
Basic Benchmark with [Benchmark] Attribute
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser]
public class StringConcatBenchmarks
{
private readonly string[] _items = Enumerable.Range(0, 100)
.Select(i => i.ToString())
.ToArray();
[Benchmark(Baseline = true)]
public string StringConcat()
{
var result = string.Empty;
foreach (var item in _items)
result += item;
return result;
}
[Benchmark]
public string StringBuilder()
{
var sb = new System.Text.StringBuilder();
foreach (var item in _items)
sb.Append(item);
return sb.ToString();
}
[Benchmark]
public string StringJoin() => string.Join(string.Empty, _items);
}Running Benchmarks
// Program.cs
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<StringConcatBenchmarks>();Run in Release mode (mandatory for valid results):
dotnet run -c ReleaseParameterized Benchmarks
[MemoryDiagnoser]
public class CollectionBenchmarks
{
[Params(10, 100, 1000)]
public int Size { get; set; }
private int[] _data = null!;
[GlobalSetup]
public void Setup()
{
_data = Enumerable.Range(0, Size).ToArray();
}
[Benchmark(Baseline = true)]
public int ForLoop()
{
var sum = 0;
for (var i = 0; i < _data.Length; i++)
sum += _data[i];
return sum;
}
[Benchmark]
public int LinqSum() => _data.Sum();
}---
Memory Diagnosers
MemoryDiagnoser
Tracks GC allocations and collection counts per benchmark invocation. Apply at class level to all benchmarks:
[MemoryDiagnoser]
public class AllocationBenchmarks
{
[Benchmark]
public byte[] AllocateArray() => new byte[1024];
[Benchmark]
public int UseStackalloc()
{
Span<byte> buffer = stackalloc byte[1024];
buffer[0] = 42;
return buffer[0];
}
}Output columns:
| Column | Meaning |
|---|---|
Allocated | Bytes allocated per operation |
Gen0 | Gen 0 GC collections per 1000 operations |
Gen1 | Gen 1 GC collections per 1000 operations |
Gen2 | Gen 2 GC collections per 1000 operations |
Zero in Allocated column confirms zero-allocation code paths.
DisassemblyDiagnoser
Inspects JIT-compiled assembly to verify optimizations (devirtualization, inlining):
[DisassemblyDiagnoser(maxDepth: 2)]
[MemoryDiagnoser]
public class DevirtualizationBenchmarks
{
// sealed enables JIT devirtualization -- verify in disassembly output
// See [skill:dotnet-csharp] `references/coding-standards.md` for sealed class conventions
[Benchmark]
public int SealedCall()
{
var obj = new SealedService();
return obj.Calculate(42);
}
[Benchmark]
public int VirtualCall()
{
IService obj = new SealedService();
return obj.Calculate(42);
}
}
public interface IService { int Calculate(int x); }
public sealed class SealedService : IService
{
public int Calculate(int x) => x * 2;
}Use DisassemblyDiagnoser to verify that sealed classes receive devirtualization from the JIT, confirming the performance rationale documented in [skill:dotnet-csharp] references/coding-standards.md.
---
Exporters for CI Integration
Configuring Exporters
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Exporters;
using BenchmarkDotNet.Exporters.Json;
[MemoryDiagnoser]
[JsonExporterAttribute.Full]
[HtmlExporter]
[MarkdownExporter]
public class CiBenchmarks
{
[Benchmark]
public void MyOperation()
{
// benchmark code
}
}Exporter Output
| Exporter | File | Use Case |
|---|---|---|
JsonExporterAttribute.Full | BenchmarkDotNet.Artifacts/results/*-report-full.json | CI regression comparison (machine-readable) |
HtmlExporter | BenchmarkDotNet.Artifacts/results/*-report.html | Human-readable PR review artifact |
MarkdownExporter | BenchmarkDotNet.Artifacts/results/*-report-github.md | Paste into PR comments |
Custom Config for CI
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Exporters.Json;
using BenchmarkDotNet.Jobs;
var config = ManualConfig.Create(DefaultConfig.Instance)
.AddJob(Job.ShortRun) // fewer iterations for CI speed
.AddExporter(JsonExporter.Full)
.WithArtifactsPath("./benchmark-results");
BenchmarkRunner.Run<CiBenchmarks>(config);GitHub Actions Artifact Upload
- name: Run benchmarks
run: dotnet run -c Release --project benchmarks/MyBenchmarks.csproj
- name: Upload benchmark results
uses: actions/upload-artifact@v4
with:
name: benchmark-results
path: benchmarks/BenchmarkDotNet.Artifacts/results/
retention-days: 30---
Baseline Comparison
Setting a Baseline
Mark one benchmark as the baseline for ratio comparison:
[MemoryDiagnoser]
public class SerializationBenchmarks
{
// Serialization format choice -- see [skill:dotnet-csharp] `references/serialization.md` for API details
private readonly JsonSerializerOptions _options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private readonly WeatherForecast _data = new()
{
Date = DateOnly.FromDateTime(DateTime.Now),
TemperatureC = 25,
Summary = "Warm"
};
[Benchmark(Baseline = true)]
public string SystemTextJson()
=> System.Text.Json.JsonSerializer.Serialize(_data, _options);
[Benchmark]
public byte[] Utf8Serialization()
=> System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(_data, _options);
}
public record WeatherForecast
{
public DateOnly Date { get; init; }
public int TemperatureC { get; init; }
public string? Summary { get; init; }
}The Ratio column in output shows performance relative to the baseline (1.00). Values below 1.00 indicate faster than baseline; above 1.00 indicate slower.
Benchmark Categories
Group benchmarks with [BenchmarkCategory] and filter at runtime:
[MemoryDiagnoser]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
public class CategorizedBenchmarks
{
[Benchmark, BenchmarkCategory("Serialization")]
public string JsonSerialize() => "...";
[Benchmark, BenchmarkCategory("Allocation")]
public byte[] ArrayAlloc() => new byte[1024];
}Run a specific category:
dotnet run -c Release -- --filter *Serialization*---
BenchmarkRunner.Run Patterns
Running Specific Benchmarks
// Run a single benchmark class
BenchmarkRunner.Run<StringConcatBenchmarks>();
// Run all benchmarks in assembly
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);Command-Line Filtering
# Run benchmarks matching a pattern
dotnet run -c Release -- --filter *StringBuilder*
# List all available benchmarks without running
dotnet run -c Release -- --list flat
# Dry run (validates setup without full benchmark)
dotnet run -c Release -- --filter *StringBuilder* --job DryAOT Benchmark Considerations
When benchmarking Native AOT scenarios, the JIT diagnosers are not available (there is no JIT). Use wall-clock time and memory comparisons instead. See [skill:dotnet-tooling] references/native-aot.md for AOT compilation setup:
[MemoryDiagnoser]
// Do NOT use DisassemblyDiagnoser with AOT -- no JIT to disassemble
public class AotBenchmarks
{
[Benchmark]
public string SourceGenSerialize()
=> System.Text.Json.JsonSerializer.Serialize(
new { Value = 42 },
AppJsonContext.Default.Options);
}---
Common Pitfalls
Dead Code Elimination
The JIT may eliminate benchmark code whose result is unused. Always return or consume the result:
// BAD: JIT may eliminate the entire loop
[Benchmark]
public void DeadCode()
{
var sum = 0;
for (var i = 0; i < 1000; i++)
sum += i;
// sum is never used -- JIT removes the loop
}
// GOOD: return the value to prevent elimination
[Benchmark]
public int LiveCode()
{
var sum = 0;
for (var i = 0; i < 1000; i++)
sum += i;
return sum;
}Measurement Bias
| Pitfall | Cause | Fix |
|---|---|---|
| Running in Debug mode | No JIT optimizations applied | Always use -c Release |
| Shared mutable state | Benchmarks interfere with each other | Use [IterationSetup] or immutable data |
| Cold-start measurement | First run includes JIT compilation | BenchmarkDotNet handles warmup automatically -- do not add manual warmup |
| Allocations in setup | Setup allocations inflate Allocated column | Use [GlobalSetup] (runs once) vs [IterationSetup] (runs per iteration) |
| Environment noise | Background processes skew results | BenchmarkDotNet detects and warns about environment issues; use Job.MediumRun for noisy environments |
Setup vs Iteration Lifecycle
[MemoryDiagnoser]
public class LifecycleBenchmarks
{
private byte[] _data = null!;
[GlobalSetup] // Runs once before all benchmark iterations
public void GlobalSetup() => _data = new byte[1024];
[IterationSetup] // Runs before each benchmark iteration
public void IterationSetup() => Array.Fill(_data, (byte)0);
[Benchmark]
public int Process()
{
// uses _data
return _data.Length;
}
[GlobalCleanup] // Runs once after all iterations
public void GlobalCleanup() { /* dispose resources */ }
}Prefer [GlobalSetup] over [IterationSetup] unless the benchmark mutates shared state. [IterationSetup] adds overhead that BenchmarkDotNet excludes from timing, but it still affects GC pressure measurement.
---
Agent Gotchas
1. Always run benchmarks in Release mode -- dotnet run -c Release. Debug mode disables JIT optimizations and produces meaningless results. 2. Never benchmark in a test project -- xUnit/NUnit test runners interfere with BenchmarkDotNet's measurement harness. Use a standalone console project. 3. Return values from benchmark methods to prevent dead code elimination. The JIT will remove computation whose result is discarded. 4. Do not add manual Thread.Sleep or Task.Delay in benchmarks -- BenchmarkDotNet manages warmup and iteration timing automatically. 5. Use `[GlobalSetup]` not constructor for initialization -- BenchmarkDotNet creates benchmark instances multiple times during a run; constructor code runs repeatedly. 6. Prefer `[Params]` over manual loops for parameterized benchmarks. BenchmarkDotNet runs each parameter combination independently with proper statistical analysis. 7. Export JSON for CI -- use [JsonExporterAttribute.Full] to produce machine-readable artifacts for regression detection, not just Markdown.
CI Benchmarking
Continuous benchmarking guidance for detecting performance regressions in CI pipelines. Covers baseline file management with BenchmarkDotNet JSON exporters, GitHub Actions workflows for artifact-based baseline comparison, regression detection patterns with configurable thresholds, and alerting strategies for performance degradation.
Version assumptions: BenchmarkDotNet v0.14+ for JSON export, GitHub Actions runner environment. Examples use actions/upload-artifact@v4 and actions/download-artifact@v4.
Baseline File Management
BenchmarkDotNet JSON Export
BenchmarkDotNet's JSON exporter produces machine-readable results for automated comparison. Configure the exporter in benchmark classes:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Exporters.Json;
[JsonExporterAttribute.Full]
[MemoryDiagnoser]
public class CriticalPathBenchmarks
{
[Benchmark(Baseline = true)]
public void ProcessOrder() { /* ... */ }
[Benchmark]
public void ProcessOrderOptimized() { /* ... */ }
}Or configure via custom config for all benchmark classes:
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Exporters.Json;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
var config = ManualConfig.Create(DefaultConfig.Instance)
.AddJob(Job.ShortRun) // fewer iterations for CI speed
.AddExporter(JsonExporter.Full)
.WithArtifactsPath("./benchmark-results");
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config);JSON Export Structure
The exported JSON file (*-report-full.json) contains structured benchmark results:
{
"Title": "CriticalPathBenchmarks",
"Benchmarks": [
{
"FullName": "MyApp.Benchmarks.CriticalPathBenchmarks.ProcessOrder",
"Statistics": {
"Mean": 1234.5678,
"Median": 1230.1234,
"StandardDeviation": 15.234,
"StandardError": 4.812
},
"Memory": {
"BytesAllocatedPerOperation": 1024,
"Gen0Collections": 0.0012,
"Gen1Collections": 0,
"Gen2Collections": 0
}
}
]
}Key fields for regression comparison:
| Field | Purpose |
|---|---|
Statistics.Mean | Average execution time (nanoseconds) |
Statistics.Median | Middle execution time (more robust to outliers) |
Statistics.StandardDeviation | Measurement variability |
Memory.BytesAllocatedPerOperation | GC allocation per operation |
Baseline Storage Strategies
| Strategy | Pros | Cons | Best For |
|---|---|---|---|
| Git-committed baseline file | Versioned, auditable, no external deps | Repo size grows; must update deliberately | Small benchmark suites, stable hardware |
| GitHub Actions artifacts | No repo bloat; automatic retention | 90-day default retention; cross-workflow access requires tokens | Large benchmark suites, shared runners |
| External storage (S3/Azure Blob) | Unlimited history; cross-repo sharing | Extra infrastructure; credential management | Multi-repo benchmark comparison |
This skill focuses on the GitHub Actions artifact strategy as the default. For composable workflow patterns and reusable actions, see [skill:dotnet-devops] references/gha-patterns.md.
---
GitHub Actions Benchmark Workflow
Basic Benchmark Workflow
name: Benchmarks
on:
pull_request:
paths:
- 'src/**'
- 'benchmarks/**'
workflow_dispatch:
permissions:
contents: read
actions: read # required for artifact download
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Run benchmarks
run: dotnet run -c Release --project benchmarks/MyBenchmarks.csproj -- --exporters json
- name: Upload benchmark results
uses: actions/upload-artifact@v4
with:
name: benchmark-results-${{ github.sha }}
path: benchmarks/BenchmarkDotNet.Artifacts/results/
retention-days: 90Baseline Comparison Workflow
This workflow downloads the baseline from a previous run and compares against current results:
name: Benchmark Regression Check
on:
pull_request:
paths:
- 'src/**'
- 'benchmarks/**'
permissions:
contents: read
actions: read
env:
BENCHMARK_PROJECT: benchmarks/MyBenchmarks.csproj
RESULTS_DIR: benchmarks/BenchmarkDotNet.Artifacts/results
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Download baseline results
uses: actions/download-artifact@v4
with:
name: benchmark-baseline
path: ./baseline-results
continue-on-error: true
id: download-baseline
- name: Run benchmarks
run: dotnet run -c Release --project ${{ env.BENCHMARK_PROJECT }} -- --exporters json
- name: Compare with baseline
if: steps.download-baseline.outcome == 'success'
shell: bash
run: |
set -euo pipefail
python3 scripts/compare-benchmarks.py \
--baseline ./baseline-results \
--current "${{ env.RESULTS_DIR }}" \
--threshold 10 \
--output benchmark-comparison.md
- name: Upload current results as new baseline
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: benchmark-baseline
path: ${{ env.RESULTS_DIR }}/
retention-days: 90
overwrite: true
- name: Upload comparison report
if: steps.download-baseline.outcome == 'success'
uses: actions/upload-artifact@v4
with:
name: benchmark-comparison-${{ github.sha }}
path: benchmark-comparison.md
retention-days: 30Key design decisions:
continue-on-error: trueon baseline download handles first-run (no baseline exists yet)- Baseline is only updated from
mainbranch merges to prevent PR branches from polluting the baseline overwrite: truereplaces the previous baseline artifact
For converting these inline workflows into reusable workflow_call patterns, see [skill:dotnet-devops] references/gha-patterns.md.
---
Regression Detection Patterns
Threshold-Based Comparison
Compare current benchmark results against baseline using percentage thresholds. A regression is flagged when the current mean exceeds the baseline mean by more than the configured threshold:
#!/usr/bin/env python3
"""compare-benchmarks.py -- Detect benchmark regressions from BenchmarkDotNet JSON exports."""
import json
import sys
from pathlib import Path
def load_benchmarks(results_dir: str) -> dict:
"""Load benchmark results from BenchmarkDotNet JSON export files."""
benchmarks = {}
for json_file in Path(results_dir).glob("*-report-full.json"):
with open(json_file) as f:
data = json.load(f)
for bm in data.get("Benchmarks", []):
name = bm["FullName"]
benchmarks[name] = {
"mean": bm["Statistics"]["Mean"],
"median": bm["Statistics"]["Median"],
"stddev": bm["Statistics"]["StandardDeviation"],
"allocated": bm.get("Memory", {}).get("BytesAllocatedPerOperation", 0),
}
return benchmarks
def compare(baseline_dir: str, current_dir: str, threshold_pct: float) -> list:
"""Compare current results against baseline. Returns list of regressions."""
baseline = load_benchmarks(baseline_dir)
current = load_benchmarks(current_dir)
regressions = []
for name, curr in current.items():
if name not in baseline:
continue # new benchmark, no comparison possible
base = baseline[name]
if base["mean"] == 0:
continue # avoid division by zero
time_change_pct = ((curr["mean"] - base["mean"]) / base["mean"]) * 100
alloc_change = curr["allocated"] - base["allocated"]
if time_change_pct > threshold_pct:
regressions.append({
"name": name,
"baseline_mean": base["mean"],
"current_mean": curr["mean"],
"change_pct": time_change_pct,
"alloc_change": alloc_change,
})
return regressions
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Compare BenchmarkDotNet results")
parser.add_argument("--baseline", required=True, help="Path to baseline results directory")
parser.add_argument("--current", required=True, help="Path to current results directory")
parser.add_argument("--threshold", type=float, default=10.0,
help="Regression threshold percentage (default: 10)")
parser.add_argument("--output", default="comparison.md", help="Output markdown file")
args = parser.parse_args()
regressions = compare(args.baseline, args.current, args.threshold)
with open(args.output, "w") as f:
if regressions:
f.write("## Benchmark Regressions Detected\n\n")
f.write("| Benchmark | Baseline (ns) | Current (ns) | Change | Alloc Delta |\n")
f.write("|-----------|--------------|-------------|--------|-------------|\n")
for r in regressions:
f.write(f"| `{r['name']}` | {r['baseline_mean']:.2f} | "
f"{r['current_mean']:.2f} | +{r['change_pct']:.1f}% | "
f"{r['alloc_change']:+d} B |\n")
f.write(f"\nThreshold: {args.threshold}%\n")
else:
f.write("## Benchmark Results\n\nNo regressions detected ")
f.write(f"(threshold: {args.threshold}%).\n")
if regressions:
print(f"REGRESSION: {len(regressions)} benchmark(s) exceeded "
f"{args.threshold}% threshold", file=sys.stderr)
sys.exit(1)Choosing Thresholds
| Environment | Suggested Threshold | Rationale |
|---|---|---|
| Dedicated benchmark hardware | 5% | Low noise floor; small regressions are signal |
| GitHub Actions shared runners | 10-15% | Shared runners introduce 5-10% variance from noisy neighbors |
| Self-hosted runners | 5-10% | More stable than shared, but still monitor variance |
Calibrate thresholds empirically: Run the same benchmark suite 5-10 times on your CI environment without code changes. The maximum observed variance sets your noise floor. Set the threshold above this noise floor (typically 2x the observed variance).
Allocation Regression Detection
Memory allocation regressions are more reliable signals than timing regressions because allocations are deterministic (not affected by noisy neighbors):
# Add to the compare function:
if alloc_change > 0:
regressions.append({
"name": name,
"type": "allocation",
"baseline_alloc": base["allocated"],
"current_alloc": curr["allocated"],
"alloc_change": alloc_change,
})Use allocation changes as a hard gate (zero tolerance for new allocations in zero-alloc paths) and timing changes as a soft gate (warning with threshold).
---
Alerting Strategies
PR Comment with Regression Summary
Post benchmark comparison results as a PR comment for reviewer visibility:
- name: Comment PR with results
if: steps.download-baseline.outcome == 'success' && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('benchmark-comparison.md', 'utf8');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});Fail the Build on Regression
Exit with non-zero status from the comparison script to fail the GitHub Actions job. This prevents merging PRs that introduce performance regressions:
- name: Check for regressions
if: steps.download-baseline.outcome == 'success'
shell: bash
run: |
set -euo pipefail
python3 scripts/compare-benchmarks.py \
--baseline ./baseline-results \
--current "${{ env.RESULTS_DIR }}" \
--threshold 10
# Script exits non-zero if regressions found -- fails the jobFor required status checks and branch protection integration with benchmark gates, see [skill:dotnet-devops] references/gha-patterns.md.
Trend Tracking
For long-term trend analysis beyond single-PR comparison, upload results to a persistent store and track metrics over time:
| Approach | Tool | Complexity |
|---|---|---|
| GitHub Actions artifacts | Built-in, 90-day retention | Low -- artifact download/upload only |
| GitHub Pages with benchmark-action | benchmark-action/github-action-benchmark@v1 | Medium -- auto-generates trend charts |
| External time-series DB | InfluxDB, Prometheus + Grafana | High -- full observability stack |
The simplest approach for most projects is the artifact-based baseline comparison shown in this skill. Graduate to trend tracking when you need historical regression analysis across many releases.
---
CI-Specific BenchmarkDotNet Configuration
ShortRun for CI Speed
Full benchmark runs take 10-30+ minutes. Use Job.ShortRun in CI to reduce iteration counts while retaining regression detection capability:
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
public class CiConfig : ManualConfig
{
public CiConfig()
{
AddJob(Job.ShortRun
.WithWarmupCount(3)
.WithIterationCount(5)
.WithInvocationCount(1));
AddExporter(BenchmarkDotNet.Exporters.Json.JsonExporter.Full);
}
}Apply conditionally based on environment:
var config = Environment.GetEnvironmentVariable("CI") is not null
? new CiConfig()
: DefaultConfig.Instance;
BenchmarkRunner.Run<CriticalPathBenchmarks>(config);Filtering Benchmarks for CI
Run only critical-path benchmarks in CI to reduce pipeline duration:
# Run only benchmarks in the "Critical" category
dotnet run -c Release --project benchmarks/MyBenchmarks.csproj -- \
--filter *Critical* --exporters json[BenchmarkCategory("Critical")]
[MemoryDiagnoser]
[JsonExporterAttribute.Full]
public class CriticalPathBenchmarks
{
[Benchmark]
public void ProcessOrder() { /* ... */ }
}
[BenchmarkCategory("Extended")]
[MemoryDiagnoser]
public class ExtendedBenchmarks
{
[Benchmark]
public void RareCodePath() { /* ... */ }
}Run Critical benchmarks on every PR; run Extended benchmarks on a nightly schedule.
Nightly Benchmark Schedule
name: Nightly Benchmarks (Full Suite)
on:
schedule:
- cron: '0 3 * * *' # 3 AM UTC daily
workflow_dispatch:
jobs:
benchmark-full:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Run full benchmark suite
run: dotnet run -c Release --project benchmarks/MyBenchmarks.csproj -- --exporters json
# No --filter: runs all benchmarks including Extended category
- name: Upload full results
uses: actions/upload-artifact@v4
with:
name: benchmark-full-${{ github.run_number }}
path: benchmarks/BenchmarkDotNet.Artifacts/results/
retention-days: 90For scheduled workflow patterns and matrix builds across TFMs, see [skill:dotnet-devops] references/gha-patterns.md.
---
Agent Gotchas
1. Use `Job.ShortRun` in CI, not `Job.Default` -- default benchmark jobs run many iterations for statistical precision, taking 10-30+ minutes per benchmark class. CI pipelines need faster feedback with ShortRun (3 warmup, 5 iteration). 2. Set threshold above measured noise floor -- shared CI runners introduce 5-10% timing variance from noisy neighbors. A 5% threshold on shared runners produces false positives. Calibrate by running the same code multiple times and measuring variance. 3. Use allocation changes as hard gates -- allocation counts are deterministic and unaffected by runner noise. A zero-to-nonzero allocation change is always a real regression, unlike timing variations. 4. Only update baselines from main branch -- if PR branches can update the baseline, a regression in one PR becomes the new baseline, masking it from subsequent comparisons. 5. Always set `set -euo pipefail` in bash steps -- without pipefail, a regression detection script that exits non-zero in a pipeline (e.g., script | tee) does not fail the GitHub Actions step. 6. Handle missing baselines gracefully -- the first CI run has no baseline to compare against. Use continue-on-error: true on the baseline download step and skip comparison when no baseline exists. 7. Export JSON, not just Markdown -- Markdown reports are human-readable but not machine-parseable for automated regression detection. Always include [JsonExporterAttribute.Full] or JsonExporter.Full in the config.
Integration Testing
Integration testing patterns for .NET applications using WebApplicationFactory, Testcontainers, and .NET Aspire testing. Covers in-process API testing, disposable infrastructure via containers, database fixture management, and test isolation strategies.
Version assumptions: .NET 8.0+ baseline, Testcontainers 3.x+, .NET Aspire 9.0+. Package versions for Microsoft.AspNetCore.Mvc.Testing must match the project's target framework major version (e.g., 8.x for net8.0, 9.x for net9.0, 10.x for net10.0). Examples below use Testcontainers 4.x APIs; the patterns apply equally to 3.x with minor namespace differences.
WebApplicationFactory
WebApplicationFactory<TEntryPoint> creates an in-process test server for ASP.NET Core applications. Tests send HTTP requests without network overhead, exercising the full middleware pipeline, routing, model binding, and serialization.
Package
<!-- Version must match target framework: 8.x for net8.0, 9.x for net9.0, etc. -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />Basic Usage
public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetOrders_ReturnsOkWithJsonArray()
{
var response = await _client.GetAsync("/api/orders");
response.EnsureSuccessStatusCode();
var orders = await response.Content
.ReadFromJsonAsync<List<OrderDto>>();
Assert.NotNull(orders);
}
[Fact]
public async Task CreateOrder_ValidPayload_Returns201()
{
var request = new CreateOrderRequest
{
CustomerId = "cust-123",
Items = [new("SKU-001", Quantity: 2)]
};
var response = await _client.PostAsJsonAsync("/api/orders", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
}
}Important: The Program class must be accessible to the test project. Either make it public or add an InternalsVisibleTo attribute:
// In the API project (e.g., Program.cs or a separate file)
[assembly: InternalsVisibleTo("MyApp.Api.IntegrationTests")]Or in the csproj:
<ItemGroup>
<InternalsVisibleTo Include="MyApp.Api.IntegrationTests" />
</ItemGroup>Customizing the Test Server
Override services, configuration, or middleware using WebApplicationFactory<T>.WithWebHostBuilder:
public class CustomWebAppFactory : WebApplicationFactory<Program>
{
// Provide connection string from test fixture (e.g., Testcontainers)
public string ConnectionString { get; set; } = "";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Default"] = ConnectionString,
["Features:EnableNewCheckout"] = "true"
});
});
builder.ConfigureTestServices(services =>
{
// Replace real services with test doubles
services.RemoveAll<IEmailSender>();
services.AddSingleton<IEmailSender, FakeEmailSender>();
// Replace database context with test database
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(ConnectionString));
});
}
}Authenticated Requests
Test authenticated endpoints by configuring an authentication handler:
public class AuthenticatedWebAppFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
"Test", options => { });
});
}
}
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
new Claim(ClaimTypes.Name, "Test User"),
new Claim(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}---
Testcontainers
Testcontainers spins up real infrastructure (databases, message brokers, caches) in Docker containers for tests. Each test run gets a fresh, disposable environment.
Packages
<PackageReference Include="Testcontainers" Version="4.*" />
<!-- Database-specific modules -->
<PackageReference Include="Testcontainers.PostgreSql" Version="4.*" />
<PackageReference Include="Testcontainers.MsSql" Version="4.*" />
<PackageReference Include="Testcontainers.Redis" Version="4.*" />PostgreSQL Example
public class PostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("testdb")
.WithUsername("test")
.WithPassword("test")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
}
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync();
}
}
[CollectionDefinition("Postgres")]
public class PostgresCollection : ICollectionFixture<PostgresFixture> { }
[Collection("Postgres")]
public class OrderRepositoryTests
{
private readonly PostgresFixture _postgres;
public OrderRepositoryTests(PostgresFixture postgres)
{
_postgres = postgres;
}
[Fact]
public async Task Insert_ValidOrder_CanBeRetrieved()
{
await using var context = CreateContext(_postgres.ConnectionString);
await context.Database.EnsureCreatedAsync();
var order = new Order { CustomerId = "cust-1", Total = 99.99m };
context.Orders.Add(order);
await context.SaveChangesAsync();
var retrieved = await context.Orders.FindAsync(order.Id);
Assert.NotNull(retrieved);
Assert.Equal(99.99m, retrieved.Total);
}
private static AppDbContext CreateContext(string connectionString)
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(connectionString)
.Options;
return new AppDbContext(options);
}
}SQL Server Example
public class SqlServerFixture : IAsyncLifetime
{
private readonly MsSqlContainer _container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
}
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync();
}
}Combining WebApplicationFactory with Testcontainers
The most common pattern: use Testcontainers for the database and WebApplicationFactory for the API:
public class ApiTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString()));
});
}
public async ValueTask InitializeAsync()
{
await _postgres.StartAsync();
}
public new async ValueTask DisposeAsync()
{
await _postgres.DisposeAsync();
await base.DisposeAsync();
}
}
public class OrdersApiIntegrationTests : IClassFixture<ApiTestFactory>
{
private readonly HttpClient _client;
private readonly ApiTestFactory _factory;
public OrdersApiIntegrationTests(ApiTestFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task CreateAndRetrieveOrder_RoundTrip()
{
// Ensure schema exists
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();
// Create
var createResponse = await _client.PostAsJsonAsync("/api/orders",
new { CustomerId = "cust-1", Items = new[] { new { Sku = "SKU-1", Quantity = 2 } } });
createResponse.EnsureSuccessStatusCode();
var location = createResponse.Headers.Location!.ToString();
// Retrieve
var getResponse = await _client.GetAsync(location);
getResponse.EnsureSuccessStatusCode();
var order = await getResponse.Content.ReadFromJsonAsync<OrderDto>();
Assert.Equal("cust-1", order!.CustomerId);
}
}---
.NET Aspire Testing
.NET Aspire provides DistributedApplicationTestingBuilder for testing multi-service applications orchestrated with Aspire. This tests the actual distributed topology including service discovery, configuration, and health checks.
Package
<PackageReference Include="Aspire.Hosting.Testing" Version="9.*" />Basic Aspire Test
public class AspireIntegrationTests
{
[Fact]
public async Task ApiService_ReturnsHealthy()
{
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyApp_AppHost>();
await using var app = await builder.BuildAsync();
await app.StartAsync();
var httpClient = app.CreateHttpClient("api-service");
var response = await httpClient.GetAsync("/health");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task ApiService_WithDatabase_ReturnsOrders()
{
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyApp_AppHost>();
await using var app = await builder.BuildAsync();
await app.StartAsync();
// Wait for resources to be healthy
var resourceNotification = app.Services
.GetRequiredService<ResourceNotificationService>();
await resourceNotification
.WaitForResourceHealthyAsync("api-service")
.WaitAsync(TimeSpan.FromSeconds(60));
var httpClient = app.CreateHttpClient("api-service");
var response = await httpClient.GetAsync("/api/orders");
response.EnsureSuccessStatusCode();
}
}Aspire with Service Overrides
Replace services in the Aspire app model for testing:
[Fact]
public async Task ApiService_WithMockedExternalDependency()
{
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyApp_AppHost>();
// Override configuration for the API service
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler();
});
await using var app = await builder.BuildAsync();
await app.StartAsync();
var httpClient = app.CreateHttpClient("api-service");
var response = await httpClient.GetAsync("/api/orders");
response.EnsureSuccessStatusCode();
}---
Database Fixture Patterns
Per-Test Isolation with Transactions
Roll back each test's changes using a transaction scope:
public class TransactionalTestBase : IClassFixture<PostgresFixture>, IAsyncLifetime
{
private readonly PostgresFixture _postgres;
private AppDbContext _context = null!;
private IDbContextTransaction _transaction = null!;
public TransactionalTestBase(PostgresFixture postgres)
{
_postgres = postgres;
}
protected AppDbContext Context => _context;
public async ValueTask InitializeAsync()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(_postgres.ConnectionString)
.Options;
_context = new AppDbContext(options);
await _context.Database.EnsureCreatedAsync();
_transaction = await _context.Database.BeginTransactionAsync();
}
public async ValueTask DisposeAsync()
{
await _transaction.RollbackAsync();
await _transaction.DisposeAsync();
await _context.DisposeAsync();
}
}
public class OrderTests : TransactionalTestBase
{
public OrderTests(PostgresFixture postgres) : base(postgres) { }
[Fact]
public async Task Insert_ValidOrder_Persists()
{
Context.Orders.Add(new Order { CustomerId = "cust-1", Total = 50m });
await Context.SaveChangesAsync();
var count = await Context.Orders.CountAsync();
Assert.Equal(1, count);
// Transaction rolls back after test -- database stays clean
}
}Per-Test Isolation with Respawn
Use Respawn to reset database state between tests by deleting data instead of rolling back transactions. This is useful when transaction rollback is not feasible (e.g., testing code that commits its own transactions):
// NuGet: Respawn
// Combined fixture: owns the container AND the respawner
public class RespawnablePostgresFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
private Respawner _respawner = null!;
private NpgsqlConnection _connection = null!;
public string ConnectionString => _container.GetConnectionString();
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
_connection = new NpgsqlConnection(ConnectionString);
await _connection.OpenAsync();
// Run migrations or EnsureCreated before creating respawner
// so it knows which tables to clean
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.Postgres,
TablesToIgnore = ["__EFMigrationsHistory"]
});
}
public async Task ResetDatabaseAsync()
{
await _respawner.ResetAsync(_connection);
}
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync();
await _container.DisposeAsync();
}
}---
Test Isolation Strategies
Strategy Comparison
| Strategy | Speed | Isolation | Complexity | Best For |
|---|---|---|---|---|
| Transaction rollback | Fastest | High | Low | Tests that use a single DbContext |
| Respawn (data deletion) | Fast | High | Medium | Tests where code commits its own transactions |
| Fresh container per class | Slow | Highest | Low | Tests that modify schema or need complete isolation |
| Shared container + cleanup | Moderate | Medium | Medium | Test suites with many classes sharing infrastructure |
Container Lifecycle Recommendations
Per-test: Too slow. Never spin up a container per test.
Per-class: Good isolation, acceptable speed with ICollectionFixture.
Per-collection: Best balance -- share one container across related test classes.
Per-assembly: Fastest but requires careful cleanup between tests.Use ICollectionFixture<T> (see [skill:dotnet-testing] references/xunit.md) to share a single container across multiple test classes while running those classes sequentially to avoid data conflicts.
---
Testing with Redis
public class RedisFixture : IAsyncLifetime
{
private readonly RedisContainer _container = new RedisBuilder()
.WithImage("redis:7-alpine")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async ValueTask InitializeAsync() => await _container.StartAsync();
public async ValueTask DisposeAsync() => await _container.DisposeAsync();
}
[CollectionDefinition("Redis")]
public class RedisCollection : ICollectionFixture<RedisFixture> { }
[Collection("Redis")]
public class CacheServiceTests
{
private readonly RedisFixture _redis;
public CacheServiceTests(RedisFixture redis) => _redis = redis;
[Fact]
public async Task SetAndGet_RoundTrip_ReturnsOriginalValue()
{
var multiplexer = await ConnectionMultiplexer.ConnectAsync(
_redis.ConnectionString);
var cache = new RedisCacheService(multiplexer);
await cache.SetAsync("key-1", new Order { Id = 1, Total = 99m });
var result = await cache.GetAsync<Order>("key-1");
Assert.NotNull(result);
Assert.Equal(99m, result.Total);
}
}---
Key Principles
- Use WebApplicationFactory for API tests. It is faster, more reliable, and more deterministic than testing against a deployed instance.
- Use Testcontainers for real infrastructure. Do not mock
DbContext-- test against a real database to verify LINQ-to-SQL translation and constraint enforcement. - Share containers across test classes via
ICollectionFixtureto avoid the overhead of starting a new container per class. - Choose the right isolation strategy. Transaction rollback is fastest and simplest; use Respawn when you cannot control transaction boundaries.
- Always clean up test data. Leftover data from one test causes flaky failures in another. Use transaction rollback, Respawn, or fresh containers.
- Match `Microsoft.AspNetCore.Mvc.Testing` version to TFM. Using the wrong version causes runtime binding failures.
---
Agent Gotchas
1. Do not hardcode `Microsoft.AspNetCore.Mvc.Testing` versions. The package version must match the project's target framework major version. Specifying e.g. Version="8.0.0" breaks net9.0 projects. 2. Do not forget `InternalsVisibleTo` for the `Program` class. Without it, WebApplicationFactory<Program> cannot access the entry point and tests fail at compile time. 3. Do not use `EnsureCreated()` with Respawn. EnsureCreated() does not track migrations. Use Database.MigrateAsync() for production schemas, or EnsureCreated() only for simple test schemas. 4. Do not dispose `WebApplicationFactory` before `HttpClient`. The factory owns the test server; disposing it invalidates all clients. Let xUnit manage disposal via IClassFixture. 5. Do not use `localhost` ports with Testcontainers. Testcontainers maps random host ports to container ports. Always use the connection string from the container object (e.g., _container.GetConnectionString()), never hardcoded ports. 6. Do not skip Docker availability checks in CI. Testcontainers requires a running Docker daemon. Ensure your CI environment has Docker available, or use conditional test skipping when Docker is unavailable.
---
References
Playwright
Playwright for .NET: browser automation and end-to-end testing. Covers browser lifecycle management, page interactions, assertions, CI caching of browser binaries, trace viewer for debugging failures, and codegen for rapid test scaffolding.
Version assumptions: Playwright 1.40+ for .NET, .NET 8.0+ baseline. Playwright supports Chromium, Firefox, and WebKit browsers.
Package Setup
<PackageReference Include="Microsoft.Playwright" Version="1.*" />
<!-- For xUnit integration: -->
<PackageReference Include="Microsoft.Playwright.Xunit" Version="1.*" />
<!-- For NUnit integration: -->
<!-- <PackageReference Include="Microsoft.Playwright.NUnit" Version="1.*" /> -->Installing Browsers
Playwright requires downloading browser binaries before tests can run:
# After building the test project:
pwsh bin/Debug/net8.0/playwright.ps1 install
# Or install specific browsers:
pwsh bin/Debug/net8.0/playwright.ps1 install chromium
pwsh bin/Debug/net8.0/playwright.ps1 install firefox
# Using dotnet tool:
dotnet tool install --global Microsoft.Playwright.CLI
playwright install---
Basic Test Structure
With Playwright xUnit Base Class
using Microsoft.Playwright;
using Microsoft.Playwright.Xunit;
// PageTest provides Page, Browser, BrowserContext, and Playwright properties
public class HomePageTests : PageTest
{
[Fact]
public async Task HomePage_Title_ContainsAppName()
{
await Page.GotoAsync("https://localhost:5001");
await Expect(Page).ToHaveTitleAsync(new Regex("My App"));
}
[Fact]
public async Task HomePage_NavLinks_AreVisible()
{
await Page.GotoAsync("https://localhost:5001");
var nav = Page.Locator("nav");
await Expect(nav.GetByRole(AriaRole.Link, new() { Name = "Home" }))
.ToBeVisibleAsync();
await Expect(nav.GetByRole(AriaRole.Link, new() { Name = "About" }))
.ToBeVisibleAsync();
}
}Manual Setup (Without Base Class)
public class ManualSetupTests : IAsyncLifetime
{
private IPlaywright _playwright = null!;
private IBrowser _browser = null!;
private IBrowserContext _context = null!;
private IPage _page = null!;
public async ValueTask InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
_context = await _browser.NewContextAsync(new BrowserNewContextOptions
{
ViewportSize = new ViewportSize { Width = 1280, Height = 720 },
Locale = "en-US"
});
_page = await _context.NewPageAsync();
}
public async ValueTask DisposeAsync()
{
await _page.CloseAsync();
await _context.CloseAsync();
await _browser.CloseAsync();
_playwright.Dispose();
}
[Fact]
public async Task Login_ValidUser_RedirectsToDashboard()
{
await _page.GotoAsync("https://localhost:5001/login");
await _page.FillAsync("[data-testid='email']", "user@example.com");
await _page.FillAsync("[data-testid='password']", "P@ssw0rd!");
await _page.ClickAsync("[data-testid='login-btn']");
await Expect(_page).ToHaveURLAsync(new Regex("/dashboard"));
}
}---
Locators and Interactions
Recommended Locator Strategies
// BEST: Role-based (accessible and semantic)
var submitBtn = Page.GetByRole(AriaRole.Button, new() { Name = "Submit Order" });
// GOOD: Test ID (stable, explicit)
var emailInput = Page.Locator("[data-testid='email-input']");
// GOOD: Label text (user-visible, accessible)
var nameField = Page.GetByLabel("Full Name");
// GOOD: Placeholder (user-visible)
var searchBox = Page.GetByPlaceholder("Search products...");
// AVOID: CSS class (fragile, changes with styling)
var card = Page.Locator(".card-primary");
// AVOID: XPath (brittle, hard to read)
var cell = Page.Locator("//table/tbody/tr[1]/td[2]");Common Interactions
// Text input
await Page.FillAsync("[data-testid='name']", "Alice Johnson");
// Click
await Page.ClickAsync("[data-testid='submit']");
// Select dropdown
await Page.SelectOptionAsync("[data-testid='country']", "US");
// Checkbox / radio
await Page.CheckAsync("[data-testid='agree-terms']");
// File upload
await Page.SetInputFilesAsync("[data-testid='avatar']", "testdata/photo.jpg");
// Keyboard
await Page.Keyboard.PressAsync("Enter");
await Page.Keyboard.TypeAsync("search query");
// Hover (for dropdowns, tooltips)
await Page.HoverAsync("[data-testid='user-menu']");Assertions (Expect API)
Playwright assertions auto-retry until the condition is met or the timeout expires:
// Element visibility
await Expect(Page.Locator("[data-testid='success']")).ToBeVisibleAsync();
await Expect(Page.Locator("[data-testid='spinner']")).ToBeHiddenAsync();
// Text content
await Expect(Page.Locator("[data-testid='total']")).ToHaveTextAsync("$99.99");
await Expect(Page.Locator("[data-testid='status']")).ToContainTextAsync("Completed");
// Attribute
await Expect(Page.Locator("[data-testid='submit']")).ToBeEnabledAsync();
await Expect(Page.Locator("[data-testid='email']")).ToHaveValueAsync("user@example.com");
// Page-level
await Expect(Page).ToHaveURLAsync(new Regex("/orders/\\d+"));
await Expect(Page).ToHaveTitleAsync("Order Details - My App");
// Count
await Expect(Page.Locator("[data-testid='order-row']")).ToHaveCountAsync(5);---
Network Interception
Mocking API Responses
[Fact]
public async Task OrderList_WithMockedApi_DisplaysOrders()
{
// Intercept API calls and return mock data
await Page.RouteAsync("**/api/orders", async route =>
{
var json = JsonSerializer.Serialize(new[]
{
new { Id = 1, CustomerName = "Alice", Total = 99.99 },
new { Id = 2, CustomerName = "Bob", Total = 149.50 }
});
await route.FulfillAsync(new RouteFulfillOptions
{
Status = 200,
ContentType = "application/json",
Body = json
});
});
await Page.GotoAsync("https://localhost:5001/orders");
await Expect(Page.Locator("[data-testid='order-row']")).ToHaveCountAsync(2);
}Waiting for Network Requests
[Fact]
public async Task CreateOrder_SubmitForm_WaitsForApiResponse()
{
await Page.GotoAsync("https://localhost:5001/orders/new");
await Page.FillAsync("[data-testid='customer']", "Alice");
await Page.FillAsync("[data-testid='amount']", "99.99");
// Wait for the API call triggered by form submission
var responseTask = Page.WaitForResponseAsync(
response => response.Url.Contains("/api/orders") && response.Status == 201);
await Page.ClickAsync("[data-testid='submit']");
var response = await responseTask;
Assert.Equal(201, response.Status);
}---
CI Browser Caching
Downloading browser binaries on every CI run is slow (500MB+). Cache them to speed up builds.
GitHub Actions Caching
# .github/workflows/e2e-tests.yml
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Build
run: dotnet build tests/MyApp.E2E/
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('tests/MyApp.E2E/MyApp.E2E.csproj') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pwsh tests/MyApp.E2E/bin/Debug/net8.0/playwright.ps1 install --with-deps
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pwsh tests/MyApp.E2E/bin/Debug/net8.0/playwright.ps1 install-deps
- name: Run E2E tests
run: dotnet test tests/MyApp.E2E/Azure DevOps Caching
# azure-pipelines.yml
steps:
- task: Cache@2
inputs:
key: 'playwright | "$(Agent.OS)" | tests/MyApp.E2E/MyApp.E2E.csproj'
path: $(HOME)/.cache/ms-playwright
restoreKeys: |
playwright | "$(Agent.OS)"
cacheHitVar: PLAYWRIGHT_CACHE_RESTORED
displayName: Cache Playwright browsers
- script: pwsh tests/MyApp.E2E/bin/Debug/net8.0/playwright.ps1 install --with-deps
condition: ne(variables.PLAYWRIGHT_CACHE_RESTORED, 'true')
displayName: Install Playwright browsers
- script: pwsh tests/MyApp.E2E/bin/Debug/net8.0/playwright.ps1 install-deps
condition: eq(variables.PLAYWRIGHT_CACHE_RESTORED, 'true')
displayName: Install Playwright system deps (cached browsers)
- script: dotnet test tests/MyApp.E2E/
displayName: Run E2E testsCache Key Strategy
The cache key should include:
- OS: Browser binaries are platform-specific
- Project file hash: Playwright version determines browser versions; changing the package version invalidates the cache
- Fallback key: Allows partial cache restoration when the project file changes
---
Trace Viewer
Playwright's trace viewer captures a full recording of test execution for debugging failures. Each trace includes screenshots, DOM snapshots, network logs, and console output.
Enabling Traces
public class TracedTests : IAsyncLifetime
{
private IPlaywright _playwright = null!;
private IBrowser _browser = null!;
private IBrowserContext _context = null!;
public IPage Page { get; private set; } = null!;
public async ValueTask InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync();
_context = await _browser.NewContextAsync();
// Start tracing before each test
await _context.Tracing.StartAsync(new TracingStartOptions
{
Screenshots = true,
Snapshots = true,
Sources = true
});
Page = await _context.NewPageAsync();
}
public async ValueTask DisposeAsync()
{
// Save trace on failure (check test result in xUnit requires custom wrapper)
await _context.Tracing.StopAsync(new TracingStopOptions
{
Path = Path.Combine("test-results", "traces",
$"trace-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zip")
});
await Page.CloseAsync();
await _context.CloseAsync();
await _browser.CloseAsync();
_playwright.Dispose();
}
}Viewing Traces
# Open trace file in browser
pwsh bin/Debug/net8.0/playwright.ps1 show-trace test-results/traces/trace-20260101-120000.zip
# Or use the online trace viewer
# Upload the .zip to https://trace.playwright.dev/Trace on Failure Only
Save traces only when tests fail to reduce storage:
// In a custom test class or middleware
public async Task RunWithTrace(Func<IPage, Task> testAction, string testName)
{
await _context.Tracing.StartAsync(new TracingStartOptions
{
Screenshots = true,
Snapshots = true,
Sources = true
});
try
{
await testAction(Page);
// Test passed -- discard trace
await _context.Tracing.StopAsync();
}
catch
{
// Test failed -- save trace for debugging
await _context.Tracing.StopAsync(new TracingStopOptions
{
Path = $"test-results/traces/{testName}.zip"
});
throw;
}
}---
Codegen
Playwright's code generator records browser interactions and generates test code. Use it to scaffold tests quickly, then refine the generated code.
Running Codegen
# Open codegen with your app URL
pwsh bin/Debug/net8.0/playwright.ps1 codegen https://localhost:5001
# With specific browser
pwsh bin/Debug/net8.0/playwright.ps1 codegen --browser firefox https://localhost:5001
# With device emulation
pwsh bin/Debug/net8.0/playwright.ps1 codegen --device "iPhone 15" https://localhost:5001
# With saved authentication state
pwsh bin/Debug/net8.0/playwright.ps1 codegen --save-storage auth.json https://localhost:5001Codegen Best Practices
1. Use codegen as a starting point, not the final test. Generated code often uses fragile selectors and lacks proper assertions. 2. Replace generated selectors with data-testid or role-based locators immediately after generating. 3. Add meaningful assertions. Codegen records actions but does not know what to verify. Add Expect() calls for expected outcomes. 4. Extract page objects from generated code. Group related interactions into page object methods.
Before and After Codegen Refinement
// GENERATED by codegen (fragile, no assertions):
await page.GotoAsync("https://localhost:5001/orders");
await page.Locator("#root > div > main > div:nth-child(2) > button").ClickAsync();
await page.GetByPlaceholder("Customer name").FillAsync("Alice");
await page.GetByPlaceholder("Amount").FillAsync("99.99");
await page.Locator("form > button[type='submit']").ClickAsync();
// REFINED (stable selectors, proper assertions):
await Page.GotoAsync("https://localhost:5001/orders");
await Page.ClickAsync("[data-testid='new-order-btn']");
await Page.FillAsync("[data-testid='customer-name']", "Alice");
await Page.FillAsync("[data-testid='amount']", "99.99");
await Page.ClickAsync("[data-testid='submit-order']");
await Expect(Page.Locator("[data-testid='success-toast']"))
.ToBeVisibleAsync();
await Expect(Page).ToHaveURLAsync(new Regex("/orders/\\d+"));---
Multi-Browser Testing
Running Tests Across Browsers
// Using Playwright xUnit base class with environment variable
// Set BROWSER=chromium|firefox|webkit via CLI or CI config
public class CrossBrowserTests : PageTest
{
[Fact]
public async Task OrderFlow_WorksAcrossBrowsers()
{
// This test runs in whichever browser BROWSER env var specifies
await Page.GotoAsync("https://localhost:5001/orders/new");
await Page.FillAsync("[data-testid='customer']", "Alice");
await Page.ClickAsync("[data-testid='submit']");
await Expect(Page.Locator("[data-testid='success']")).ToBeVisibleAsync();
}
}# Run tests in each browser
BROWSER=chromium dotnet test
BROWSER=firefox dotnet test
BROWSER=webkit dotnet testCI Matrix Strategy
# GitHub Actions matrix for multi-browser
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- name: Run E2E tests
run: dotnet test tests/MyApp.E2E/
env:
BROWSER: ${{ matrix.browser }}---
Key Principles
- Use Playwright assertions (`Expect`) instead of raw xUnit `Assert`. Playwright assertions auto-retry with configurable timeouts, eliminating flaky timing issues.
- Cache browser binaries in CI. Downloading 500MB+ of browsers per run wastes time and bandwidth. Cache by OS + Playwright version.
- Enable trace viewer for debugging CI failures. Traces capture everything needed to reproduce a failure without re-running the test.
- Use codegen to bootstrap tests, then refine. Generated code gets you started fast; manual refinement makes tests maintainable.
- Prefer role-based or `data-testid` locators over CSS classes or XPath. See [skill:dotnet-testing]
references/ui-testing-core.mdfor the full selector priority guide.
---
Agent Gotchas
1. Do not forget to install browsers after adding the Playwright package. The NuGet package does not include browser binaries. Run the install script after building. 2. Do not use `Task.Delay` for waiting. Playwright's auto-waiting and Expect assertions handle timing automatically. Adding delays makes tests slow and still flaky. 3. Do not hardcode `localhost` ports. Use configuration or environment variables for the base URL. CI environments may use different ports than local development. 4. Do not skip `--with-deps` on first CI install. Playwright browsers need system libraries (libgbm, libasound, etc.) on Linux. The --with-deps flag installs them. Subsequent cached runs only need install-deps. 5. Do not store trace files in the repository. Traces are large binary files. Write them to a test-results/ directory that is git-ignored, and upload them as CI artifacts. 6. Do not create a new browser instance per test. Browser launch is expensive. Use IClassFixture or the Playwright xUnit base class to share a browser across tests in a class. Create a new BrowserContext per test for isolation.
---
References
Slopwatch
Slopwatch: LLM Anti-Cheat Quality Gate for .NET
Run the Slopwatch.Cmd dotnet tool as an automated quality gate after code modifications to detect "slop" -- shortcuts that make builds/tests pass without fixing real problems.
Prerequisites
- .NET 8.0+ SDK
Slopwatch.CmdNuGet package (v0.3.3+)
Cross-references: [skill:dotnet-tooling] references/tool-management.md for general dotnet tool installation mechanics.
---
Installation
Local Tool (Recommended)
Add to .config/dotnet-tools.json:
{
"version": 1,
"isRoot": true,
"tools": {
"slopwatch.cmd": {
"version": "0.3.3",
"commands": ["slopwatch"],
"rollForward": false
}
}
}Then restore:
dotnet tool restoreGlobal Tool
dotnet tool install --global Slopwatch.CmdSee [skill:dotnet-tooling] references/tool-management.md for tool manifest conventions and restore patterns.
---
Usage
Basic Analysis
# Analyze current directory for slop
slopwatch analyze
# Analyze specific directory
slopwatch analyze -d ./src
# Strict mode -- fail on warnings too
slopwatch analyze --fail-on warning
# JSON output for tooling integration
slopwatch analyze --output json
# Show performance stats
slopwatch analyze --statsFirst-Time Setup: Establish a Baseline
For existing projects with pre-existing issues, create a baseline so slopwatch only catches new slop. The init command scans all files and records current findings as the accepted baseline:
slopwatch init
git add .slopwatch/baseline.json
git commit -m "Add slopwatch baseline"Updating the Baseline (Rare)
Only update when slop is truly justified and documented:
slopwatch analyze --update-baselineValid reasons: third-party library forces a pattern, intentional rate-limiting delay (not test flakiness), generated code that cannot be modified. Always add a code comment explaining the justification.
---
Configuration
Create .slopwatch/slopwatch.json to customize rules and exclusions:
{
"minSeverity": "warning",
"rules": {
"SW001": { "enabled": true, "severity": "error" },
"SW002": { "enabled": true, "severity": "warning" },
"SW003": { "enabled": true, "severity": "error" },
"SW004": { "enabled": true, "severity": "warning" },
"SW005": { "enabled": true, "severity": "warning" },
"SW006": { "enabled": true, "severity": "warning" }
},
"exclude": [
"**/Generated/**",
"**/obj/**",
"**/bin/**"
]
}Strict Mode (Recommended for LLM Sessions)
Elevate all rules to errors during LLM coding sessions:
{
"minSeverity": "warning",
"rules": {
"SW001": { "enabled": true, "severity": "error" },
"SW002": { "enabled": true, "severity": "error" },
"SW003": { "enabled": true, "severity": "error" },
"SW004": { "enabled": true, "severity": "error" },
"SW005": { "enabled": true, "severity": "error" },
"SW006": { "enabled": true, "severity": "error" }
}
}---
Detection Rules
| Rule | Severity | What It Catches |
|---|---|---|
| SW001 | Error | Disabled tests (Skip=, Ignore, #if false) |
| SW002 | Warning | Warning suppression (#pragma warning disable, SuppressMessage) |
| SW003 | Error | Empty catch blocks that swallow exceptions |
| SW004 | Warning | Arbitrary delays in tests (Task.Delay, Thread.Sleep) |
| SW005 | Warning | Project file slop (NoWarn, TreatWarningsAsErrors=false) |
| SW006 | Warning | CPM bypass (VersionOverride, inline Version attributes) |
When Slopwatch Flags an Issue
1. Understand why the shortcut was taken 2. Request a proper fix -- be specific about what's wrong 3. Verify the fix doesn't introduce different slop
# Example output
❌ SW001 [Error]: Disabled test detected
File: tests/MyApp.Tests/OrderTests.cs:45
Pattern: [Fact(Skip="Test is flaky")]Never disable tests to achieve a green build. Fix the underlying issue.
---
Claude Code Hook Integration
Add slopwatch as a PostToolUse hook to automatically validate every edit. Create or update .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "slopwatch analyze -d . --hook",
"timeout": 60000
}
]
}
]
}
}The --hook flag:
- Only analyzes git dirty files (fast, even on large repos)
- Outputs errors to stderr in readable format
- Blocks the edit on warnings/errors (exit code 2)
- Claude sees the error and can fix it immediately
This is the pattern used by projects like BrighterCommand/Brighter.
---
CI/CD Integration
GitHub Actions
jobs:
slopwatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x' # any .NET 8+ SDK works
- name: Install Slopwatch
run: dotnet tool install --global Slopwatch.Cmd
- name: Run Slopwatch
run: slopwatch analyze -d . --fail-on warningAzure Pipelines
- task: DotNetCoreCLI@2
displayName: 'Install Slopwatch'
inputs:
command: 'custom'
custom: 'tool'
arguments: 'install --global Slopwatch.Cmd'
- script: slopwatch analyze -d . --fail-on warning
displayName: 'Slopwatch Analysis'---
Agent Gotchas
- Do not suppress slopwatch findings. If slopwatch flags an issue, fix the code -- do not update the baseline or disable the rule without explicit user approval.
- Run after every code change, not just at the end. Catching slop early prevents cascading shortcuts.
- Use `--hook` flag in Claude Code hooks, not bare
analyze. The hook flag restricts analysis to dirty files for performance. - Baseline is not a wastebasket. Adding items to the baseline requires documented justification. Never bulk-update baseline to silence warnings.
- Local tool preferred over global. Use
.config/dotnet-tools.jsonso the version is pinned and reproducible across team members.
---
Quick Reference
# First time setup
slopwatch init
git add .slopwatch/baseline.json
# After every code change
slopwatch analyze
# Strict mode (recommended)
slopwatch analyze --fail-on warning
# Hook mode (for Claude Code integration)
slopwatch analyze -d . --hook
# JSON output for tooling
slopwatch analyze --output json
# Update baseline (rare, document why)
slopwatch analyze --update-baseline---
References
- Slopwatch NuGet Package
- [skill:dotnet-tooling]
references/tool-management.md-- dotnet tool installation and manifest conventions - [skill:dotnet-api]
references/agent-gotchas.md-- manual slop pattern recognition (visual detection counterpart) - [skill:dotnet-testing]
references/test-quality.md-- test coverage and quality measurement
Snapshot Testing
Snapshot (approval) testing with the Verify library for .NET. Covers verifying API responses, serialized objects, rendered emails, and other complex outputs by comparing them against approved baseline files. Includes scrubbing and filtering patterns to handle non-deterministic values (dates, GUIDs, timestamps), custom converters for domain-specific types, and strategies for organizing and reviewing snapshot files.
Version assumptions: Verify 20.x+ (.NET 8.0+ baseline). Examples use the Verify.Xunit integration package; equivalent packages exist for NUnit (Verify.NUnit) and MSTest (Verify.MSTest). Verify auto-discovers the test framework from the referenced package.
Setup
Packages
<PackageReference Include="Verify.Xunit" Version="20.*" />
<!-- For HTTP response verification -->
<PackageReference Include="Verify.Http" Version="6.*" />Module Initializer
Verify requires a one-time initialization per test assembly. Place this in a file at the root of your test project:
// ModuleInitializer.cs
using System.Runtime.CompilerServices;
public static class ModuleInitializer
{
[ModuleInitializer]
public static void Init() =>
VerifySourceGenerators.Initialize();
}Source Control
Add to .gitignore:
# Verify received files (test failures)
*.received.*Add to .gitattributes so verified files diff cleanly:
*.verified.txt text eol=lf
*.verified.xml text eol=lf
*.verified.json text eol=lf---
Basic Usage
Verifying Objects
Verify serializes the object to JSON and compares against a .verified.txt file:
[UsesVerify]
public class OrderSerializationTests
{
[Fact]
public Task Serialize_CompletedOrder_MatchesSnapshot()
{
var order = new Order
{
Id = 1,
CustomerId = "cust-123",
Status = OrderStatus.Completed,
Items =
[
new OrderItem("SKU-001", Quantity: 2, UnitPrice: 29.99m),
new OrderItem("SKU-002", Quantity: 1, UnitPrice: 49.99m)
],
Total = 109.97m
};
return Verify(order);
}
}First run creates OrderSerializationTests.Serialize_CompletedOrder_MatchesSnapshot.verified.txt:
{
Id: 1,
CustomerId: cust-123,
Status: Completed,
Items: [
{
Sku: SKU-001,
Quantity: 2,
UnitPrice: 29.99
},
{
Sku: SKU-002,
Quantity: 1,
UnitPrice: 49.99
}
],
Total: 109.97
}Verifying Strings and Streams
[Fact]
public Task RenderInvoice_MatchesExpectedHtml()
{
var html = invoiceRenderer.Render(order);
return Verify(html, extension: "html");
}
[Fact]
public Task ExportReport_MatchesExpectedXml()
{
var stream = reportExporter.Export(report);
return Verify(stream, extension: "xml");
}---
Scrubbing and Filtering
Non-deterministic values (dates, GUIDs, auto-incremented IDs) change between test runs. Scrubbing replaces them with stable placeholders so snapshots remain comparable.
Built-In Scrubbers
Verify includes scrubbers for common non-deterministic types that are active by default:
[Fact]
public Task CreateOrder_ScrubsNonDeterministicValues()
{
var order = new Order
{
Id = Guid.NewGuid(), // Scrubbed to Guid_1
CreatedAt = DateTime.UtcNow, // Scrubbed to DateTime_1
TrackingNumber = Guid.NewGuid().ToString() // Scrubbed to Guid_2
};
return Verify(order);
}Produces stable output:
{
Id: Guid_1,
CreatedAt: DateTime_1,
TrackingNumber: Guid_2
}Custom Scrubbers
When built-in scrubbing is not sufficient, add custom scrubbers:
[Fact]
public Task AuditLog_ScrubsTimestampsAndMachineNames()
{
var log = auditService.GetRecentEntries();
return Verify(log)
.ScrubLinesWithReplace(line =>
Regex.Replace(line, @"Machine:\s+\w+", "Machine: Scrubbed"))
.ScrubLinesContaining("CorrelationId:");
}Ignoring Members
Exclude specific properties from verification:
[Fact]
public Task OrderSnapshot_IgnoresVolatileFields()
{
var order = orderService.CreateOrder(request);
return Verify(order)
.IgnoreMember("CreatedAt")
.IgnoreMember("UpdatedAt")
.IgnoreMember("ETag");
}Or ignore by type across all verifications:
// In ModuleInitializer
[ModuleInitializer]
public static void Init()
{
VerifierSettings.IgnoreMembersWithType<DateTime>();
VerifierSettings.IgnoreMembersWithType<DateTimeOffset>();
}Scrubbing Inline Values
Replace specific patterns in the serialized output:
[Fact]
public Task ApiResponse_ScrubsTokens()
{
var response = authService.GenerateTokenResponse(user);
return Verify(response)
.ScrubLinesWithReplace(line =>
Regex.Replace(line, @"Bearer [A-Za-z0-9\-._~+/]+=*", "Bearer {scrubbed}"));
}---
Verifying HTTP Responses
Verify HTTP responses from WebApplicationFactory integration tests to lock down API contracts.
Setup
<PackageReference Include="Verify.Http" Version="6.*" />Verifying Full HTTP Responses
[UsesVerify]
public class OrdersApiSnapshotTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersApiSnapshotTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetOrders_ResponseMatchesSnapshot()
{
var response = await _client.GetAsync("/api/orders");
await Verify(response);
}
}The verified file captures status code, headers, and body:
{
Status: 200 OK,
Headers: {
Content-Type: application/json; charset=utf-8
},
Body: [
{
Id: 1,
CustomerId: cust-123,
Status: Pending,
Total: 109.97
}
]
}Verifying Specific Response Parts
[Fact]
public async Task CreateOrder_VerifyResponseBody()
{
var response = await _client.PostAsJsonAsync("/api/orders", request);
var body = await response.Content.ReadFromJsonAsync<OrderDto>();
await Verify(body)
.IgnoreMember("Id")
.IgnoreMember("CreatedAt");
}---
Verifying Rendered Emails
Snapshot-test email templates by verifying the rendered HTML output:
[UsesVerify]
public class EmailTemplateTests
{
private readonly EmailRenderer _renderer = new();
[Fact]
public Task OrderConfirmation_MatchesSnapshot()
{
var model = new OrderConfirmationModel
{
CustomerName = "Alice Johnson",
OrderNumber = "ORD-001",
Items =
[
new("Widget A", Quantity: 2, Price: 29.99m),
new("Widget B", Quantity: 1, Price: 49.99m)
],
Total = 109.97m
};
var html = _renderer.RenderOrderConfirmation(model);
return Verify(html, extension: "html");
}
[Fact]
public Task PasswordReset_MatchesSnapshot()
{
var model = new PasswordResetModel
{
UserName = "alice",
ResetLink = "https://example.com/reset?token=test-token"
};
var html = _renderer.RenderPasswordReset(model);
return Verify(html, extension: "html")
.ScrubLinesWithReplace(line =>
Regex.Replace(line, @"token=[^""&]+", "token={scrubbed}"));
}
}---
Custom Converters
Custom converters control how specific types are serialized for verification. Use them for domain types that need a readable, stable representation.
Writing a Custom Converter
public class MoneyConverter : WriteOnlyJsonConverter<Money>
{
public override void Write(VerifyJsonWriter writer, Money value)
{
writer.WriteStartObject();
writer.WriteMember(value, value.Amount, "Amount");
writer.WriteMember(value, value.Currency.Code, "Currency");
writer.WriteEndObject();
}
}Register in the module initializer:
[ModuleInitializer]
public static void Init()
{
VerifierSettings.AddExtraSettings(settings =>
settings.Converters.Add(new MoneyConverter()));
}Converter for Complex Domain Types
public class AddressConverter : WriteOnlyJsonConverter<Address>
{
public override void Write(VerifyJsonWriter writer, Address value)
{
// Single-line summary for compact snapshots
writer.WriteValue($"{value.Street}, {value.City}, {value.State} {value.Zip}");
}
}
public class DateRangeConverter : WriteOnlyJsonConverter<DateRange>
{
public override void Write(VerifyJsonWriter writer, DateRange value)
{
writer.WriteStartObject();
writer.WriteMember(value, value.Start.ToString("yyyy-MM-dd"), "Start");
writer.WriteMember(value, value.End.ToString("yyyy-MM-dd"), "End");
writer.WriteMember(value, value.Duration.Days, "DurationDays");
writer.WriteEndObject();
}
}Usage in tests:
[Fact]
public Task Customer_WithAddress_MatchesSnapshot()
{
var customer = new Customer
{
Name = "Alice Johnson",
Address = new Address("123 Main St", "Springfield", "IL", "62701"),
MemberSince = new DateRange(
new DateTime(2020, 1, 15),
new DateTime(2025, 1, 15))
};
return Verify(customer);
}Produces:
{
Name: Alice Johnson,
Address: 123 Main St, Springfield, IL 62701,
MemberSince: {
Start: 2020-01-15,
End: 2025-01-15,
DurationDays: 1827
}
}---
Snapshot File Organization
Default Naming
Verify names snapshot files based on the test class and method:
TestClassName.MethodName.verified.txtFiles are placed next to the test source file by default.
Unique Directory
Move verified files into a dedicated directory to reduce clutter:
// ModuleInitializer.cs
[ModuleInitializer]
public static void Init()
{
Verifier.DerivePathInfo(
(sourceFile, projectDirectory, type, method) =>
new PathInfo(
directory: Path.Combine(projectDirectory, "Snapshots"),
typeName: type.Name,
methodName: method.Name));
}Parameterized Tests
For [Theory] tests, Verify appends parameter values to the file name:
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("ja-JP")]
public Task FormatCurrency_ByLocale_MatchesSnapshot(string locale)
{
var formatted = currencyFormatter.Format(1234.56m, locale);
return Verify(formatted)
.UseParameters(locale);
}Creates separate files:
FormatCurrencyTests.FormatCurrency_ByLocale_MatchesSnapshot_locale=en-US.verified.txt
FormatCurrencyTests.FormatCurrency_ByLocale_MatchesSnapshot_locale=de-DE.verified.txt
FormatCurrencyTests.FormatCurrency_ByLocale_MatchesSnapshot_locale=ja-JP.verified.txt---
Workflow: Accepting Changes
When a snapshot test fails, Verify creates a .received.txt file alongside the .verified.txt file. Review the diff and accept or reject:
Diff Tool Integration
Verify launches a diff tool automatically when a test fails. Configure the preferred tool:
[ModuleInitializer]
public static void Init()
{
// Verify auto-detects installed diff tools
// Override if needed:
DiffTools.UseOrder(DiffTool.VisualStudioCode, DiffTool.Rider);
}CLI Acceptance
Install the Verify CLI tool (one-time setup), then accept pending changes after review:
# Install the Verify CLI tool (one-time)
dotnet tool install -g verify.tool
# Accept all received files in the solution
verify accept
# Accept for a specific test project
verify accept --project tests/MyApp.TestsCI Behavior
In CI, Verify should fail tests without launching a diff tool. Set the environment variable:
env:
DiffEngine_Disabled: trueOr in the module initializer:
[ModuleInitializer]
public static void Init()
{
if (Environment.GetEnvironmentVariable("CI") is not null)
{
DiffRunner.Disabled = true;
}
}---
Key Principles
- Snapshot test complex outputs, not simple values. If the expected value fits in a single
Assert.Equal, prefer that over a snapshot. Snapshots shine for multi-field objects, API responses, and rendered content. - Scrub all non-deterministic values. Dates, GUIDs, timestamps, and machine-specific values must be scrubbed or ignored. Unscrubbed snapshots cause flaky tests.
- Commit `.verified.txt` files to source control. These are the approved baselines. Never add
.received.txtfiles -- they represent unapproved changes. - Review snapshot diffs carefully. Accepting a snapshot change without review can silently approve regressions. Treat snapshot diffs like code review.
- Use custom converters for domain readability. Default JSON serialization may be verbose or unclear for domain types. Converters produce focused, human-readable snapshots.
- Keep snapshots focused. Verify only the parts that matter. Use
IgnoreMemberto exclude volatile or irrelevant fields rather than verifying the entire object graph.
---
Agent Gotchas
1. Do not forget `[UsesVerify]` on the test class. Without this attribute, Verify() calls compile but fail at runtime with an initialization error. Every test class using Verify must have this attribute. 2. Do not commit `.received.txt` files. These represent test failures and unapproved changes. Add *.received.* to .gitignore to prevent accidental commits. 3. Do not skip `UseParameters()` in parameterized tests. Without it, all parameter combinations write to the same snapshot file, overwriting each other. Always call UseParameters() with the theory data values. 4. Do not scrub values that are part of the contract. If an API always returns a specific date format or a known GUID, verify those values rather than scrubbing them. Only scrub values that are genuinely non-deterministic between runs. 5. Do not use snapshot testing for rapidly evolving APIs. During early development when the API shape changes frequently, snapshot tests create excessive churn. Wait until the API stabilizes. 6. Do not hardcode Verify package versions across different test frameworks. Verify.Xunit, Verify.NUnit, and Verify.MSTest have independent version lines. Always use version ranges (e.g., 20.*) rather than pinning to a specific version.
---
References
xUnit
xUnit v3 testing framework features for .NET. Covers [Fact] and [Theory] attributes, test fixtures (IClassFixture, ICollectionFixture), parallel execution configuration, IAsyncLifetime for async setup/teardown, custom assertions, and xUnit analyzers.
Version assumptions: xUnit v3 primary (.NET 8.0+ baseline). Where v3 behavior differs from v2, compatibility notes are provided inline.
Facts and Theories
[Fact] -- Single Test Case
Use [Fact] for tests with no parameters:
public class DiscountCalculatorTests
{
[Fact]
public void Apply_NegativePercentage_ThrowsArgumentOutOfRangeException()
{
var calculator = new DiscountCalculator();
var ex = Assert.Throws<ArgumentOutOfRangeException>(
() => calculator.Apply(100m, percentage: -5));
Assert.Equal("percentage", ex.ParamName);
}
}[Theory] -- Parameterized Tests
Use [Theory] to run the same test logic with different inputs.
[InlineData]
Best for simple value types:
[Theory]
[InlineData(100, 10, 90)]
[InlineData(200, 25, 150)]
[InlineData(50, 0, 50)]
public void Apply_VariousInputs_ReturnsExpectedPrice(
decimal price, decimal percentage, decimal expected)
{
var calculator = new DiscountCalculator();
Assert.Equal(expected, calculator.Apply(price, percentage));
}[MemberData] with TheoryData<T>
Best for complex data or shared datasets:
public class OrderValidatorTests
{
public static TheoryData<Order, bool> ValidationCases => new()
{
{ new Order { Items = [new("SKU-1", 1)], CustomerId = "C1" }, true },
{ new Order { Items = [], CustomerId = "C1" }, false },
};
[Theory]
[MemberData(nameof(ValidationCases))]
public void IsValid_VariousOrders_ReturnsExpected(Order order, bool expected)
{
Assert.Equal(expected, new OrderValidator().IsValid(order));
}
}[ClassData] (xUnit v3)
For data shared across multiple test classes. v3 uses TheoryDataRow<T> for strongly-typed rows (v2 used IEnumerable<object[]>):
public class CurrencyConversionData : IEnumerable<TheoryDataRow<string, string, decimal>>
{
public IEnumerator<TheoryDataRow<string, string, decimal>> GetEnumerator()
{
yield return new("USD", "EUR", 0.92m);
yield return new("GBP", "USD", 1.27m);
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
[Theory]
[ClassData(typeof(CurrencyConversionData))]
public void Convert_KnownPairs_ReturnsExpectedRate(
string from, string to, decimal expectedRate)
{
Assert.Equal(expectedRate, new CurrencyConverter().GetRate(from, to), precision: 2);
}---
Fixtures: Shared Setup and Teardown
IClassFixture<T> -- Shared Per Test Class
Use when multiple tests in the same class share an expensive resource:
public class DatabaseFixture : IAsyncLifetime
{
public string ConnectionString { get; private set; } = "";
// xUnit v3: IAsyncLifetime returns ValueTask (v2 returns Task)
public ValueTask InitializeAsync()
{
ConnectionString = $"Host=localhost;Database=test_{Guid.NewGuid():N}";
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
public class OrderRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _db;
public OrderRepositoryTests(DatabaseFixture db) => _db = db;
[Fact]
public async Task GetById_ExistingOrder_ReturnsOrder()
{
var repo = new OrderRepository(_db.ConnectionString);
Assert.NotNull(await repo.GetByIdAsync(KnownOrderId));
}
}ICollectionFixture<T> -- Shared Across Test Classes
Use when multiple test classes need the same expensive resource:
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
[Collection("Database")]
public class OrderRepositoryTests(DatabaseFixture db)
{
[Fact]
public async Task Insert_ValidOrder_Persists() { /* uses db */ }
}
[Collection("Database")]
public class CustomerRepositoryTests(DatabaseFixture db) { }---
Parallel Execution
xUnit runs test classes within a collection sequentially but runs different collections in parallel. Each test class without an explicit [Collection] is its own implicit collection.
Disable Parallelism for Specific Tests
[CollectionDefinition("Sequential", DisableParallelization = true)]
public class SequentialCollection { }
[Collection("Sequential")]
public class StatefulServiceTests { /* runs sequentially */ }Assembly-Level Configuration
Create xunit.runner.json in the test project root (copy to output via <Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />):
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": false,
"parallelizeTestCollections": true,
"maxParallelThreads": 4
}---
Custom Assertions and Assert.Multiple
Create domain-specific assertions for cleaner test code:
public static class OrderAssert
{
public static void HasStatus(Order order, OrderStatus expected)
{
Assert.NotNull(order);
if (order.Status != expected)
throw Xunit.Sdk.EqualException.ForMismatchedValues(expected, order.Status);
}
}Use Assert.Multiple (xUnit v3 only) to evaluate all assertions even if one fails:
[Fact]
public void CreateOrder_ValidRequest_SetsAllProperties()
{
var order = OrderFactory.Create(request);
Assert.Multiple(
() => Assert.Equal("cust-123", order.CustomerId),
() => Assert.Equal(OrderStatus.Pending, order.Status),
() => Assert.NotEqual(Guid.Empty, order.Id)
);
}---
xUnit Analyzers
The xunit.analyzers package (included with xUnit v3) catches common mistakes at compile time. Key rules:
| Rule | What it catches |
|---|---|
xUnit1025 | Duplicate [InlineData] within a [Theory] |
xUnit2000 | Constants should be the expected (first) argument in Assert.Equal |
xUnit2013 | Do not use equality check to verify collection size (use Assert.Single, Assert.Empty) |
Suppress per-project in .editorconfig:
[tests/**.cs]
dotnet_diagnostic.xUnit1004.severity = suggestion---
Key Principles
- One fact per `[Fact]`, one concept per `[Theory]`. Split fundamentally different scenarios into separate methods.
- Use `IClassFixture` for expensive shared resources within a class,
ICollectionFixturewhen multiple classes share the same resource. - Do not disable parallelism globally. Group tests sharing mutable state into named collections instead.
- Use `IAsyncLifetime` for async setup/teardown instead of constructors and
IDisposable. - Keep test data close to the test. Prefer
[InlineData]for simple cases,[MemberData]/[ClassData]only when data is complex or shared.
---
Agent Gotchas
1. Do not use constructor-injected `ITestOutputHelper` in static methods. It is per-test-instance; store in an instance field. 2. Fixture classes must be `public` with a public parameterless constructor (or IAsyncLifetime). Non-public fixtures cause silent failures. 3. Do not mix `[Fact]` and `[Theory]` on the same method. A method is either a fact or a theory. 4. Async test methods must return `Task` or `ValueTask`, never `async void`. async void tests report false success. 5. `[Collection]` without a matching `[CollectionDefinition]` silently creates an implicit collection with default behavior.
---