Fluent assertions for modern .NET

Tests that read clearly and fail helpfully.

OmniAssert is a fluent assertion library for .NET 10 and C# 14. Express test intent in plain language, collect related failures, compare complex objects, and use optional compile-time diagnostics for boolean expressions.

01. Overview

What OmniAssert gives you

OmniAssert makes tests easier to read at the point of writing and easier to diagnose at the point of failure. Assertions start from the value under test, failure messages capture the expression and formatted actual values, and optional compile-time tooling enriches boolean diagnostics.

Readable intent

Start from the subject: user.Email.Must().Contain("@"). The same pattern works across strings, numbers, collections, objects, exceptions, files, dates, GUIDs, URIs, spans, and nullable values.

Better failures

Failures include caller expressions and formatted values. Collections are expanded, object equivalence shows where graphs diverge, and ANSI colour improves terminal output.

Modern diagnostics

The bundled Roslyn generator can rewrite Ensure.Expression(...) call sites at compile time so failed boolean expressions report operand values, not just “condition was false”.

Deep object comparison

BeEquivalentTo performs structural comparison of object graphs, handles collections as unordered multisets, and navigates circular references safely.

Soft assertions

Wrap checks in AssertionScope to collect every failure in a test and report them together when the scope is disposed.

Explicit outcomes

Use Ensure.Succeed() to mark a test or branch as intentionally passing, and Ensure.Fail() to force a failure with an optional message — similar to NUnit’s Assert.Succeed / Assert.Fail.

Provenance note: OmniAssert was created with substantial AI-assisted coding support. If you use it in production, security-sensitive, or compliance-heavy environments, review the implementation, run your own tests, and apply your organisation’s supply-chain policies.
What's new in 2.3: convenience assertions for strings, numbers, dates, collections, and files; EquivalenceOptions for BeEquivalentTo; expanded XML docs on public APIs; documentation site API reference updated for v2.3.0.
What's new in 2.2: OmniAssert.Extensions companion package for web, financial, security, and regional validators; expanded span assertion coverage; improved automated test coverage across Core, Extensions, Analyzers, and Generator.
What's new in 2.1: Ensure.Expression(...) replaces deprecated VerifyExpression(); new analyzers OA005–OA007 for subject-first comparisons, BeEmpty() over HaveCount(0), and redundant NotBeNull() after new; collection BeNull() / NotBeNull() / NotBe(...); string NotContain(...).
Upgrading from v1? Legacy Verify() / To* syntax still compiles in v2 but is obsolete. See the migration guide and bundled Roslyn analyzers OA001–OA007 for migration and style guidance.

02. Prerequisites

What you need before installing

Basic assertions need only the NuGet package and a compatible target framework. Compile-time boolean diagnostics require C# 14 interceptors.

For normal package usage

  • A test project targeting net10.0 or a compatible TFM.
  • The .NET SDK on your PATH.
  • Any test framework — xUnit, NUnit, MSTest, etc. OmniAssert is framework-agnostic.
  • NuGet access to install the OmniAssert package.

For interceptor diagnostics

  • .NET 10 SDK and C# 14 (or later).
  • InterceptorsNamespaces configured if you pin language settings manually.
  • OmniAssertDisableVerifyInterceptors not set to true.

03. Installation

Install and configure OmniAssert

Add the package to your test project, import OmniAssert, then write assertions with .Must(), .VerifyNullable(), Ensure.Expression(...), and the exception and file helpers.

Add the NuGet package

dotnet add package OmniAssert --version 2.3.0

Or add the package reference manually

<ItemGroup>
  <PackageReference Include="OmniAssert" Version="2.3.0" />
</ItemGroup>

Import the namespace

using OmniAssert;

Configure interceptors if needed

Interceptors are bundled and enabled by default. If you pin language settings, ensure C# 14 and the generated namespace are configured:

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <LangVersion>14</LangVersion>
  <InterceptorsNamespaces>$(InterceptorsNamespaces);OmniAssert.Generated</InterceptorsNamespaces>
</PropertyGroup>

To opt out of compile-time boolean diagnostics:

<PropertyGroup>
  <OmniAssertDisableVerifyInterceptors>true</OmniAssertDisableVerifyInterceptors>
</PropertyGroup>

Optionally install Extensions for domain assertions

OmniAssert.Extensions adds web, financial, security, and regional assertions. It requires the core OmniAssert package.

dotnet add package OmniAssert.Extensions --version 2.3.0
<ItemGroup>
  <PackageReference Include="OmniAssert.Extensions" Version="2.3.0" />
</ItemGroup>

04. Quick start

Your first OmniAssert test

This xUnit example validates a user object. The same style works with any .NET test framework because OmniAssert throws assertion exceptions directly.

using OmniAssert;

public sealed record User(int Id, string Email, bool IsActive);

