FatCat.Testing
1.0.2
dotnet add package FatCat.Testing --version 1.0.2
NuGet\Install-Package FatCat.Testing -Version 1.0.2
<PackageReference Include="FatCat.Testing" Version="1.0.2" />
<PackageVersion Include="FatCat.Testing" Version="1.0.2" />
<PackageReference Include="FatCat.Testing" />
paket add FatCat.Testing --version 1.0.2
#r "nuget: FatCat.Testing, 1.0.2"
#:package FatCat.Testing@1.0.2
#addin nuget:?package=FatCat.Testing&version=1.0.2
#tool nuget:?package=FatCat.Testing&version=1.0.2
FatCat.Testing
A fluent assertion library for .NET — a free, Apache-2.0 replacement for FluentAssertions.
What It Is
FatCat.Testing is a small, self-contained assertion library. You write assertions as fluent, chainable sentences:
result.Should().Be(expected);
name.Should().StartWith("Fat").EndWith("Cat"); // assertions chain by returning the comparer
count.Should().BeGreaterThan(0);
It exists because FluentAssertions stopped being free: 7.0.0 is the last Apache-2.0 release, and every
8.x release is commercially licensed. FatCat.Testing is Apache-2.0 and stays that way. It is a
replacement, not a source-compatible clone — see MIGRATION.md for the differences and how
to move across.
Installing
dotnet add package FatCat.Testing
Then bring the extension methods into scope, typically through a GlobalUsings.cs:
global using FatCat.Testing;
Quick Start
Every assertion starts with .Should() on the subject and reads like a sentence:
using FatCat.Testing;
true.Should().BeTrue();
"hello world".Should().Contain("world");
42.Should().BeGreaterThan(10).BeLessThan(100);
Guid.Empty.Should().BeEmpty();
// Negation is a property, not a method — see ## Negation
response.IsSuccess.Should().Not.BeFalse();
A failing assertion throws CompareException with a plain-English message describing what was expected. An
optional trailing because argument replaces that message:
age.Should().BeGreaterThan(18, "a member must be an adult");
Requirements
FatCat.Testing requires xUnit. This is not a soft preference — it is a hard coupling:
CompareExceptionderives from xUnit'sXunitException.xunit.assertis the library's only package reference.
NUnit, MSTest, TUnit, and MSpec are not supported. A failure raised by FatCat.Testing is an
XunitException, so it is only recognised as a test failure by an xUnit test runner. If your test project
does not use xUnit, this library will not work for you today.
Assertion Catalog
One table per subject family. Every assertion below ships today and is covered by tests. Negated forms are
reached through the Not property (x.Should().Not.Be(y)) and are not listed separately.
Assertions marked (nullable only) live on the Nullable<Type>Comparer and are reached when the subject is
the nullable value type (bool?, Guid?, and so on).
Booleans
| Assertion | What it asserts |
|---|---|
Be(expected) |
The value equals expected. |
BeTrue() |
The value is true. |
BeFalse() |
The value is false. |
BeNull() |
(nullable only) The nullable has no value. |
HaveValue() |
(nullable only) The nullable has a value. |
Imply(consequent) |
Material implication — fails only when the subject is true and consequent is false. |
Characters
| Assertion | What it asserts |
|---|---|
Be(expected) |
The character equals expected. |
BeDigit() |
The character is a decimal digit. |
BeLetter() |
The character is a letter. |
BeLetterOrDigit() |
The character is a letter or a digit. |
BeLowerCased() |
The character is lower case. |
BeUpperCased() |
The character is upper case. |
BeWhiteSpace() |
The character is white space. |
BeControl() |
The character is a control character. |
BeNull() |
(nullable only) The nullable has no value. |
HaveValue() |
(nullable only) The nullable has a value. |
Collections
Entry points exist for IEnumerable<T>, List<T>, and T[]. Most assertions match elements by element
equality (Equals, via EqualityComparer<T>.Default); BeEquivalentTo and ContainEquivalentOf match
structurally instead (see below). A lazy IEnumerable<T> is snapshotted once when .Should() is
called, so a side-effecting query is enumerated a single time and every chained assertion reads that
snapshot.
| Assertion | What it asserts |
|---|---|
Contain(expected) |
The collection contains an element equal to expected. |
BeEmpty() |
The collection has no elements. |
HaveCount(count) |
The collection has exactly count elements. |
ContainSingle() |
The collection has exactly one element. |
Equal(expected) |
The collection equals expected element-by-element, in order. |
BeEquivalentTo(expected) |
The collection has the same elements as expected, compared structurally, ignoring order. |
ContainEquivalentOf(expected) |
The collection contains an element structurally equivalent to expected. |
OnlyContain(predicate) |
Every element satisfies predicate. |
OnlyHaveUniqueItems() |
The collection has no duplicate elements. |
ContainInOrder(expected) |
The expected elements appear in this relative order (not necessarily contiguous). |
BeInDescendingOrder() |
The elements are in descending order (Comparer<T>.Default). |
ContainSingle(predicate) |
Exactly one element satisfies predicate. |
BeInAscendingOrder() |
The elements are in ascending order (Comparer<T>.Default). |
BeSubsetOf(superset) |
Every element is contained in superset. |
IntersectWith(other) |
The collection and other share at least one element. |
AllSatisfy(inspector) |
Every element passes the inspector without throwing. |
SatisfyRespectively(inspectors) |
Element i passes inspector i; the counts must match. |
AllBeOfType<T>() / AllBeOfType(type) |
Every element's runtime type is exactly T. |
AllBeEquivalentTo(expected) |
Every element is structurally equivalent to expected. |
HaveCountGreaterThan(n) |
The collection has more than n elements. |
HaveCountLessThan(n) |
The collection has fewer than n elements. |
HaveSameCount(other) |
The collection has the same number of elements as other. |
HaveElementAt(index, expected) |
The element at index equals expected. |
ContainNulls() |
At least one element is null. |
ContainMatch(wildcard) |
At least one string element matches the wildcard (*, ?). |
Equal is order-sensitive — [1, 2, 3].Should().Equal([3, 2, 1]) fails. BeEquivalentTo is its
order-insensitive counterpart — [1, 2, 3].Should().BeEquivalentTo([3, 2, 1]) passes. This is the
single most surprising default in the library and it matches FluentAssertions; there is no
WithStrictOrdering opt-out — reach for Equal when order matters.
BeEquivalentTo and ContainEquivalentOf compare elements structurally, reusing the same object
equivalency engine as object BeEquivalentTo (public readable properties, recursively), so elements match by
value without needing an Equals override. Matching is greedy: each element is paired with the first
still-unmatched candidate it is equivalent to. Greedy pairing can theoretically mis-pair when several
elements are ambiguously equivalent to one another; this matches FluentAssertions and is accepted rather than
running a full bipartite matcher. Failure messages render at most 32 elements (the value formatter's cap),
so a large mismatch does not dump the whole collection.
Predicate-based assertions (OnlyContain, ContainSingle(predicate)) cannot describe the predicate in the
generated message, which reads "matching the predicate". Supply because to make the failure specific.
Negated forms are reached through Not: Not.Contain(x), Not.BeEmpty() (assert the collection is not
empty), Not.HaveCount(n), Not.IntersectWith(other) (share no element), and Not.ContainNulls(). The
remaining assertions have no negated form because it would not read naturally.
AllSatisfy and SatisfyRespectively take inspectors that assert on each element and signal failure by
throwing (typically a nested Should() call). SatisfyRespectively uses params for its inspectors, so it
has no trailing because. ContainMatch and ContainNulls are meaningful only on the element types they
name — ContainMatch inspects string elements, ContainNulls a collection whose elements can be null.
Deliberately not shipped from FluentAssertions' collection surface: ContainInConsecutiveOrder,
HaveElementPreceding / HaveElementSucceeding, and ContainItemsAssignableTo. They have no consumer and
marginal value; they are a possible future top-up, not part of the current catalog.
DateTimes
| Assertion | What it asserts |
|---|---|
Be(expected) |
The value equals expected. |
BeAfter(other) |
The value is strictly after other. |
BeBefore(other) |
The value is strictly before other. |
BeOnOrAfter(other) |
The value is at or after other. |
BeOnOrBefore(other) |
The value is at or before other. |
BeCloseTo(other, tolerance) |
The value is within tolerance of other. |
BeUtc() |
The value's Kind is Utc. |
BeLocal() |
The value's Kind is Local. |
HaveYear(year) |
The year component equals year. |
HaveMonth(month) |
The month component equals month. |
HaveDay(day) |
The day component equals day. |
HaveHour(hour) |
The hour component equals hour. |
HaveMinute(minute) |
The minute component equals minute. |
HaveSecond(second) |
The second component equals second. |
HaveMillisecond(millisecond) |
The millisecond component equals millisecond. |
HaveKind(kind) |
The DateTimeKind equals kind. |
HaveOffset(offset) |
The value's offset equals offset. |
BeIn(kind) |
The value's Kind equals kind. Alias of HaveKind under the FluentAssertions name. |
BeSameDateAs(other) |
The value falls on the same calendar date as other, ignoring the time of day. |
BeLessThan(tolerance).Before(other) / .After(other) |
The gap between the value and other is less than tolerance. |
BeMoreThan(tolerance).Before(other) / .After(other) |
The gap between the value and other is more than tolerance. |
BeAtLeast(tolerance).Before(other) / .After(other) |
The gap between the value and other is at least tolerance. |
BeWithin(tolerance).Before(other) / .After(other) |
The gap between the value and other is at most tolerance. |
BeExactly(tolerance).Before(other) / .After(other) |
The gap between the value and other is exactly tolerance. |
BeNull() |
(nullable only) The nullable has no value. |
HaveValue() |
(nullable only) The nullable has a value. |
BeIn overlaps HaveKind — both assert on Subject.Kind. BeIn exists so the FluentAssertions name is
available; HaveKind remains the native spelling. Either reads the same failure through the same check.
Difference chains
The five difference builders are a two-step assertion. The builder captures the tolerance; .Before(other)
or .After(other) names the direction and runs the check. .Before requires the value to be earlier than
other and measures other - value; .After requires it to be later and measures value - other. Calling
the wrong direction is a failure, not a silent pass. Each direction rejoins the normal fluent surface by
returning the DateTimeComparer.
var start = new DateTime(2026, 1, 1, 10, 0, 0);
var end = new DateTime(2026, 1, 1, 11, 30, 0);
start.Should().BeLessThan(2.Hours()).Before(end);
end.Should().BeMoreThan(1.Hours()).After(start);
Numeric time extensions
Hours(), Minutes(), Seconds(), Days(), and Milliseconds() are extension methods on int that return
a TimeSpan, so a tolerance reads as 2.Hours() instead of TimeSpan.FromHours(2). They exist primarily to
make the difference chains read well, but they are general-purpose and usable anywhere a TimeSpan is wanted.
2.Hours(); // TimeSpan.FromHours(2)
30.Minutes(); // TimeSpan.FromMinutes(30)
250.Milliseconds(); // TimeSpan.FromMilliseconds(250)
Doubles And Floats
Both double and float share the same assertion set.
| Assertion | What it asserts |
|---|---|
BeApproximately(expected, tolerance) |
The value is within tolerance of expected. Negatable via .Not.BeApproximately(expected, tolerance). |
BeGreaterThan(expected) |
The value is strictly greater than expected. |
BeLessThan(expected) |
The value is strictly less than expected. |
BeInRange(lower, upper) |
The value is within [lower, upper]. Negatable via .Not.BeInRange(lower, upper). |
BeNaN() |
The value is NaN. |
BeNegative() |
The value is less than zero. |
BePositive() |
The value is greater than zero. |
BeZero() |
The value is zero. |
Match(predicate) |
The value satisfies predicate. |
Enums
| Assertion | What it asserts |
|---|---|
Be(expected) |
The value equals expected. |
BeDefined() |
The value is a defined member of the enum. Negatable — Not.BeDefined() asserts the value is not defined. |
HaveFlag(flag) |
The value has flag set. |
HaveSameNameAs(other) |
The value has the same member name as other, an enum of any type. |
HaveSameValueAs(other) |
The value has the same underlying numeric value as other, an enum of any type. |
BeNull() |
(nullable only) The nullable has no value. |
HaveValue() |
(nullable only) The nullable has a value. |
Exceptions
Assertions on a delegate — an Action for synchronous code, a Func<Task> for asynchronous code. This is
the only family whose failure messages do not lead with the subject: an Action's ToString() is just its
type name, so there is nothing useful to render, and the messages start with should instead.
| Assertion | What it asserts |
|---|---|
Throw<TException>() |
Invoking the Action throws a TException (or a derived type). Returns a ThrownExceptionComparer. |
ThrowExactly<TException>() |
Invoking the Action throws a TException exactly — a derived type fails. Returns a ThrownExceptionComparer. |
NotThrow() |
Invoking the Action throws nothing. |
ThrowAsync<TException>() |
Awaiting the Func<Task> throws a TException (or a derived type). Returns a ThrownExceptionComparer. |
ThrowExactlyAsync<TException>() |
Awaiting the Func<Task> throws a TException exactly — a derived type fails. Returns a ThrownExceptionComparer. |
NotThrowAsync() |
Awaiting the Func<Task> throws nothing. |
WithMessage(expected) |
(on ThrownExceptionComparer) The caught exception's Message equals expected exactly. |
WithInnerException<TInner>() |
(on ThrownExceptionComparer) The caught exception has an inner exception of TInner (or a derived type). Returns a ThrownExceptionComparer scoped to the inner exception, so it chains further. |
WithInnerExceptionExactly<TInner>() |
(on ThrownExceptionComparer) The inner exception is TInner exactly. Returns a ThrownExceptionComparer scoped to the inner exception. |
Where(predicate) |
(on ThrownExceptionComparer) The caught exception satisfies predicate (Func<Exception, bool>). |
WithParameterName(expected) |
(on ThrownExceptionComparer) The caught ArgumentException's ParamName equals expected. |
Three things worth knowing before you use these:
The delegate must be a typed variable. A bare lambda has no type and cannot receive an extension method, so you cannot write
(() => Foo()).Should(). Declare the delegate first:Action action = () => service.Charge(card); action.Should().Throw<InvalidOperationException>();Throw<T>returns aThrownExceptionComparer, not the action comparer, soWithMessagecan chain onto the caught exception. This is the one place in the library where an assertion changes comparer type — the subject genuinely changes from "the delegate" to "the exception it threw":action.Should().Throw<InvalidOperationException>().WithMessage("card declined");Throw<T>matches derived exception types;ThrowExactly<T>does not.Throw<ArgumentException>()is satisfied by anArgumentNullException;ThrowExactly<ArgumentException>()fails on it, requiring the thrown type to beArgumentExceptionexactly. The same distinction holds forWithInnerException<T>(derived) versusWithInnerExceptionExactly<T>(exact).
WithMessage compares the message with exact string equality — FluentAssertions supports * wildcards
there and FatCat.Testing does not. Use Where(e => e.Message.Contains("...")) for a substring check. See
MIGRATION.md §5.
Asserting that code throws does not make the assertion API asynchronous: ThrowAsync<T> and
ThrowExactlyAsync<T> observe the Task synchronously inside the comparer, so the fluent Should() surface
stays synchronous and is never awaited.
Deliberate omissions. FluentAssertions' Awaiting / Invoking / Enumerating wrappers are not
provided — the Action and Func<Task> overloads above already adapt a subject into a delegate. WithResult
(asserting an async result value) is omitted as it has no consumer here. CompleteWithinAsync(TimeSpan) is
also omitted: enforcing a timeout requires either a banned primitive (Task.Delay / Thread.Sleep) or a
second blocking site, both disallowed, so this family is intentionally not a complete port.
Guids
| Assertion | What it asserts |
|---|---|
Be(expected) |
The value equals expected. Accepts either a Guid or a string. |
BeEmpty() |
The value is Guid.Empty. |
BeNull() |
(nullable only) The nullable has no value. |
HaveValue() |
(nullable only) The nullable has a value. |
Be has a string overload that parses the expected value into a Guid before comparing, so
id.Should().Be("12345678-1234-1234-1234-123456789012") reads naturally. An unparseable string is a usage
error, not a failed assertion — it throws ArgumentException, not CompareException.
Numbers
Applies to every integral numeric type (int, long, short, byte, decimal, and their signed and
unsigned relatives).
| Assertion | What it asserts |
|---|---|
Be(expected) |
The value equals expected. |
BeGreaterThan(expected) |
The value is strictly greater than expected. |
BeLessThan(expected) |
The value is strictly less than expected. |
BeInRange(lower, upper) |
The value is within [lower, upper]. Negatable via .Not.BeInRange(lower, upper). |
BeAround(center, tolerance) |
The value is within tolerance of center. |
BeNegative() |
The value is less than zero. |
BePositive() |
The value is greater than zero. |
BeZero() |
The value is zero. |
Match(predicate) |
The value satisfies predicate. |
BeNull() |
(nullable only) The nullable has no value. |
HaveValue() |
(nullable only) The nullable has a value. |
Objects
Any reference type has a .Should() entry point through Should<T>(this T) where T : class. Concrete
collection shapes (IEnumerable<T>, List<T>, T[]) keep binding to their own comparer, so a List<string>
never lands here by accident.
| Assertion | What it asserts |
|---|---|
Be(expected) |
The subject equals expected by value — Equals, so a type that overrides Equals compares by value. |
BeEquivalentTo(expected) |
The subject is structurally equivalent to expected — a recursive, member-by-member comparison. |
BeNull() |
The subject is null. |
BeSameAs(expected) |
The subject is the same instance as expected — reference identity (ReferenceEquals). |
Satisfy(inspector) |
The subject passes the inspector action without throwing. |
BeOfType(type) |
(inherited) The subject is exactly type. |
BeAssignableTo(type) |
(inherited) The subject is assignable to type. |
BeOneOf(values) |
(inherited) The subject equals one of values. |
Be and BeSameAs are different questions. Be is value equality: two distinct instances that are
Equals pass Be and fail BeSameAs. BeSameAs is reference identity: only the very same object
passes. Be is not structural comparison — comparing the members of a type that does not override
Equals is BeEquivalentTo. When two objects differ but render the same text (because
they override ToString), the Be failure message cannot explain why they differ; BeEquivalentTo is the
assertion that does.
BeEquivalentTo compares two objects structurally, member by member, without either type having to
override Equals. It:
- compares public readable instance properties only — fields are not compared, so two objects that differ only in a public field are still equivalent;
- uses the type's own
Equalsfor any type that overrides it (sostring, primitives,Guid,DateTime, and enums compare by value and terminate the recursion) — this is the base case that keeps the engine from trying to dump the members of anint; - recurses into nested objects and into collection-typed members (collection members match order-insensitively), and is cycle-safe — a reference already on the current path is treated as equivalent rather than recursing forever;
- names the differing member in the failure message and the full path to it, for example
... but Address.City differs: expected "York" but found "Leeds".
This is a separate question from Be. Be uses the type's own Equals; BeEquivalentTo walks members
regardless of whether Equals is overridden. Recursion is depth-limited (10) and collection members are
capped (32 elements) as safety valves for pathological graphs.
var actual = new Person { Name = "Alice", Address = new Address { City = "Leeds" } };
var expected = new Person { Name = "Alice", Address = new Address { City = "Leeds" } };
actual.Should().BeEquivalentTo(expected); // passes — same members, no Equals override needed
actual.Should().Not.BeEquivalentTo(other); // negation is the Not property, as everywhere
Per-type equivalency rules — Equivalency.Using<T>
BeEquivalentTo ships one configuration hook: a global per-type rule that replaces the default comparison
for a single type wherever that type appears in a graph. The canonical use is a DateTime closeness rule —
two timestamps within a tolerance count as equivalent even though they are not exactly equal. Register the
rule with Equivalency.Using<T> and it fires for every T node, short-circuiting the recursion for that
type (a DateTime with a closeness rule never recurses into DateTime's own properties):
Equivalency.Using<DateTime>((subject, expected) => (subject - expected).Duration() <= TimeSpan.FromSeconds(1));
actual.Should().BeEquivalentTo(expected); // Timestamp members within one second now match
Registration is a single <T> — there is no WhenTypeIs<T>() chain to repeat the type. Registering
twice for the same type is last-registration-wins.
This registry is process-wide, mutable state. A rule registered by one test affects every later test in the run, so registration belongs in a fixture (a collection fixture, a static constructor, or a test class's constructor), never inside a
[Fact]. Tear it down withEquivalency.Reset()in the fixture'sDispose, so no rule leaks into unrelated tests and test-run order stays irrelevant:
public class EquivalencyFixture : IDisposable
{
public EquivalencyFixture()
{
Equivalency.Using<DateTime>((subject, expected) => (subject - expected).Duration() <= TimeSpan.FromSeconds(1));
}
public void Dispose() { Equivalency.Reset(); }
}
This is a deliberate divergence from FluentAssertions, where options.Using<T>(...).WhenTypeIs<T>() is a
per-call option. FatCat.Testing registers the rule globally instead — matched to the only way real
call sites use it, and without growing every BeEquivalentTo signature with an options lambda.
A bare null literal cannot receive Should() — the compiler has no type to bind T to. Type the variable
first:
Dto result = null;
result.Should().BeNull();
result.Should().Not.BeNull(); // fails with "null should not be null"
Strings
| Assertion | What it asserts |
|---|---|
Be(expected) |
The string equals expected (case-sensitive). |
BeEquivalentTo(expected) |
The string equals expected, ignoring case. |
Contain(expected) |
The string contains expected. |
ContainAll(values) |
The string contains every value in values. Negatable via .Not.. |
ContainAny(values) |
The string contains at least one value in values. Negatable via .Not.. |
ContainEquivalentOf(expected) |
The string contains expected, ignoring case. |
StartWith(expected) |
The string starts with expected. |
EndWith(expected) |
The string ends with expected. |
StartWithEquivalentOf(expected) |
The string starts with expected, ignoring case. |
EndWithEquivalentOf(expected) |
The string ends with expected, ignoring case. |
Match(pattern) |
The string matches the wildcard pattern. |
MatchRegex(pattern) |
The string matches the regular expression pattern. |
HaveLength(length) |
The string has exactly length characters. |
BeEmpty() |
The string is empty. |
BeNull() |
The string is null. |
BeNullOrEmpty() |
The string is null or empty. |
BeNullOrWhiteSpace() |
The string is null, empty, or white space. |
BeLowerCased() |
The string is entirely lower case. |
BeUpperCased() |
The string is entirely upper case. |
HaveValue() |
The string is not null. |
TimeSpans
| Assertion | What it asserts |
|---|---|
Be(expected) |
The value equals expected. |
BeCloseTo(other, tolerance) |
The value is within tolerance of other. |
BeGreaterThan(expected) |
The value is strictly greater than expected. |
BeGreaterThanOrEqualTo(expected) |
The value is greater than or equal to expected. |
BeLessThan(expected) |
The value is strictly less than expected. |
BeLessThanOrEqualTo(expected) |
The value is less than or equal to expected. |
BeNegative() |
The value is negative. |
BePositive() |
The value is positive. |
HaveDays(days) |
The days component equals days. |
HaveHours(hours) |
The hours component equals hours. |
HaveMinutes(minutes) |
The minutes component equals minutes. |
HaveSeconds(seconds) |
The seconds component equals seconds. |
HaveMilliseconds(milliseconds) |
The milliseconds component equals milliseconds. |
BeNull() |
(nullable only) The nullable has no value. |
HaveValue() |
(nullable only) The nullable has a value. |
Shared (all comparers)
Every comparer inherits these from the shared base, so they are available on every subject type above.
| Assertion | What it asserts |
|---|---|
BeOfType(type) |
The subject is exactly type. |
BeAssignableTo(type) |
The subject is assignable to type. |
BeOneOf(values) |
The subject equals one of values. |
Satisfy(inspector) |
The subject passes the inspector action without throwing. |
Negation
Negation is a property, not a family of methods. Every positive assertion has a negated form reached through
Not:
value.Should().Not.Be(expected);
name.Should().Not.BeNullOrEmpty();
There are no NotXxx methods, and there never will be. FatCat.Testing does not expose NotBe,
NotBeNull, or any other prefixed negation — not even as obsolete shims. The single negation form is the
Not property. This is a deliberate, permanent API decision.
Because FluentAssertions uses the prefixed shape (NotBe, NotBeNull, …), moving from it is a mechanical
rewrite of .Should().NotXxx( to .Should().Not.Xxx(. That rewrite, and a codemod to perform it, are
described in MIGRATION.md.
Custom Comparers
FatCat.Testing is extensible: you write your own comparer for your own subject type, reusing the same base
class, failure exception, and value formatter the built-in comparers use. There is no separate authoring API
to learn — a custom comparer is an ordinary class deriving from ComparerBase<TSubject, TComparer>, and it
gets BeOfType, BeAssignableTo, BeOneOf, and Satisfy for free.
The recipe:
- Derive from
ComparerBase<TSubject, TComparer>, forwarding the subject through the primary constructor.TCompareris your own comparer type, so chained assertions return it and keep chaining. - Expose a
Notproperty returning a twin that derives fromNotComparerBase<TSubject, TComparer>(recommended — it gives you.Not.BeOk(); there are noNotXxxmethods). - Return
thisfrom every assertion method so calls chain. - Throw failures with
CompareException.New(because ?? $"...")—becausereplaces the generated message, and messages are built with string interpolation, never+concatenation. - Add a
Should(this TSubject)extension in your own static class to reach the comparer. - Read
Subject(public on the base) and format object or collection values withFatCat.Testing.Formatting.ValueFormatter.Formatso your messages render subjects the way the built-in comparers do.
Subject is public on both base classes, so a composing helper can read it from outside the comparer —
which is exactly what a real assertion does when it delegates to an inner .Should().
The direct form
The comparer inspects Subject and throws:
using FatCat.Testing.Comparers;
using FatCat.Testing.Exceptions;
public class WebResponse
{
public int StatusCode { get; set; }
}
public class WebResponseComparer(WebResponse subject) : ComparerBase<WebResponse, WebResponseComparer>(subject)
{
public WebResponseComparer BeOk(string because = null)
{
if (Subject.StatusCode != 200) { CompareException.New(because ?? $"status code {Subject.StatusCode} should be 200 (OK)"); }
return this;
}
}
public static class WebResponseShouldExtensions
{
public static WebResponseComparer Should(this WebResponse subject) { return new WebResponseComparer(subject); }
}
response.Should().BeOk(); // passes when StatusCode is 200
response.Should().BeOk().BeOk(); // chains — every assertion returns the comparer
response.Should().BeOk("service is up"); // because replaces the generated message on failure
The delegating form
Because Subject is public, an assertion can delegate to an inner .Should() on a property of the subject
rather than re-implementing the comparison — this is how the real consumer assertions are written:
public WebResponseComparer BeOk(string because = null)
{
Subject.StatusCode.Should().Be(200, because);
return this;
}
Here the inner Subject.StatusCode.Should().Be(200) produces its own CompareException if the status code
is not 200, and BeOk simply returns this to keep the chain going. Both forms are proven in
Tests.FatCat.Testing.Extensibility.CustomComparerTests.
Negation
Give your comparer a Not twin deriving from NotComparerBase<TSubject, TComparer> so negation reads
.Not.BeOk(), matching the rest of the library:
public class NotWebResponseComparer(WebResponse subject)
: NotComparerBase<WebResponse, NotWebResponseComparer>(subject)
{
public NotWebResponseComparer BeOk(string because = null)
{
if (Subject.StatusCode == 200) { CompareException.New(because ?? $"status code {Subject.StatusCode} should not be 200 (OK)"); }
return this;
}
}
Then expose it from the positive comparer with public NotWebResponseComparer Not { get; } = new(subject);.
One caveat — failure stack traces
FatCat.Testing has no [CustomAssertion] equivalent. When a custom assertion fails, the top of the stack
trace points at CompareException.New inside your comparer method, not at the test line that called it — the
test line is the bottom-most frame. The failure message is unaffected; only the stack-trace attribution
differs from a hand-written Assert in the test body.
Value Formatting
When an assertion fails, FatCat.Testing renders the subject into the failure message with
FatCat.Testing.Formatting.ValueFormatter.Format. ToString() alone is not enough: a List<string>
renders as System.Collections.Generic.List`1[System.String] and a plain DTO renders as its type name.
ValueFormatter turns both into something a reader can act on.
ValueFormatter.Format is public and supported. Custom comparers (see ## Custom Comparers) call it so
their failure messages render subjects exactly the way the built-in comparers do.
Rendering rules
Position matters — a string at the top level renders differently from a string nested inside a collection or an object dump (see the top-level-bare / nested-quoted rule below).
| Value | Top level | Nested (inside a collection or object dump) |
|---|---|---|
null |
null |
null |
string |
the string, bare | "the string" |
char |
the char, bare | 'c' |
IEnumerable (not string) |
[a, b, c] |
[a, b, c] |
anything overriding ToString() |
ToString() |
ToString() |
| any other object | TypeName { Member = value, ... } |
same |
Because Guid, DateTime, TimeSpan, and every enum override ToString(), they render exactly as they
always have.
Top-level bare, nested quoted
A string passed straight to Format renders without quotes; a string found inside a collection or an object
dump renders quoted, so [a, b] is never ambiguous against a collection of some other type:
ValueFormatter.Format("hello"); // hello
ValueFormatter.Format(new[] { "a", "b" }); // ["a", "b"]
ValueFormatter.Format(new Person { Name = "Bob" }); // Person { Name = "Bob" }
Collections
An empty collection renders []. At most 32 elements are shown; the rest are summarised as the count of
the remainder:
ValueFormatter.Format(Enumerable.Range(1, 40)); // [1, 2, ... 32, …and 8 more]
Object dumps
A plain object that does not override ToString() renders its public, readable, instance properties in
declaration order (no fields, no static members, no indexers). A type with no such properties renders
TypeName { }.
- Depth cap. Nesting deeper than 5 levels renders
{ … }. - Cycle detection. A reference already on the current path renders
{ cyclic reference to TypeName }, so a graph that references itself formats and returns instead of overflowing the stack. Detection is by reference identity, neverEquals— the object being formatted is frequently one whoseEqualsis the thing under test. - A property getter that throws renders
Member = <threw TypeName>rather than failing the assertion for the wrong reason.
Coming From FluentAssertions
FatCat.Testing is a replacement for FluentAssertions, but it is not source-compatible. This section is a
practical guide to moving a real codebase across. It uses FatCat.Toolkit as the
worked example, because the Toolkit exercises the whole spectrum of FluentAssertions usage — plain
assertions in tests and custom assertion classes shipped from production libraries — so if your project
does something with FluentAssertions, the Toolkit almost certainly does the same thing somewhere.
The full mapping table (every FluentAssertions call and its FatCat.Testing form), the behavioural
differences, the known-unsupported list, and the codemod reference all live in
MIGRATION.md. This section is the how-to; that file is the lookup.
The three kinds of change
Every migration is some mix of these three, in increasing order of effort:
- Swap the reference and the using. Replace the
FluentAssertionspackage reference withFatCat.Testing, and theglobal using FluentAssertions;withglobal using FatCat.Testing;. Both projects target the samenet10.0, so nothing else in the project file moves. - Rewrite the call sites. Negation moves from a prefixed method to the
Notproperty (.NotBeNull()→.Not.BeNull()), and anybecausethat used{0}-style format arguments becomes an interpolated string. The negation rewrite is mechanical — the shipped codemod does it (see below); thebecauserewrite is a hand edit at the few sites that use format arguments. - Port the custom assertion classes. If your project ships its own
.Should()assertions built on FluentAssertions'ReferenceTypeAssertions<,>, those classes are rewritten as comparers deriving fromComparerBase<TSubject, TComparer>. This is the only part that is genuinely a rewrite rather than a find-and-replace, and it is covered in full below.
Step by step
# 1. Swap the package reference (per project that references FluentAssertions)
dotnet remove package FluentAssertions
dotnet add package FatCat.Testing
# 2. Swap the global using — change `global using FluentAssertions;` to
# `global using FatCat.Testing;` in each GlobalUsings.cs
# 3. Preview the mechanical negation rewrite, then apply it
pwsh ./tools/Convert-FluentAssertions.ps1 -Path <dir> -WhatIf
pwsh ./tools/Convert-FluentAssertions.ps1 -Path <dir>
# 4. Port any custom assertion classes (see "Porting a custom assertion" below)
# 5. Build and let the compiler flag the residue — the handful of behavioural
# differences in MIGRATION.md §5/§6 that no rewrite can catch
dotnet build
The codemod (tools/Convert-FluentAssertions.ps1) rewrites .Should().NotXxx( to .Should().Not.Xxx(
across a source tree, is idempotent, and reports the four cases it deliberately cannot rewrite (chains
through .And, line-broken chains, project-defined Not* methods, and subjects the target build does not
yet cover) rather than mangling them. See MIGRATION.md §7.
Porting a custom assertion — the Toolkit model
The Toolkit ships assertion helpers like FatResultAssertions<T>, FatWebResponseAssertions, and
EndpointAssertions from its production libraries, so downstream tests can write
result.Should().BeSuccessful(). Each is built on FluentAssertions' extensibility surface. Here is the real
FatResultAssertions<T> and its FatCat.Testing rewrite.
Before — on FluentAssertions' ReferenceTypeAssertions<,>:
using FluentAssertions;
using FluentAssertions.Primitives;
public class FatResultAssertions<T>(FatResult<T> subject)
: ReferenceTypeAssertions<FatResult<T>, FatResultAssertions<T>>(subject)
{
protected override string Identifier
{
get { return "FatResultAssertions"; }
}
public FatResultAssertions<T> Be(FatResult<T> expectedResult)
{
new ObjectAssertions(Subject).BeEquivalentTo(expectedResult);
return this;
}
public FatResultAssertions<T> BeSuccessful()
{
Subject.Should().NotBeNull();
Subject.IsSuccessful.Should().BeTrue();
return this;
}
}
After — on FatCat.Testing's ComparerBase<,>:
using FatCat.Testing;
using FatCat.Testing.Comparers;
public class FatResultComparer<T>(FatResult<T> subject)
: ComparerBase<FatResult<T>, FatResultComparer<T>>(subject)
where T : class
{
public FatResultComparer<T> Be(FatResult<T> expected)
{
Subject.Should().BeEquivalentTo(expected);
return this;
}
public FatResultComparer<T> BeSuccessful()
{
Subject.Should().Not.BeNull();
Subject.IsSuccessful.Should().BeTrue();
return this;
}
}
public static class FatResultShouldExtensions
{
public static FatResultComparer<T> Should<T>(this FatResult<T> response)
where T : class
{
return new FatResultComparer<T>(response);
}
}
Point by point, that is the whole mapping you apply to every custom assertion class:
| FluentAssertions | FatCat.Testing |
|---|---|
ReferenceTypeAssertions<TSubject, TAssertions> base |
ComparerBase<TSubject, TComparer> base |
protected override string Identifier { get; } |
Deleted — there is no identifier concept |
new ObjectAssertions(Subject).BeEquivalentTo(x) |
Subject.Should().BeEquivalentTo(x) |
.Should().NotBeNull() |
.Should().Not.BeNull() |
AndConstraint<TAssertions> return |
return this; (the comparer itself) |
Execute.Assertion.ForCondition(c).FailWith(m) |
if (!c) { CompareException.New(m); } |
the Should(this TSubject) extension |
unchanged in shape — returns the new comparer |
Subject is public on ComparerBase, so the delegating style the Toolkit uses — assert on the subject's
members through an inner .Should() rather than re-implementing the check — carries over unchanged. See
## Custom Comparers for the full recipe, including the Not twin.
Call-site rewrites in tests
The bulk of a test suite needs only mechanical edits. The negations are handled by the codemod; the
because format-argument sites are the only routine hand edits:
// FluentAssertions
value.Should().NotBeNull();
copy.Should().NotBeSameAs(original);
bytes.Should().NotBeEquivalentTo(other);
list.Should().HaveCount(3, "expected {0} items", count);
// FatCat.Testing
value.Should().Not.BeNull();
copy.Should().Not.BeSameAs(original);
bytes.Should().Not.BeEquivalentTo(other);
list.Should().HaveCount(3, $"expected {count} items"); // because replaces the message; no becauseArgs
Watch-outs — the things a rewrite cannot catch
These compile-or-fail in ways the codemod cannot see. The Toolkit hits each of them:
becausereplaces the message; it is not appended. FluentAssertions appends the reason to the generated text — FatCat.Testing'sbecausebecomes the whole message. Migrate the intent, and note there is noparams object[] becauseArgs.EquivalencyAssertionOptions<T>config lambdas. The Toolkit'sHaveContentEquivalentTo(x, config => …)overloads take an options lambda but never apply it. There is no per-call options object in FatCat.Testing — drop the parameter, or, for a genuine per-type rule (aDateTimecloseness tolerance, say), register it once globally withEquivalency.Using<T>in a fixture (see## Objects).Excluding,Including, andWithStrictOrderinghave no equivalent.MatchEquivalentOf(string) andBeGreaterOrEqualTo/BeLessOrEqualTo(numerics) are not shipped today. They belong to a separatetier_2_gapsplan. Rewrite aMatchEquivalentOfwildcard asMatch(case-sensitive wildcard) orContain(substring);TimeSpanalready shipsBeGreaterThanOrEqualTo/BeLessThanOrEqualTo. SeeMIGRATION.md§3.- No
AssertionScope,.And, or.Which. Assertions throw on the first failure and chain by returning the comparer. Split soft-assertion scopes into separate statements; capture a matched item in a local instead of drilling through.Which.
Migrate in dependency order
FluentAssertions reaches consumers transitively, so migrate a chain of repositories from the bottom up.
Because the Toolkit references FluentAssertions from production projects (it ships those test helpers),
replacing it changes the Toolkit package's public contract — treat that as its own release step, port its
custom assertion layer, then move downstream consumers onto the FluentAssertions-free Toolkit. The full
sequence is in MIGRATION.md §7.
Known Limitations
- xUnit only.
CompareExceptionderives fromXunitException; there is no framework-detection shim. See## Requirements. becausereplaces the message. A suppliedbecausebecomes the whole failure message. It is not appended to the generated one, and there is noparams object[] becauseArgs.- No
AssertionScope. Assertions throw on the first failure; there are no soft assertions that collect multiple failures. - No
.And/.Which. Assertions chain by returning the comparer, but there is no.Andproperty and no.Whichdrill-down onto a matched item. BeEquivalentToships default options plus one hook. The only configuration point is the globalEquivalency.Using<T>per-type rule (register it in a fixture, reset it after). There is noExcluding,Including,WithStrictOrdering, or the wider option-method surface, and no per-call options lambda.
Deliberately omitted from the FluentAssertions surface
These have no consumer and are intentionally not part of the current catalog: the collection assertions
ContainInConsecutiveOrder, HaveElementPreceding / HaveElementSucceeding, and ContainItemsAssignableTo;
the exception wrappers Awaiting / Invoking / Enumerating, WithResult, and CompleteWithinAsync; and
WithMessage wildcard matching. See MIGRATION.md §6.
BeGreaterOrEqualTo / BeLessOrEqualTo on numerics and MatchEquivalentOf on strings are not shipped by
this library today — they belong to a separate tier_2_gaps plan (TimeSpan already ships its own
BeGreaterThanOrEqualTo / BeLessThanOrEqualTo). See MIGRATION.md §3.
Known coverage gaps
A few shipped methods are not yet test-complete to the standard the rest of the library holds, and the catalog
should not be read as claiming otherwise: the DateTime difference chains (BeLessThan(t).Before(x) and the
other four builders) ship on DateTime but not on DateTime?; double / float BeInRange ships but is
exercised only through its negated form; and there is no dedicated nullable ContainEquivalentOf collection
test. These are follow-up top-ups, not part of the current plan.
Framework coupling — an open decision (flagged for review)
The xUnit coupling above (CompareException : XunitException, single xunit.assert reference) is a
deliberately open decision, not a settled one. Whether to add a framework-detection shim so non-xUnit
runners are supported is left for a human to decide — the trade-off, and why it grows more expensive the
longer it waits, is written up in tasks/todo/final_gaps/CLOSEOUT.md.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- xunit.assert (>= 2.9.3)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on FatCat.Testing:
| Package | Downloads |
|---|---|
|
FatCat.Toolkit
Package Description |
|
|
FatCat.Toolkit.WebServer
Package Description |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.2 | 92 | 7/23/2026 |