Ergonomic .NET unit testing — xUnit 3, NSubstitute, AutoFixture, and FluentAssertions in one package.
Cabazure.Test bundles xUnit 3, NSubstitute, AutoFixture, and FluentAssertions into a single, pre-configured testing package. One NuGet reference and you're writing expressive tests immediately — no per-project wiring required.
Several things set it apart from wiring up the four libraries yourself:
- Zero-boilerplate theory data —
[AutoNSubstituteData]generates all test parameters automatically. Interfaces and abstract classes become NSubstitute substitutes; noSubstitute.For<T>()calls needed. [Frozen]auto-wiring — a[Frozen]parameter is shared across all subsequent parameters in the same test, so the same substitute flows into your SUT and is available forReceived()verification.- Explicit project-wide customization — register fixture configuration once in a
[ModuleInitializer]. No assembly scanning or reflection-based discovery — startup is deterministic and fast. - Batteries included — built-in customizations for
ImmutableArray<T>,DateOnly,JsonElement,JsonSerializerOptions, andCancellationToken. Works out of the box. - Extended assertions — FluentAssertions extensions for JSON, XML, and string-similarity comparison, plus configurable
DateTimeOffsetprecision.
Spiritual successor to Atc.Test, rebuilt from the ground up for xUnit 3.
dotnet add package Cabazure.Test
The package includes all dependencies needed for testing:
- xUnit 3 — test framework
- AutoFixture & AutoFixture.Xunit3 — fixture generation and
[Frozen]attribute - NSubstitute — automatic mocking
- FluentAssertions — assertion library
No additional packages are required to get started.
FixtureFactory.Create() returns a fully configured IFixture with NSubstitute auto-substitution. Call fixture.Freeze<T>() to register a shared instance before creating your SUT.
using AutoFixture;
using Cabazure.Test;
using FluentAssertions;
using NSubstitute;
using Xunit;
public class OrderServiceTests
{
[Fact]
public void ProcessOrder_CallsRepository_WithOrder()
{
// Arrange
var fixture = FixtureFactory.Create();
var repository = fixture.Freeze<IOrderRepository>(); // NSubstitute substitute, shared with SUT
var sut = fixture.Create<OrderService>(); // OrderService(IOrderRepository) auto-wired
var order = fixture.Create<Order>();
// Act
sut.ProcessOrder(order);
// Assert
repository.Received(1).Save(order);
}
}For theory-driven tests, [AutoNSubstituteData] resolves all parameters from a fixture. Mark upstream dependencies with [Frozen] so they are registered before the SUT is constructed — the same instance flows through to the SUT's constructor.
using AutoFixture.Xunit3;
using FluentAssertions;
using NSubstitute;
using Xunit;
public class NotificationServiceTests
{
[Theory, AutoNSubstituteData]
public void SendWelcome_PublishesMessage_ToEmailSender(
[Frozen] IEmailSender emailSender, // frozen first — injected into sut
NotificationService sut, // created with emailSender already wired
string recipientAddress) // random test data from AutoFixture
{
// Act
sut.SendWelcome(recipientAddress);
// Assert
emailSender.Received(1).Send(Arg.Is<Email>(e => e.To == recipientAddress));
}
}Tip: Parameters are resolved left to right. Place
[Frozen]parameters before any types that depend on them.
Combine explicit inline values with auto-generated parameters. Inline values fill leading parameters; the rest are resolved from the fixture.
using AutoFixture.Xunit3;
using FluentAssertions;
using NSubstitute;
using Xunit;
public class DiscountServiceTests
{
[Theory]
[InlineAutoNSubstituteData("SAVE10", 0.10)]
[InlineAutoNSubstituteData("SAVE20", 0.20)]
public void Apply_CalculatesCorrectDiscount(
string couponCode,
double rate,
[Frozen] IDiscountRepository repository,
DiscountService sut)
{
repository.GetRate(couponCode).Returns(rate);
sut.Apply(couponCode).Should().Be(rate);
}
}Register a customization once for the whole test assembly using [ModuleInitializer]. It is applied to every fixture created by any data attribute.
using System.Runtime.CompilerServices;
internal static class TestAssemblyInitializer
{
[ModuleInitializer]
public static void Initialize()
=> FixtureFactory.Customizations.Add(new MyDomainCustomization());
}[ModuleInitializer] is built into .NET 5 and later. All runtimes compatible with Cabazure.Test (.NET 6–10+) include it, so no extra setup is needed in practice. If you are in the unusual position of targeting .NET Core 3.1 — the only netstandard2.1-compatible runtime that predates .NET 5 — you can define the attribute yourself with a C# 9+ compiler; the compiler emits proper module-initializer IL and the CLR will call the method automatically:
// Only needed for .NET Core 3.1 (corner case)
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
internal sealed class ModuleInitializerAttribute : Attribute { }
}Apply a customization to a single test method or an entire test class without touching the global registry.
using AutoFixture.Xunit3;
using Cabazure.Test;
using FluentAssertions;
using NSubstitute;
using Xunit;
[CustomizeWith(typeof(MyProjectCustomization))]
public class PaymentServiceTests
{
[Theory, AutoNSubstituteData]
[CustomizeWith(typeof(MyTestCustomization))]
public void Charge_InvokesGateway([Frozen] IPaymentGateway gateway, PaymentService sut)
{
sut.Charge(100m);
gateway.Received(1).Process(100m);
}
}The [Frozen] attribute comes from the AutoFixture.Xunit3 package, which is a transitive dependency of Cabazure.Test. When you mark a parameter with [Frozen] in a theory test, AutoFixture registers that instance in the fixture before resolving subsequent parameters, ensuring the same instance is injected everywhere it is needed.
Add the using directive:
using AutoFixture.Xunit3;[Theory, AutoNSubstituteData]
public void Process_UsesRepository(
[Frozen] IRepository repo, // Frozen first — registered in the fixture
Service service) // Created with repo already wired in
{
var item = new Item { Id = 1 };
service.Process(item);
repo.Received(1).Save(item);
}For fine-grained control over which types match the frozen instance, use the Matching enum:
[Theory, AutoNSubstituteData]
public void Handler_UsesInterfaces(
[Frozen(Matching.ImplementedInterfaces)] IHandler handler,
MyClass sut)
{
sut.DoWork();
handler.Received(1).Execute();
}Key points:
- Frozen parameters must appear before any parameters that depend on them — parameters are resolved left to right.
- Only reference types are frozen; value types are ignored.
- Works with all data attributes:
[AutoNSubstituteData],[InlineAutoNSubstituteData],[MemberAutoNSubstituteData],[ClassAutoNSubstituteData].
FluentArg.Match<T> bridges FluentAssertions into NSubstitute's argument matching pipeline. Use it when you want to verify a received call with rich FluentAssertions assertions on the argument, rather than simple equality.
using Cabazure.Test;
using FluentAssertions;
using NSubstitute;
[Fact]
public void Submit_SendsCorrectRequest()
{
var service = Substitute.For<IOrderService>();
var sut = new OrderHandler(service);
sut.Submit("Alice", 100);
service.Received(1).Process(
FluentArg.Match<OrderRequest>(r =>
{
r.CustomerName.Should().Be("Alice");
r.Amount.Should().Be(100);
}));
}When the assertion fails, the FluentAssertions failure message is included in NSubstitute's ReceivedCallsException, so you get precise feedback on exactly which field didn't match.
| Feature | Description |
|---|---|
FluentArg.Match<T> |
NSubstitute argument matcher that uses FluentAssertions assertions. Assertion failure messages surface in ReceivedCallsException. |
FixtureFactory |
Create() and Create(ICustomization[]) — returns a configured IFixture for use in [Fact] tests. |
[AutoNSubstituteData] |
xUnit 3 DataAttribute — all theory parameters are auto-generated from the fixture. |
[InlineAutoNSubstituteData] |
Inline values fill leading parameters; remaining parameters are auto-generated by the fixture. |
[MemberAutoNSubstituteData] |
Rows from a static member fill leading parameters; remaining parameters are auto-generated by the fixture. |
[ClassAutoNSubstituteData] |
Rows from an IEnumerable<object[]> class fill leading parameters; remaining are auto-generated. |
[Frozen] |
Freezes a parameter in the fixture so all later parameters that depend on the same type receive the same instance. |
FixtureFactory.Customizations |
Ordered collection of project-wide customizations, pre-seeded with AutoNSubstituteCustomization. Supports Add, Remove, Remove<T>, Clear, and enumeration. |
[CustomizeWith] |
Per-method or per-class attribute that applies an ICustomization on top of any project-wide registrations. |
RecursionCustomization |
Replaces ThrowingRecursionBehavior with OmitOnRecursionBehavior so recursive object graphs don't throw. |
ImmutableCollectionCustomization |
Enables fixture creation of ImmutableList<T>, ImmutableArray<T>, ImmutableDictionary<,>, and other immutable collections. |
CancellationTokenCustomization |
Provides non-cancelled CancellationToken parameters in theory tests (new CancellationToken(false)), fixing AutoFixture's default. |
DateOnlyTimeOnlyCustomization |
Enables reliable creation of DateOnly and TimeOnly values derived from a random DateTime. Requires .NET 6+. |
JsonElementCustomization |
Included-by-default customization that enables creation of System.Text.Json.JsonElement instances. Produces a random JSON string by default; configurable via constructor overloads. |
JsonSerializerOptionsCustomization |
Prevents ArgumentOutOfRangeException when AutoFixture tries to set IndentCharacter to a random char. Produces new JsonSerializerOptions() by default. |
InvokeProtected / InvokeProtectedAsync |
Extension methods for invoking protected instance methods via reflection — void, typed-return, and async variants. Useful for testing Template Method patterns and protected virtual hooks without subclassing. |
BeSimilarTo |
Whitespace-normalized string comparison (collapses whitespace/newlines) |
BeXmlEquivalentTo |
XML structural comparison ignoring formatting |
BeJsonEquivalentTo |
JSON structural comparison ignoring formatting |
| Auto-substitution | Interfaces and abstract classes are automatically replaced with NSubstitute substitutes everywhere — no manual Substitute.For<T>() required. Un-setup method calls return NSubstitute defaults (null, 0, false, completed Task). Use .Returns() to configure explicit return values. |
Replaces AutoFixture's default ThrowingRecursionBehavior with OmitOnRecursionBehavior so recursive object graphs don't throw. Included by default.
Enables creation of ImmutableList<T>, ImmutableArray<T>, ImmutableHashSet<T>, ImmutableDictionary<,>, ImmutableQueue<T>, ImmutableStack<T>, and ImmutableSortedSet<T>. Without this customization, AutoFixture throws ObjectCreationException for most immutable types. Included by default.
Provides properly initialized CancellationToken parameters in theory tests. AutoFixture's default behavior creates an already-cancelled token with CanBeCanceled = false, which is rarely useful for testing. This customization supplies new CancellationToken(false) instead — a token that is not cancelled but cannot be cancelled.
Included by default. You can:
- Use runner-scoped cancellation in test code: Access
TestContext.Current.CancellationTokendirectly (xUnit 3's idiomatic approach). This token is cancelled if the test run is aborted. - Create per-test cancellation for testing cancellation handling: Build a
CancellationTokenSourcein the test body:var cts = new CancellationTokenSource(); var token = cts.Token; // Use and control token as needed
- Opt out of the customization if you need different behavior:
[ModuleInitializer] public static void Initialize() => FixtureFactory.Customizations.Remove<CancellationTokenCustomization>();
Enables reliable creation of DateOnly and TimeOnly values. AutoFixture cannot construct DateOnly by default (it generates invalid year/month/day combinations), and while TimeOnly technically works, AutoFixture produces near-zero tick values making it useless for tests. This customization derives both types from a randomly generated DateTime.
Included by default on .NET 6+. Remove it with FixtureFactory.Customizations.Remove<DateOnlyTimeOnlyCustomization>() if you need different behavior. On netstandard2.1 targets, this customization is excluded from the default fixture because DateOnly and TimeOnly require .NET 6+.
Enables creation of System.Text.Json.JsonElement instances. AutoFixture cannot construct JsonElement by default because it requires a ref Utf8JsonReader parameter. Included by default — produces a random JSON string using Utf8JsonWriter so it works regardless of whether JsonSerializerIsReflectionEnabledByDefault is set to false.
Three constructor overloads let you control the generated value:
// Default — random JSON string (reflection-free)
new JsonElementCustomization()
// Raw JSON string factory — you return the JSON, parsing/cloning is automatic
new JsonElementCustomization(
f => $"{{\"{f.Create<string>()}\": \"{f.Create<string>()}\"}}")
// Element factory — you produce the JsonElement directly with full control
new JsonElementCustomization(f =>
{
using var buffer = new MemoryStream();
using (var writer = new Utf8JsonWriter(buffer))
{
writer.WriteStartObject();
writer.WriteString(f.Create<string>(), f.Create<string>());
writer.WriteEndObject();
}
return JsonDocument.Parse(buffer.ToArray()).RootElement.Clone();
})To override the default in your project, remove the built-in instance and add your own:
[ModuleInitializer]
public static void Initialize()
{
FixtureFactory.Customizations.Remove<JsonElementCustomization>();
FixtureFactory.Customizations.Add(
new JsonElementCustomization(f => f.Create<int>().ToString()));
}Prevents an ArgumentOutOfRangeException when AutoFixture tries to construct JsonSerializerOptions. The IndentCharacter property (added in .NET 8) only accepts ' ' (space) or '\t' (tab); AutoFixture generates a random char for it, which always fails with:
"Supported indentation characters are space and horizontal tab."
Included by default. Produces new JsonSerializerOptions() with all framework defaults. Override for a single fixture with fixture.Inject(...), or project-wide by removing and re-registering:
[ModuleInitializer]
public static void Initialize()
{
FixtureFactory.Customizations.Remove<JsonSerializerOptionsCustomization>();
FixtureFactory.Customizations.Add(
_ => new JsonSerializerOptions(JsonSerializerDefaults.Web));
}AutoFixture cannot construct some types by default — JsonElement, DateOnly, or your own domain types with special creation logic. Use FixtureFactory.Customizations to register custom factories and builders.
Provide a factory function that receives the fixture and returns a fully constructed instance:
FixtureFactory.Customizations.Add<DateOnly>(
f => DateOnly.FromDateTime(f.Create<DateTime>()));The factory is invoked whenever AutoFixture needs to create a DateOnly, whether requested directly or as a constructor parameter.
For types AutoFixture can already construct, use Build<T>() to fix specific properties without replacing the entire creation logic:
FixtureFactory.Customizations.Add<Order>(
f => f.Build<Order>()
.With(o => o.Status, OrderStatus.Pending)
.Create());For customizations shared across many tests, subclass TypeCustomization<T>:
public sealed class MoneyCustomization : TypeCustomization<Money>
{
public MoneyCustomization()
: base(f => new Money(f.Create<decimal>())) { }
}Register it once in your test assembly initializer:
[ModuleInitializer]
public static void Initialize()
{
FixtureFactory.Customizations.Add(new MoneyCustomization());
}For power users who need full control over specimen creation logic, implement ISpecimenBuilder and register it directly:
FixtureFactory.Customizations.Add(new MyAdvancedSpecimenBuilder());To replace a built-in customization like DateOnlyTimeOnlyCustomization or JsonElementCustomization, remove the original first:
// Remove the built-in first
FixtureFactory.Customizations.Remove<DateOnlyTimeOnlyCustomization>();
// Then add your replacement
FixtureFactory.Customizations.Add<DateOnly>(
f => DateOnly.FromDateTime(f.Create<DateTime>().Date));| Package | Version |
|---|---|
xunit.v3 |
3.2.2 |
NSubstitute |
5.3.0 |
AutoFixture |
4.18.1 |
AutoFixture.AutoNSubstitute |
4.18.1 |
FluentAssertions |
7.0.0 |
All packages are exposed as transitive dependencies — you get full access to the xUnit, NSubstitute, AutoFixture, and FluentAssertions APIs without adding additional package references.
ProtectedMethodExtensions provides extension methods for invoking protected instance methods via reflection. This is useful when testing the Template Method pattern or protected virtual hooks without creating a TestableMyClass : MyClass subclass per test.
// Invoke a protected void method
sut.InvokeProtected("Reset");
// Invoke a protected method with a return value
var discount = sut.InvokeProtected<decimal>("CalculateDiscount", 100m);
// Invoke a protected async method (returns Task)
await sut.InvokeProtectedAsync("OnActivatedAsync", cancellationToken);
// Invoke a protected async method with a typed return value
var dto = await sut.InvokeProtectedAsync<OrderDto>("FetchOrderAsync", id, cancellationToken);using Cabazure.Test;
using FluentAssertions;
using Xunit;
public class OrderProcessorTests
{
[Theory, AutoNSubstituteData]
public void CalculateDiscount_Returns10Percent(OrderProcessor sut)
{
var result = sut.InvokeProtected<decimal>("CalculateDiscount", 100m);
result.Should().Be(10m);
}
}
public class OrderProcessor
{
protected virtual decimal CalculateDiscount(decimal total) => total * 0.1m;
}- Base class methods — Methods are resolved with
FlattenHierarchy, so protected methods defined on any base class in the hierarchy are found automatically. - Overload resolution — Overloads are matched by argument count and type compatibility.
- Missing methods — Throws
MissingMethodExceptionwith a descriptive message (including the expected parameter types) if no matching method is found. - Exception transparency — Original exceptions are surfaced directly via
ExceptionDispatchInfo, not wrapped inTargetInvocationException, keeping stack traces and assertion errors intact.
Cabazure.Test extends FluentAssertions with domain-specific assertions for JSON and datetime operations. All extensions are available via using Cabazure.Test; — no additional using directives are required.
JsonElementAssertions provides methods to compare System.Text.Json.JsonElement instances against other elements or raw JSON strings. Comparisons are performed by normalizing both sides through serialization, ensuring structure equivalence regardless of whitespace or key ordering in the source.
using Cabazure.Test;
using FluentAssertions;
using System.Text.Json;
var element1 = JsonDocument.Parse("""{"name":"Alice","age":30}""").RootElement;
var element2 = JsonDocument.Parse("""{"age":30,"name":"Alice"}""").RootElement;
element1.Should().BeEquivalentTo(element2); // ✓ Passes — same content, different key orderusing Cabazure.Test;
using FluentAssertions;
using System.Text.Json;
var element = JsonDocument.Parse("""{"status":"active"}""").RootElement;
element.Should().BeEquivalentTo("""{"status":"active"}"""); // ✓ Passes — direct comparison- Array order is significant —
[1, 2, 3]and[3, 2, 1]are not equivalent. - Object key order is preserved — Key order from the serialized JSON is maintained during comparison, but does not affect equivalence (only content matters).
DateTimeOffsetExtensions provides BeCloseTo and NotBeCloseTo methods for asserting that two DateTimeOffset values are within a specified precision tolerance. A project-wide default precision can be configured once using [ModuleInitializer].
By default, CabazureAssertionOptions.DateTimeOffsetPrecision is set to 1 second. Assert two values without specifying a tolerance:
using Cabazure.Test;
using FluentAssertions;
[Fact]
public void OrderTimestamp_IsRecent()
{
var now = DateTimeOffset.UtcNow;
var order = new Order { CreatedAt = now.AddMilliseconds(500) };
order.CreatedAt.Should().BeCloseTo(now); // ✓ Passes — within default 1 second
}Provide a custom precision in milliseconds for a single assertion:
using Cabazure.Test;
using FluentAssertions;
var time1 = new DateTimeOffset(2026, 3, 10, 12, 0, 0, TimeSpan.Zero);
var time2 = new DateTimeOffset(2026, 3, 10, 12, 0, 0, 100, TimeSpan.Zero);
time1.Should().BeCloseTo(time2, 200); // ✓ Passes — within 200 milliseconds
time1.Should().NotBeCloseTo(time2, 50); // ✓ Passes — difference is 100ms, beyond 50msSet CabazureAssertionOptions.DateTimeOffsetPrecision once in a [ModuleInitializer] to change the default for all tests in the assembly:
using System.Runtime.CompilerServices;
using Cabazure.Test;
internal static class TestAssemblyInitializer
{
[ModuleInitializer]
public static void Initialize()
{
// All BeCloseTo/NotBeCloseTo calls now default to 100ms tolerance
CabazureAssertionOptions.DateTimeOffsetPrecision = TimeSpan.FromMilliseconds(100);
}
}When a DTO contains a JsonElement property, BeEquivalentTo falls back to reference equality by default, causing false failures for semantically identical JSON. Register JsonElementEquivalencyStep to enable proper semantic comparison:
Per-call:
result.Should().BeEquivalentTo(expected, opts => opts.UsingJsonElementComparison());Globally (in your module initializer):
[ModuleInitializer]
public static void Initialize()
{
AssertionOptions.AssertEquivalencyUsing(opts => opts.UsingJsonElementComparison());
}When testing serialization round-trips across many DTO types, some may have no public properties. FluentAssertions 7.x throws InvalidOperationException: "No members were found for comparison..." in this case.
Use AllowingEmptyObjects() to allow the assertion to pass for types with no public members:
// Per-call
result.Should().BeEquivalentTo(expected, opts => opts.AllowingEmptyObjects());
// Global (in module initializer)
AssertionOptions.AssertEquivalencyUsing(opts => opts.AllowingEmptyObjects());Types with members continue through FluentAssertions' normal equivalency pipeline unchanged.
The StringContentExtensions class extends FluentAssertions' StringAssertions with three
format-ignorant comparison methods, each with a positive and negative form.
Compare strings ignoring formatting differences — multiple spaces, tabs, and newlines are collapsed to a single space before comparison:
using Cabazure.Test;
using FluentAssertions;
var subject = """
Hello
World
""";
subject.Should().BeSimilarTo("Hello World");Compare XML strings by structure and content, ignoring indentation and line endings:
using Cabazure.Test;
using FluentAssertions;
var subject = """
<root>
<child value="42" />
</root>
""";
subject.Should().BeXmlEquivalentTo("<root><child value=\"42\" /></root>");Compare JSON strings by value, ignoring formatting:
using Cabazure.Test;
using FluentAssertions;
var subject = """
{
"name": "Alice",
"age": 30
}
""";
subject.Should().BeJsonEquivalentTo("""{"name":"Alice","age":30}""");Each method has a Not counterpart (NotBeSimilarTo, NotBeXmlEquivalentTo, NotBeJsonEquivalentTo)
and supports the standard FluentAssertions because/becauseArgs parameters.
xUnit 3 and the .NET BCL provide three complementary timeout patterns for different scenarios:
Use the Timeout parameter on [Fact] or [Theory] to set a maximum duration for the entire test:
[Fact(Timeout = 5000)] // Timeout in milliseconds
public void SlowOperation_CompletesInTime()
{
var result = ExpensiveComputation();
result.Should().Be(42);
}If the test exceeds the timeout, xUnit 3 throws TestTimeoutException. This is useful for detecting hangs in synchronous code or ensuring performance thresholds.
Use Task.WaitAsync(TimeSpan) (.NET 6+) to add a timeout to individual await expressions. This is ideal when a single async call might hang:
using FluentAssertions;
[Fact]
public async Task ApiCall_ReturnsWithinTimeout()
{
var cts = new CancellationTokenSource();
var result = await SomeApiCall(cts.Token)
.WaitAsync(TimeSpan.FromSeconds(2)); // Timeout on this specific call
result.Should().NotBeNull();
}Use WaitForReceived to poll until an asynchronous call is verified on a substitute:
[Theory, AutoNSubstituteData]
public async Task Service_CallsRepository(IRepository repo, Service sut)
{
await sut.ProcessAsync();
// Waits up to 10 seconds for the call (configurable)
await repo.WaitForReceived(r => r.Save(Arg.Any<Item>()));
}The default timeout is WaitForReceivedExtensions.DefaultTimeout (10 seconds). Override it globally in a [ModuleInitializer]:
using Cabazure.Test;
internal static class TestAssemblyInitializer
{
[System.Runtime.CompilerServices.ModuleInitializer]
public static void Initialize()
{
WaitForReceivedExtensions.DefaultTimeout = TimeSpan.FromSeconds(5);
}
}- .NET 10+ (
net10.0) — primary target; gets full performance. - .NET 6–9 — use the
netstandard2.1DLL (xUnit 3 requires .NET 6+, so this is the typical multi-version scenario). Xamarin, Unity, and Mono (6.4+) also support netstandard2.1.
Note: .NET Framework is not supported. It tops out at netstandard2.0, while Cabazure.Test requires netstandard2.1.
- FluentAssertions 7.x is included. FA 7 contains breaking changes from 6.x. If you are migrating from Atc.Test or another package that used FA 6, review the FluentAssertions 7 migration guide before upgrading.
MIT — © Ricky Kaare Engelharth
Contributions are welcome — open an issue or pull request on GitHub.