public class UserTests
{
    [Fact]
    public void Should_validate_user()
    {
        var user = new User(42, "ada@example.com", true);

        user.Id.Must().BeGreaterThan(0);
        user.Email.Must().Contain("@");
        user.Email.Must().EndWith(".com");
        user.IsActive.Must().BeTrue();
    }
}
How to read the API: start with the value under test, call Must(), then choose the assertion that describes the expected behaviour. For nullable subjects use VerifyNullable(). For simple booleans use flag.Must().BeTrue(). For compound conditions use Ensure.Expression(x > 0 && count < limit).

Assertions and exceptions together

Combine value checks with exception assertions on the same test. Failure messages include the expression under test and the actual value.

using OmniAssert;

public class OrderServiceTests
{
    [Fact]
    public void CreateOrder_with_invalid_email_fails()
    {
        var email = "not-an-email";

        email.Must().NotBeNullOrEmpty();
        email.Must().Contain("@");

        var ex = (() => OrderService.Create(email)).Throws<ArgumentException>();
        ex.WithMessageContaining("invalid email");
    }
}

05. API reference

Assertion types and methods

Fluent assertions begin with .Must() on any value. Nullable subjects use .VerifyNullable(). Exception testing uses extension methods on Action, Func<Task>, or the static Ensure.Throws<T>() helper. For explicit pass/fail control, use Ensure.Succeed() and Ensure.Fail().

Basic values and numeric

Assertions for standard numeric types including BigInteger (via INumber<T>).

answer.Must().Be(42);
count.Must().BeGreaterThan(0);
order.Total.Must().BePositive();
price.Must().BeApproximately(9.99m, 0.01m);
rate.Must().HaveDecimalPlaces(2);
score.Must().BeOneOf(10, 20, 30);
  • Be(expected) / NotBe(unexpected)
  • BeGreaterThan(threshold) / BeGreaterThanOrEqualTo(threshold)
  • BeLessThan(threshold) / BeLessThanOrEqualTo(threshold)
  • BeInRange(min, max)
  • BeApproximately(expected, precision)
  • BeOneOf(values...)
  • BePositive() / BeNegative() — strictly greater/less than zero
  • BeEven() / BeOdd() — integral values only
  • BeMultipleOf(factor) — exact integer multiple; factor must be non-zero
  • BeFinite() / BeInfinite() — floating-point semantics; integral types always pass BeFinite
  • HaveDecimalPlaces(n) — counts fractional digits after converting to decimal (trailing zeros ignored)

Strings

name.Must().StartWith("John");
sku.Must().BeAlphanumeric();
createdAt.Must().BeIso8601();
token.Must().BeBase64();
configPath.Must().BeAbsolutePath();
  • Contain(substring) / NotContain(substring) — ordinal by default; overloads with StringComparison
  • StartWith(prefix) / EndWith(suffix) / NotEndWith(suffix)
  • Match(regex)
  • BeIgnoringCase(expected) / BeOneOf(values...)
  • BeNull() / NotBeNull()
  • BeEmpty() / NotBeEmpty()
  • BeNullOrEmpty() / NotBeNullOrEmpty()
  • BeNullOrWhiteSpace() / NotBeNullOrWhiteSpace()
  • BeWhiteSpace() / NotBeWhiteSpace()
  • HaveLength(n) / HaveLengthGreaterThan(n) / HaveLengthLessThan(n) / HaveLengthBetween(min, max)
  • BeTrimmed() — no leading or trailing whitespace
  • BeAlphanumeric() / BeLowerCase() / BeUpperCase() / BeNumeric()
  • BeBase64() / BeHexString() / BeHexColor() — format validators
  • BeIso8601() / BeDateString(format) — parsed with InvariantCulture
  • BeAbsolutePath() / BeRelativePath() — syntactic check via Path.IsPathRooted (OS-specific)

Collections and dictionaries

users.Must().HaveCountBetween(1, 10);
roles.Must().BeSubsetOf(allRoles);
items.Must().ContainOnly(allowedStatuses);
results.Must().BeEmpty();
settings.Must().ContainKey("Theme");
  • Be(expected) / NotBe(expected) — reference equality on the collection instance
  • HaveCount(n), HaveCountGreaterThan(n), HaveCountLessThan(n), HaveCountBetween(min, max)
  • HaveCountMatching(n, predicate)
  • BeEmpty() / NotBeEmpty()
  • Contain(item) / NotContain(item)
  • Contain(predicate), AllSatisfy(predicate), AnySatisfy(predicate), NoneSatisfy(predicate)
  • ContainOnly(allowed) / ContainOnly(predicate) — every element must be permitted or match
  • BeSubsetOf(expected) / BeSupersetOf(expected) — set semantics with default equality
  • BeUnique() / HaveUniqueCount(n)
  • BeInAscendingOrder() / BeInDescendingOrder() — optional key-selector overloads
  • BeEquivalentTo(other) — multiset equivalence, order ignored
  • BeEquivalentTo(other, EquivalenceOptions) — relaxed string case and/or nested sequence order
  • BeSequenceEqual(other) — exact sequence, same order
  • ContainInOrder(items...) — items appear in order, not necessarily consecutively
  • BeNull() / NotBeNull()
  • Dictionaries: ContainKey, NotContainKey, ContainValue, NotContainValue, HaveValue(key, value)

