ApprovalTests.Net and Verify: Snapshot Testing in C#
The .NET ecosystem has two mature approval/snapshot testing libraries: ApprovalTests.Net (the original) and Verify (the modern alternative with better async support and scrubbing). Both capture output to files and compare on subsequent runs. This guide covers both libraries with practical C# examples for NUnit and xUnit.
Key Takeaways
Verify is the modern choice for new projects. Better async support, built-in scrubbers for timestamps and GUIDs, and cleaner xUnit/NUnit integration than ApprovalTests.Net.
ApprovalTests.Net has a larger library of reporters. BeyondCompare, WinMerge, and other diff tool integrations are better supported in ApprovalTests.Net.
Approved files go in source control. Both libraries create .approved.txt (ApprovalTests) or .verified.txt (Verify) files alongside your test files. Commit these.
Use scrubbers for non-deterministic data. Verify has built-in scrubbers for DateTime, Guid, and common patterns. ApprovalTests.Net requires custom string manipulation. Always scrub before approving.
Initial test run always fails. The first run creates the "received" file. You review it, copy to "approved"/"verified", and re-run. This is by design.
Choosing Between ApprovalTests.Net and Verify
Both libraries implement the same pattern but with different APIs and ecosystems:
| Feature | ApprovalTests.Net | Verify |
|---|---|---|
| NuGet package | ApprovalTests |
Verify.Xunit / Verify.NUnit |
| File extension | .approved.txt |
.verified.txt |
| Async support | Limited | First-class |
| Built-in scrubbers | None | DateTime, Guid, IDs |
| Diff tool integration | Many | Via DiffEngine |
| Activity | Mature, slower updates | Actively developed |
For new projects, start with Verify. For projects already using ApprovalTests.Net, stay with it unless you have a specific reason to migrate.
ApprovalTests.Net Setup
<!-- Install via NuGet -->
<PackageReference Include="ApprovalTests" Version="5.9.0" />
<PackageReference Include="ApprovalTests.NUnit" Version="5.9.0" />Basic Usage with NUnit
// OrderApprovalTests.cs
using ApprovalTests;
using ApprovalTests.Reporters;
using NUnit.Framework;
using System.Text.Json;
[TestFixture]
[UseReporter(typeof(DiffReporter))]
public class OrderApprovalTests
{
[Test]
public void ApproveOrderSummary()
{
var order = new Order
{
Id = "ORD-001",
Customer = "Alice",
Items = new[] { new Item("Widget", 2, 14.99m) },
Total = 29.98m
};
var summary = OrderFormatter.FormatSummary(order);
Approvals.Verify(summary);
// Creates: OrderApprovalTests.ApproveOrderSummary.approved.txt
}
[Test]
public void ApproveOrderAsJson()
{
var order = BuildTestOrder();
var json = JsonSerializer.Serialize(order, new JsonSerializerOptions
{
WriteIndented = true
});
Approvals.Verify(json);
}
}On first run, the test fails and creates:
tests/OrderApprovalTests.ApproveOrderSummary.received.txtReview the file. If it looks correct, copy it to:
tests/OrderApprovalTests.ApproveOrderSummary.approved.txtCommit both to source control. Re-run — the test passes.
Approving Combinations
ApprovalTests.Net has a powerful combination approver for testing multiple input combinations:
[Test]
public void ApprovePricingForAllTiers()
{
var customers = new[] { "standard", "premium", "enterprise" };
var quantities = new[] { 1, 10, 100 };
ApprovalTests.Combination.CombinationApprovals.VerifyAllCombinations(
(customer, qty) => CalculatePrice(customer, qty),
customers,
quantities
);
}This generates a grid of all combinations and stores the entire grid in one approved file — excellent for pricing tables, permission matrices, and rule engines.
Verify Setup
<PackageReference Include="Verify.Xunit" Version="23.0.0" />
<!-- Or for NUnit: -->
<PackageReference Include="Verify.NUnit" Version="23.0.0" />Verify requires a one-time module initializer:
// VerifyInitializer.cs (one per project)
[ModuleInitializer]
public static class VerifyInitializer
{
[ModuleInitializer]
public static void Init() => VerifierSettings.InitializePlugins();
}Basic Usage with xUnit
using VerifyXunit;
[UsesVerify]
public class OrderVerifyTests
{
[Fact]
public async Task VerifyOrderSummary()
{
var order = new Order
{
Id = "ORD-001",
Customer = "Alice",
Total = 29.98m
};
await Verify(order);
// Creates: OrderVerifyTests.VerifyOrderSummary.verified.txt
}
[Fact]
public async Task VerifyOrderAsJson()
{
var result = OrderService.Process(BuildTestOrder());
await Verify(result);
}
}Verify automatically serializes objects to JSON. You don't need to serialize manually.
Scrubbers for Non-Deterministic Data
The killer feature of Verify is built-in scrubbing:
[Fact]
public async Task VerifyOrderWithTimestamp()
{
var result = OrderService.CreateOrder(new OrderRequest
{
CustomerId = "c1",
Items = new[] { new Item("Widget", 1, 9.99m) }
});
// result.CreatedAt = DateTime.Now — non-deterministic!
await Verify(result)
.ScrubMember("CreatedAt") // Replace with <CreatedAt>
.ScrubMember("OrderId"); // Replace with <OrderId>
}Or configure globally:
// In VerifyInitializer
VerifierSettings.AddExtraSettings(settings =>
{
settings.Converters.Add(new OrderIdScrubber());
});
// Or use built-in date scrubbing
VerifierSettings.ScrubInlineGuids();
VerifierSettings.ScrubInlineDateTimes();Testing HTTP Responses with Verify
[UsesVerify]
public class ApiVerifyTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ApiVerifyTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task VerifyGetOrdersResponse()
{
var response = await _client.GetAsync("/api/orders");
var content = await response.Content.ReadAsStringAsync();
await Verify(new
{
StatusCode = (int)response.StatusCode,
Body = content
})
.ScrubLinesContaining("\"id\":") // Scrub generated IDs
.ScrubLinesContaining("\"createdAt\":"); // Scrub timestamps
}
}Approving Files Efficiently
CLI Approval (Cross-Platform)
# Install the Verify.Net.Tool globally
dotnet tool install -g verify.tool
<span class="hljs-comment"># After running tests, approve all received files:
verify approve --pattern <span class="hljs-string">"**/*.received.txt"
<span class="hljs-comment"># Or ApprovalTests equivalent — copy received to approved:
<span class="hljs-keyword">for file <span class="hljs-keyword">in **/*.received.txt; <span class="hljs-keyword">do
<span class="hljs-built_in">cp <span class="hljs-string">"$file" <span class="hljs-string">"${file/.received./.approved.}"
<span class="hljs-keyword">doneInline Snapshots with Verify
For small outputs, use inline snapshots instead of files:
[Fact]
public async Task VerifySmallOutput()
{
var result = GetSimpleValue();
await Verify(result)
.UseTextForParameters("inline")
.ToMatchSnapshot("""
{
"value": 42,
"status": "ok"
}
""");
}Combining with Test Data Builders
Approval tests work best when paired with builder patterns for test data:
public class OrderBuilder
{
private string _customerId = "test-customer";
private List<Item> _items = new();
private string _discountCode = null;
public OrderBuilder WithCustomer(string customerId)
{
_customerId = customerId;
return this;
}
public OrderBuilder WithItem(string productId, int qty, decimal price)
{
_items.Add(new Item(productId, qty, price));
return this;
}
public OrderBuilder WithDiscount(string code)
{
_discountCode = code;
return this;
}
public Order Build() => new Order
{
CustomerId = _customerId,
Items = _items,
DiscountCode = _discountCode
};
}
// In tests:
[Fact]
public async Task VerifyDiscountedOrder()
{
var order = new OrderBuilder()
.WithCustomer("premium-user")
.WithItem("widget", 2, 19.99m)
.WithDiscount("PREMIUM20")
.Build();
var result = OrderService.Process(order);
await Verify(result).ScrubMember("ProcessedAt");
}File Organization
Both libraries create test data files alongside your tests by default. Recommended structure:
tests/
OrderTests/
OrderApprovalTests.cs
OrderApprovalTests.ApproveOrderSummary.approved.txt
OrderApprovalTests.ApproveCombinations.approved.txt
OrderVerifyTests/
OrderVerifyTests.cs
OrderVerifyTests.VerifyOrderSummary.verified.txtAdd received files to .gitignore:
*.received.txtNever commit .received.txt — only .approved.txt and .verified.txt.
CI Configuration
Approval tests should fail loudly in CI when output changes:
# .github/workflows/test.yml
- name: Run approval tests
run: dotnet test --configuration Release
# If approved files are committed and output changes,
# the test fails and shows the diff in CI outputOn CI, no diff tool opens (DiffReporter falls back to file output). Configure a CI-friendly reporter:
// For CI environments
[UseReporter(typeof(ClipboardReporter))] // Outputs diff to console
// Or:
[UseReporter(typeof(QuietReporter))] // Just fails without opening a toolSummary
Both ApprovalTests.Net and Verify implement the approval testing pattern in C# with good tooling:
- Verify for new projects: better async, built-in scrubbers, active development
- ApprovalTests.Net for existing codebases or if you need specific diff tool reporters
- Approved files belong in source control — they are specifications
- Scrub non-deterministic fields before approving to avoid flaky tests
- Use combination approvers (ApprovalTests) or parameterized tests for input grids
Start with a single approval test for your most complex output format. That first approved file will show you immediately whether the pattern fits your codebase.