Objects and equivalence

instance.Must().BeOfType<MyClass>();
actualDto.Must().BeEquivalentTo(expectedDto);
actual.Must().BeEquivalentTo(expected, new EquivalenceOptions
{
    IgnoreCase = true,
    IgnoreCollectionOrder = true
});
  • Be(expected) — reference equality
  • BeNull() / NotBeNull()
  • BeOfType<T>() / NotBeOfType<T>()
  • BeAssignableTo<T>() / NotBeAssignableTo<T>()
  • BeEquivalentTo(expected) — recursive property comparison with coloured diff
  • BeEquivalentTo(expected, EquivalenceOptions) — tune case and nested collection order
  • EquivalenceOptions.IgnoreCase — ordinal case-insensitive strings in the graph
  • EquivalenceOptions.IgnoreCollectionOrder — multiset comparison for nested sequences

Exceptions and tasks

Action act = () => service.DoWork();
act.Throws<InvalidOperationException>().WithMessage("Failed");

Func<Task> asyncAct = () => service.DoWorkAsync();
await asyncAct.ThrowsAsync<ArgumentException>();

await TimeSpan.FromSeconds(2).CompleteWithin(() => longRunningTask);
  • Throws<T>() / ThrowsAsync<T>()
  • NotThrow() / NotThrowAsync()
  • WithMessage(expected), WithMessageContaining(substring), WithMessageMatching(wildcard)
  • WithMessageIgnoringCase(expected)
  • WithInnerException<TInner>()
  • CompleteWithin(action) on TimeSpan

Dates, times, and other types

createdAt.Must().BeInPast();
dueDate.Must().BeSameDayAs(expected);
birthday.Must().BeToday();
timeSpan.Must().BePositive();
guid.Must().NotBeEmpty();
  • DateTime / DateTimeOffset: Be, NotBe, BeBefore, BeAfter, BeWithin, BeInPast, BeInFuture, BeSameDayAs
  • BeInPast / BeInFuture compare against UtcNow — use UTC subjects
  • DateOnly: Be, BeBefore, BeAfter, HaveYear, HaveMonth, HaveDay, BeToday, BeYesterday, BeTomorrow, BeWeekday, BeWeekend, BeLeapYear, BeWithinDays(n, anchor)
  • DateOnly “today” helpers use the local clock (DateTime.Today)
  • TimeOnly: Be, BeBefore, BeAfter
  • TimeSpan: Be, NotBe, BePositive, BeNegative, range and ordering helpers
  • Guid: Be, BeEmpty, NotBeEmpty, BeOneOf
  • Enum: Be, NotBe, BeOneOf
  • Uri: Be, HaveScheme, HaveHost, HavePath, HaveQuery

Spans and memory slices

Zero-allocation assertions for ReadOnlySpan<T>, Span<T>, ReadOnlyMemory<T>, and Memory<T> via .Must().

ReadOnlySpan<byte> header = buffer.AsSpan(0, 4);
header.Must().StartWith(magicBytes);
header.Must().HaveCountBetween(1, 64);
  • Equal / NotEqual, BeEmpty / NotBeEmpty
  • HaveLength / HaveCount, count bounds including HaveCountBetween
  • Contain, NotContain, StartWith, EndWith
  • BeUnique, ordering, predicate helpers — same vocabulary as collections
  • BeEquivalentTo, BeSequenceEqual, ContainInOrder

File system

"config.json".FileExists().HaveContent(expectedJson);
"log.txt".FileExists().NotBeEmpty().HaveExtension(".txt");
"temp".DirectoryExists().BeEmpty();
  • FileExists()HaveContent(text), BeEmpty(), NotBeEmpty(), HaveExtension(ext)
  • HaveExtension — leading dot optional, comparison is case-insensitive
  • DirectoryExists()BeEmpty()

Names from other libraries

Common assertion names elsewhere and their OmniAssert spelling:

BeDistinct / HaveUniqueItems BeUnique()
BeSorted / BeInAscendingOrder BeInAscendingOrder()
MatchRegex / BeRegex Match(pattern)
BeNumericString BeNumeric()
BeExistingFile path.FileExists()
HaveSameElementsAs BeEquivalentTo() (unordered) / BeSequenceEqual() (ordered)
BeQuasiEquivalentTo BeEquivalentTo(expected, new EquivalenceOptions { IgnoreCase = true, IgnoreCollectionOrder = true })

Booleans: Must() vs Ensure.Expression()

// Fluent — consistent with other assertions
isValid.Must().BeTrue();

// Subject-first comparison (preferred over wrapping comparisons in BeTrue)
count.Must().BeLessThan(limit);

// Expression — compound boolean logic with rich diagnostics
Ensure.Expression(x > 0 && count < limit);

The legacy extension (condition).VerifyExpression() still works in v2.1 but is deprecated; use Ensure.Expression(condition) for new code. It will be removed in v3.

Explicit outcomes: Ensure.Succeed() and Ensure.Fail()

Static helpers for tests that need an intentional pass marker or a forced failure without asserting on a subject value. Useful for placeholder tests, documented no-ops, unreachable branches, and framework-agnostic equivalents of NUnit’s Assert.Succeed / Assert.Fail.

// Mark a test as intentionally passing (no checks performed)
Ensure.Succeed();

// Force failure with default message: "Verification failed: explicit failure."
Ensure.Fail();

// Force failure with a custom message (prefix added when omitted)
Ensure.Fail("not implemented yet");
// → "Verification failed: not implemented yet"

// Message already prefixed is used as-is
Ensure.Fail("Verification failed: custom wording");
  • Ensure.Succeed() — completes without throwing; performs no verification
  • Ensure.Fail(message?) — throws OmniAssertionException immediately, unless an enclosing AssertionScope is collecting failures

05b. Extensions

Domain-specific assertions

OmniAssert.Extensions extends the .Must() fluent API with validators for web payloads, financial identifiers, security tokens, and regional business rules. Import only the namespaces you need.

Prerequisite: Extensions requires the core OmniAssert package at the same version. Add both PackageReference entries to your test project.

Installation

<ItemGroup>
  <PackageReference Include="OmniAssert" Version="2.3.0" />
  <PackageReference Include="OmniAssert.Extensions" Version="2.3.0" />
</ItemGroup>

Namespaces

OmniAssert.Extensions.Web Email, URL, JSON/XML/HTML, IP, hostname, MAC, HTTP status, content-type, network reachability
OmniAssert.Extensions.Financials GUID, ULID, CUID, ISBN, IMEI, credit card (Luhn), IBAN (mod-97)
OmniAssert.Extensions.Security Password strength, hash formats, JWT structure, API keys, OAuth tokens
OmniAssert.Extensions.Regional Nigerian identifiers (phone, NUBAN, BVN, NIN, postal), global phone/postal/SSN, age and business hours

Quick examples

using OmniAssert;
using OmniAssert.Extensions.Web;
using OmniAssert.Extensions.Financials;
using OmniAssert.Extensions.Security;
using OmniAssert.Extensions.Regional;

// Web
"user@acme.com".Must().BeEmailAddress();
"https://api.example.com/v1".Must().BeUrl();
"{\"id\":1}".Must().BeJson();

// Financial
"978-0-306-40615-7".Must().BeIsbn();
"GB29NWBK60161331926819".Must().BeIban();

// Security
"MyP@ssw0rd!".Must().BeStrongPassword(minLength: 12);
token.Must().BeJwt();

// Regional
"+2348012345678".Must().BeNigerianPhoneNumber();
"0022728153".Must().BeNigerianBankAccountNumber("058");

Web, network & communications

String extensions unless noted. Use core Uri assertions (HaveScheme, HaveHost, …) when you already have a parsed Uri object; use Extensions BeUrl() to validate string format before parsing.

BeEmailAddress() Non-empty; MailAddress parseable; local-part and domain present; domain contains .; no spaces; max 254 characters (practical RFC 5322 subset).
BeUrl() / BeAbsoluteUrl() Absolute URI with http or https scheme and non-empty host.
BeRelativeUrl() Starts with /; not parseable as an absolute URI (no scheme/host).
BeSlug() Lowercase alphanumeric segments separated by single hyphens; no leading/trailing hyphen (^[a-z0-9]+(?:-[a-z0-9]+)*$).
BeJson() Non-empty; strict JsonDocument.Parse (malformed or trailing junk fails).
BeXml() Non-empty; well-formed XML via XDocument.Parse.
BeHtml() Document heuristic: <html>…</html> / <body>…</body>, or opening tag plus matching closing tag (not a full HTML5 parser).
BeIpAddress() / BeValidIpAddress() IPAddress.TryParse succeeds (IPv4 or IPv6).
BeIpv4() / BeIpv6() Parsed address family is InterNetwork or InterNetworkV6 respectively.
BeValidHostname() Labels 1–63 chars; total ≤ 253; optional trailing dot stripped; each label matches hostname label rules.
BeValidMacAddress() / BeMacAddress() AA:BB:CC:DD:EE:FF, hyphen-separated, or 12 contiguous hex digits.
BeValidPort() On NumericAssertions<int>: value in 065535.
BeHttpStatusCode(int) / BeSuccessStatusCode() On NumericAssertions<int>: exact match, or any 2xx code.
BeReachable() / BeReachableAsync() Live HTTP check: prepends https:// when no scheme; HEAD then GET fallback; 2xx/3xx = reachable; default timeout 5s (optional TimeSpan?). Set OMNIASSERT_SKIP_NETWORK=1 in CI to skip without failing.

HTTP response assertions

Use HttpResponseMessage.Must() from OmniAssert.Extensions.Web:

using var response = await client.GetAsync("/api/users");
response.Must().BeSuccessStatusCode();
response.Must().HaveContentType("application/json");

404.Must().BeHttpStatusCode(404);
BeSuccessStatusCode() Status code in 200299.
BeHttpStatusCode(int) Exact status code match.
HaveContentType(string) Content-Type media type equals expected (case-insensitive; charset/parameters ignored).

Financial & industry identifiers

BeGuid(GuidFormat?) Guid.TryParse; optional GuidFormat enum (N, D, B, P, X) requires string to match that layout exactly.
BeUlid() 26 Crockford base32 characters; rejects I, L, O, U.
BeCuid() Starts with c; 24–25 lowercase alphanumeric characters (CUID v1 pattern).
BeIsbn() ISBN-10 (mod 11 check digit, X allowed) or ISBN-13 (978/979 prefix, mod 10 check digit); hyphens/spaces stripped.
BeImei() Exactly 15 digits; Luhn-valid including check digit.
BeCreditCard() / BeCreditCardNumber() 13–19 digits after stripping separators; Luhn-valid.
BeIban() Length 15–34; country code + check digits; mod-97 rearrangement validation (IBAN standard).

Security, authentication & cryptography

BeStrongPassword(minLength?) Default minLength 8; requires uppercase, lowercase, digit, and symbol from a documented punctuation set.
BeSha256Hash() Exactly 64 hex characters (optional 0x prefix stripped).
BeMd5Hash() Exactly 32 hex characters.
BeHashedWith(HashType) HashType: Md5 (32 hex), Sha256 (64 hex), Sha512 (128 hex).
BeJwtToken() / BeJwt() Three dot-separated Base64Url segments; decoded header JSON contains alg; payload is a JSON object.
BeApiKey() Length 16–128; [A-Za-z0-9_-]; at least one letter and one digit.
BeOAuthToken() Optional Bearer prefix stripped; length ≥ 20; RFC 6750-style token character set.

Regional, demographic & business rules

BeNigerianPhoneNumber() +234 or leading 0; normalized to Nigerian mobile prefixes (e.g. 701–709, 802–809, 811, 813–819, 901–909).
BeNigerianBankAccountNumber() Format-only: exactly 10 digits.
BeNigerianBankAccountNumber(bankCode) Full NUBAN check: 10-digit account + 3-digit bank code; weighted mod-10 check digit verification.
BeNigerianBvn() / BeNigerianNin() Exactly 11 digits; rejects all-identical digits.
BeNigerianPostalCode() Exactly 6 digits.
BePhoneNumber() E.164-style: optional +; 8–15 total digits.
BePostalCode(CountryCode) CountryCode: US, GB, CA, NG, or Generic (default). Each applies country-specific patterns.
BeSocialSecurityNumber() US SSN: rejects invalid area (000, 666, 9xx), group 00, serial 0000.
BeAdult(age?) On DateTime / DateOnly; default age 18; computed against UTC calendar date today.
BeBirthday() Month and day match local calendar date today (DateTime.Today / DateOnly equivalent).
BeBusinessHours(timeZone?) Weekday (Mon–Fri); time in [09:00, 17:00) local or converted via optional TimeZoneInfo.

Failure messages & soft assertions

Extensions use the same VerificationFlow as core OmniAssert. Failures are prefixed with Verification failed: and include the captured expression (for example email) and the actual value. Inside an AssertionScope, failures are collected and reported together.

Extensions FAQ

When should I use Extensions vs core string Match(regex)?

Prefer Extensions when you need a named, documented validator (IBAN mod-97, NUBAN, JWT structure) with consistent failure wording. Use core Match for one-off project-specific patterns.

Why does BeReachable() pass in CI without network access?

Set OMNIASSERT_SKIP_NETWORK=1. Reachability assertions become no-ops so pipelines without outbound HTTP stay deterministic.

Do I need both packages at the same version?

Yes. OmniAssert.Extensions declares a NuGet dependency on OmniAssert at the matching version. Mismatched versions can cause missing APIs or assertion struct incompatibilities.

06. Advanced usage

Real-world test patterns

Soft assertions, explicit pass/fail outcomes, deep object comparison, predicate-based collection checks, async exception testing, and compile-time boolean operand capture.

Explicit outcomes with Ensure.Succeed() and Ensure.Fail()

Use Ensure.Succeed() when a test must pass without performing checks — for example a placeholder while behaviour is still being defined, or a branch that documents intentional no-op behaviour. Use Ensure.Fail() when a code path should never run, or when you need to abort a test with a clear message before v3-style guard assertions exist.

[Fact]
public void Placeholder_until_feature_lands()
{
    // Documents that the test harness is wired; replace with real checks later.
    Ensure.Succeed();
}

[Fact]
public void Should_reject_unsupported_platform()
{
    if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
    {
        Ensure.Fail("This integration test runs on Linux CI agents only.");
    }

    service.RunLinuxOnlyWorkflow();
}

Inside an AssertionScope, Ensure.Fail() is collected like any other failure and reported when the scope is disposed — it does not throw immediately.

using (new AssertionScope())
{
    customer.Id.Must().BeGreaterThan(0);
    Ensure.Fail("billing address not yet validated");
    // Both failures are reported together when the scope ends
}

Soft assertions with AssertionScope

using (new AssertionScope())
{
    user.Name.Must().Be("John");
    user.Age.Must().Be(30);
    user.Email.Must().Contain("@");
}

Deep object comparison

BeEquivalentTo compares object graphs recursively and reports the path where values diverge. On collections it treats sequences as multisets — same elements in any order. Use BeSequenceEqual when order matters. Pass EquivalenceOptions to relax string case and nested collection order instead of inventing a separate “quasi-equivalent” API.

var expected = new InvoiceDto
{
    Number = "INV-2026-001",
    Lines =
    [
        new InvoiceLineDto("Consulting", 2, 750m),
        new InvoiceLineDto("Support", 1, 125m)
    ]
};

var actual = await client.GetInvoiceAsync("INV-2026-001");
actual.Must().BeEquivalentTo(expected);

// Case-insensitive strings + order-insensitive nested sequences
actual.Must().BeEquivalentTo(expected, new EquivalenceOptions
{
    IgnoreCase = true,
    IgnoreCollectionOrder = true
});

Collection sets and bounds

roles.Must().BeSubsetOf(allRoles);
permissions.Must().BeSupersetOf(requiredPermissions);
tags.Must().ContainOnly(allowedTags);
batch.Must().HaveCountBetween(1, 100);

Collections, predicates, and ordering

results.Must().HaveCount(documents.Count);
results.Must().BeUnique();
results.Must().AllSatisfy(r => r.Score > 0);
results.Must().AnySatisfy(r => r.Title.Contains("OmniAssert"));
results.Must().NoneSatisfy(r => r.Status == "Failed");
results.Must().BeInDescendingOrder(r => r.Score);

Exception and asynchronous assertions

Action syncAct = () => parser.Parse("");
syncAct.Throws<ArgumentException>()
    .WithMessageContaining("input");

Func<Task> asyncAct = () => service.CreateUserAsync(invalidRequest);
await asyncAct.ThrowsAsync<ValidationException>();

await TimeSpan.FromSeconds(2).CompleteWithin(
    () => service.RebuildProjectionAsync());

Compile-time boolean diagnostics

The bundled Roslyn generator rewrites supported Ensure.Expression(...) call sites at compile time. Legacy VerifyExpression() call sites are still intercepted for compatibility. When active, a bare identifier such as Ensure.Expression(flag) becomes flag.Must(expression).BeTrue(), and compound expressions capture operand snapshots on failure.

var count = orders.Count;
var limit = 10;
var hasPriorityOrder = orders.Any(o => o.Priority == Priority.High);

Ensure.Expression(count > 0 && count <= limit && hasPriorityOrder);

File system and URI checks

"appsettings.test.json".FileExists().HaveContent(expectedJson);
"artifacts".DirectoryExists().BeEmpty();

var callback = new Uri("https://github.com/bolorundurowb/OmniAssert");
callback.Must().HaveScheme("https");
callback.Must().HaveHost("github.com");

07. Assertion style

Write clearer tests with analyzers

OmniAssert ships Roslyn analyzers that migrate legacy v1 syntax (OA001–OA004) and suggest clearer, subject-first patterns (OA005–OA007). All rules are bundled in the NuGet package.

OA001 Warning — legacy Assert.* entry point → use Ensure.*
OA002 Warning — legacy .Verify() fluent root → use .Must()
OA003 Warning — legacy To* / NotTo* grammar → use Be* / Not* / Have*
OA004 Warning — legacy VerifyExpression(...) → use Ensure.Expression(...)
OA005 Warning — (a < b).Must().BeTrue() / BeFalse() → rewrite as a.Must().BeLessThan(b) (numeric / TimeSpan only)
OA006 Warning — collection.Must().HaveCount(0) → use collection.Must().BeEmpty()
OA007 Info — new Widget().Must().NotBeNull() is redundant (a new expression cannot be null)

Preferred patterns

Subject-first comparisons

// Avoid
(count < limit).Must().BeTrue();

// Prefer
count.Must().BeLessThan(limit);

Empty collections

// Avoid
results.Must().HaveCount(0);

// Prefer
results.Must().BeEmpty();

String negation

// Avoid
(path.Contains('/')).Must().BeFalse();

// Prefer
path.Must().NotContain('/');

Compound boolean logic

// When a single comparison assertion does not apply
Ensure.Expression(x > 0 && count < limit && hasPriority);

OA005 suggests Ensure.Expression(...) when the left-hand side is not a numeric or TimeSpan subject.

08. Migration

Upgrading from v1.x to v2

OmniAssert v2 modernises the fluent API, avoids naming clashes with NUnit’s Assert, and ships Roslyn analyzers to automate migration. Legacy v1 syntax still compiles but is [Obsolete] — plan to migrate before v3 removes it.

v3.0 removal plan: The following will be removed in the next major release: Assert, .Verify(), all To* / NotTo* methods, the Expect wrapper, and extension VerifyExpression(...). Use Ensure.Expression(...) instead. VerifyNullable, exception extensions, and file helpers are not scheduled for removal.

Summary of changes

v1 (legacy) v2 (recommended)
Assert static entry point Ensure
.Verify() .Must()
.ToBeTrue(), .ToBe(42), .ToContain("x") .BeTrue(), .Be(42), .Contain("x")
.NotToBe(...), .NotToBeNull() .NotBe(...), .NotBeNull()
.ToHaveCount(3), .ToBeGreaterThan(0) .HaveCount(3), .BeGreaterThan(0)
Expect.Throws<T>(...) Ensure.Throws<T>(...) or (...).Throws<T>()
Assert.Succeed() / Assert.Fail(...) Ensure.Succeed() / Ensure.Fail(...)
(condition).VerifyExpression() Ensure.Expression(condition)

Methods that never used a To prefix in v1 are unchanged — for example ContainKey, HaveHost, AllSatisfy, and file helpers like HaveContent.

Entry point: AssertEnsure

v1 used a static class named Assert, which conflicts with NUnit, xUnit, and other frameworks.

// v1
OmniAssert.Assert.VerifyExpression(condition);

// v2 (legacy extension — still works with warnings)
Ensure.VerifyExpression(condition);

// v2 (recommended)
Ensure.Expression(condition);

Fluent chain: Verify()Must()

// v1
count.Verify().ToBeGreaterThan(0);
user.Email.Verify().ToContain("@");

// v2
count.Must().BeGreaterThan(0);
user.Email.Must().Contain("@");

Assertion grammar: drop the To prefix

General rules:

ToBeTrue() / ToBeFalse()BeTrue() / BeFalse()
ToBe(expected)Be(expected)
NotToBe(unexpected)NotBe(unexpected)
ToBeNull() / NotToBeNull()BeNull() / NotBeNull()
ToContain(...) / NotToContain(...)Contain(...) / NotContain(...)
ToBeEquivalentTo(...)BeEquivalentTo(...)
ToBeOfType<T>()BeOfType<T>()

What did not change

Booleans: Must() vs Ensure.Expression()

// Fluent (v2)
isValid.Must().BeTrue();

// Expression style (recommended for compound conditions)
Ensure.Expression(x > 0 && count < limit);

// Legacy extension (deprecated — removed in v3)
(x > 0 && count < limit).VerifyExpression();

When interceptors are enabled, Ensure.Expression(flag) is rewritten at compile time to flag.Must(expression).BeTrue(). Legacy VerifyExpression() call sites are still intercepted for compatibility.

Automated migration (Roslyn analyzers)

The OmniAssert NuGet package includes OmniAssert.Analyzers.dll — no extra package reference is required.

OA001 Legacy OmniAssert.Assert → replace with Ensure
OA002 .Verify() fluent entry → replace with .Must()
OA003 Legacy To* / NotTo* methods → replace with Be* / Not* / Have*
OA004 Legacy VerifyExpression(...) → rewrite to Ensure.Expression(...)

Style analyzers OA005–OA007 are documented in the assertion style section.

Applying fixes

  1. Build the solution so analyzers load from the package.
  2. Open a file with legacy syntax (light bulb on OA001–OA004).
  3. Choose Migrate to Ensure/Must/Be* syntax, or Fix all occurrences in Solution.
// Before
Assert.Throws<InvalidOperationException>(() => act());
value.Verify().ToBeFalse();
items.Verify().NotToBeNull();
(x > y).VerifyExpression();

// After
Ensure.Throws<InvalidOperationException>(() => act());
value.Must().BeFalse();
items.Must().NotBeNull();
Ensure.Expression(x > y);

Framework name collisions

If you used Expect to avoid clashing with NUnit’s Assert, prefer Ensure in v2. The Expect wrapper remains in v2 but will be removed in v3.

// v1
Expect.Throws<ArgumentException>(() => act());

// v2
Ensure.Throws<ArgumentException>(() => act());
// or
((Action)act).Throws<ArgumentException>();

Recommended migration steps

  1. Upgrade the OmniAssert package to v2.x (latest: 2.3.0).
  2. Build and review OA001–OA004 warnings.
  3. Run code fixes (per file or fix all in solution).
  4. Search for remaining .Verify(), .ToBe, VerifyExpression, or OmniAssert.Assert usages.
  5. Run tests and confirm failure messages still read as expected.
  6. Optionally treat warnings as errors until migration is complete.
<PropertyGroup>
  <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  <WarningsNotAsErrors>OA001;OA002;OA003;OA004</WarningsNotAsErrors>
</PropertyGroup>
Need help? Open an issue with a minimal repro if a code fix does not apply cleanly or you find a v1 pattern with no v2 equivalent.

09. Troubleshooting

Frequently asked questions

Most setup issues come from target framework mismatches, missing namespace imports, or pinned language settings.

Why does .Must() not appear in IntelliSense?

Confirm the package is referenced by the test project and using OmniAssert; is in scope. Restore packages and reload the project if you use central package management.

Why are interceptor diagnostics not showing operand values?

Check that the project builds with the .NET 10 SDK and C# 14, and that OmniAssertDisableVerifyInterceptors is not true. If you pin language settings, add OmniAssert.Generated to InterceptorsNamespaces.

Should I use Must().BeTrue() or Ensure.Expression()?

Use Must().BeTrue() for simple boolean values. Prefer subject-first comparisons such as count.Must().BeLessThan(limit) over (count < limit).Must().BeTrue() (OA005). Use Ensure.Expression(...) when a compound condition cannot be expressed as a single comparison assertion. The legacy VerifyExpression() extension is deprecated.

Why did my collection equivalence assertion pass even though the order changed?

BeEquivalentTo for collections treats sequences as multisets. Use BeSequenceEqual or ordering assertions such as BeInAscendingOrder() when order is part of the behaviour under test.

Why do I see several failures in one exception?

The assertions were probably inside an AssertionScope. The scope collects failures and throws an aggregate result when disposed — this is expected.

Can I use OmniAssert with NUnit, xUnit, or MSTest?

Yes. OmniAssert is a standalone assertion library. Import the namespace and use it from whichever .NET test framework your project already uses. v2's Ensure entry point avoids clashing with NUnit's Assert.

Is OmniAssert.Extensions a required dependency?

No. Extensions is an optional companion package. The core OmniAssert package includes all standard assertions. Install OmniAssert.Extensions only if you need domain-specific validators for web, financial, security, or regional checks.

How do I skip network-dependent assertions in CI?

Set the environment variable OMNIASSERT_SKIP_NETWORK=1 before running tests. When this variable is set, BeReachable() and BeReachableAsync() pass immediately without making any network calls. This prevents flaky tests in CI environments without network access.

Does BeNigerianBankAccountNumber() validate the bank code?

Without a bank code parameter, the assertion checks only that the value is exactly 10 digits (format-only). Use the overload BeNigerianBankAccountNumber(string bankCode) for full NUBAN validation that verifies the check digit against the 3-digit bank code.

I upgraded from v1 — why do I see compiler warnings?

Legacy Verify(), To*, and VerifyExpression APIs are marked obsolete in v2. Apply the OA001–OA004 code fixes or follow the migration guide. Legacy syntax will be removed in v3.

When should I use Ensure.Succeed() or Ensure.Fail()?

Use Ensure.Succeed() for intentional no-op or placeholder tests where no subject value is under verification. Use Ensure.Fail() to abort a test with a message — for example an unsupported platform branch or a path that should be unreachable. Prefer fluent .Must() assertions when you have a value to check. If you used NUnit’s Assert.Succeed / Assert.Fail, switch to the Ensure equivalents to avoid clashing with NUnit’s Assert type.

10. Contributing

Contributing and support

OmniAssert is open source. Contributions are most useful when they are focused, tested, and clear about the behaviour they change.

Report issues

Use GitHub issues for bug reports, documentation gaps, feature requests, or confusing diagnostics. Include the OmniAssert version, target framework, test framework, a minimal reproduction, and failure output.

Open an issue on GitHub

Submit changes

  1. Fork and clone the repository.
  2. Build and test the solution locally.
  3. Make focused changes with accompanying tests.
  4. Open a pull request with a concise description.
dotnet build src/OmniAssert.slnx
dotnet test src/OmniAssert.slnx
dotnet test src/OmniAssert.slnx /p:CollectCoverage=true