Assertions
await Assert.That(order.Total).ToBeEqualAsync(42.00m);
Every assertion is awaited, and reads as the sentence its failure prints:
Expected order.Total to be equal to 42.00, but found 41.50.
The subject names itself from the expression the test wrote, so nothing has to be repeated in a message. Pass a name when the expression is not the point: Assert.That(total, "the basket total").
The await is the design. An assertion over a value in hand completes synchronously, but the same sentence also has to cover an element that is not on the page yet and a job that finishes eventually — so waiting lives inside the assertion instead of in a sleep in front of it, and the caller does not have to know which kind it just wrote.
What ships#
| Any value | ToBeEqualAsync(x), ToBeSameAsAsync(x), ToBeNullAsync(), ToBeOneOfAsync(a, b), ToSatisfyAsync(predicate, "be …") |
bool |
ToBeTrueAsync(), ToBeFalseAsync() |
IComparable |
ToBeGreaterThanAsync(x), ToBeGreaterThanOrEqualAsync(x), ToBeLessThanAsync(x), ToBeLessThanOrEqualAsync(x), ToBeInRangeAsync(a, b) |
string |
ToContainAsync(s), ToStartWithAsync(s), ToEndWithAsync(s), ToMatchAsync(pattern), ToBeEmptyAsync() |
| Collections | ToContainAsync(item), ToHaveCountAsync(n), ToBeEmptyAsync(), ToOnlyContainAsync(predicate, "be …") |
| Actions | ToThrowAsync<TException>() (returns the exception), ToCompleteAsync() |
Each takes the modifiers in front of it:
await Assert.That(basket.Items).Not.ToBeEmptyAsync();
await Assert.That(total).Because("the promo code is 10% off").ToBeEqualAsync(90m);
Assert.MultipleAsync collects failures instead of stopping at the first, so one run reports every field that is wrong:
await Assert.MultipleAsync(async () =>
{
await Assert.That(summary.Name).ToBeEqualAsync("Ada Lovelace");
await Assert.That(summary.Email).ToBeEqualAsync("ada@example.com");
await Assert.That(summary.Orders).ToHaveCountAsync(3);
});
Waiting for something to become true#
Assert.Eventually takes a way to get the value rather than the value, and re-reads it until the expectation holds or the timeout passes:
await Assert.Eventually(() => Rest.Get($"/jobs/{id}").SendAsync())
.Within(TimeSpan.FromMinutes(2))
.ToHaveJsonValueAsync("state", "Completed");
await Assert.Eventually(() => Db.ScalarAsync<long>("select count(*) from outbox where sent = 0"))
.ToBeEqualAsync(0L);
Every assertion works this way — the waiting belongs to the subject, not to the expectation. A provider that throws counts as "not there yet" and is retried; if the deadline passes it becomes the failure's cause. Defaults are in precept.json:
{
"Assertions": {
"TimeoutMilliseconds": 5000,
"PollIntervalMilliseconds": 100
}
}
On a page#
Precept.Web maps Playwright's web-first assertions onto the same sentence. The retrying is Playwright's — Precept never polls on top of it — so these need no wait in front of them:
var page = await Browser.PageAsync();
await Assert.That(page).ToHaveTitleAsync(new Regex("Checkout"));
await Assert.That(page.GetByRole(AriaRole.Alert)).Not.ToBeVisibleAsync();
await Assert.That(page.GetByTestId("total"), "the basket total")
.Within(TimeSpan.FromSeconds(10))
.ToHaveTextAsync("£42.00");
ToBeVisibleAsync, ToBeHiddenAsync, ToBeAttachedAsync, ToBeEnabledAsync, ToBeDisabledAsync, ToBeEditableAsync, ToBeCheckedAsync, ToBeFocusedAsync, ToBeEmptyAsync, ToBeInViewportAsync, ToHaveTextAsync, ToContainTextAsync, ToHaveValueAsync, ToHaveValuesAsync, ToHaveAttributeAsync, ToHaveClassAsync, ToContainClassAsync, ToHaveIdAsync, ToHaveCssAsync, ToHaveJsPropertyAsync, ToHaveCountAsync, ToHaveRoleAsync, ToHaveAccessibleNameAsync, ToHaveAccessibleDescriptionAsync, ToHaveAccessibleErrorMessageAsync and ToMatchAriaSnapshotAsync cover locators; ToHaveUrlAsync, ToHaveTitleAsync and ToMatchAriaSnapshotAsync cover the page.
ToHaveTextAsync, ToContainTextAsync and ToHaveValuesAsync take several values, in which case the locator has to resolve to one element per value, in that order — a list that grew an item fails rather than passing on the items that still match:
await Assert.That(page.GetByRole(AriaRole.Listitem)).ToHaveTextAsync("Espresso", "Cortado");
ToMatchAriaSnapshotAsync asserts a whole region's roles and names at once, in Playwright's YAML, which describes what the page means rather than how it is built:
await Assert.That(page.GetByRole(AriaRole.Navigation)).ToMatchAriaSnapshotAsync("""
- list:
- listitem:
- link "Orders"
""");
Anything not mirrored — an option Precept does not surface, and anything Playwright adds later — goes through ToSatisfyAsync:
await Assert.That(page.Locator("main")).ToSatisfyAsync(
"be half in the viewport",
expect => expect.ToBeInViewportAsync(new() { Ratio = 0.5f }));
A failed web assertion keeps Playwright's call log, which is the part that says what the page actually looked like on each retry:
Expected the heading to have the text "Sign out", but found 'Sign in'.
Because the page has loaded.
Call log:
- Expect "ToHaveTextAsync" with timeout 700ms
- waiting for Locator("#heading")
8 × locator resolved to <h1 id="heading">Sign in</h1>
- unexpected value "Sign in"
The wait is Web.AssertionTimeoutMilliseconds (5 s), separate from Web.TimeoutMilliseconds — how long an action waits for an element is usually far longer than a check should hang around for. .Within(…) overrides it per assertion.
On a response#
Precept.Api adds ToHaveStatusAsync, ToBeSuccessfulAsync, ToHaveHeaderAsync, ToContainAsync, ToHaveJsonValueAsync and ToRespondWithinAsync, plus narrowing helpers that hand the rest of the response to the general assertions:
var response = await Rest.Get("/orders/42").SendAsync();
await Assert.That(response).ToBeSuccessfulAsync();
await Assert.That(response).JsonAt("customer.email").ToEndWithAsync("@example.com");
await Assert.That(response).Json<Order>().ToSatisfyAsync(o => o.Lines.Count == 2, "have two lines");
Every failure carries the request line and the body, because that is always the next question.
XML#
An API that speaks XML gets the same sentences. WithXmlBody writes a [DataContract] type with
DataContractSerializer, Xml<T> reads one back, and XmlAt reads a single value by XPath — an
element's text, an attribute's value, or what a function such as count() returns:
var response = await Rest.Post("/policies/drafts")
.WithXmlBody(new PolicyDraftRequest { ProductCode = "MOTOR-STD", CustomerId = "CUST-1001" })
.SendAsync();
await Assert.That(response).ToHaveXmlValueAsync("/s:policy/s:status", "Draft");
await Assert.That(response).XmlAt("/s:policy/@number").ToStartWithAsync("POL-");
await Assert.That(response).Xml<Policy>().ToSatisfyAsync(p => p.Premium > 0, "carry a premium");
The s: prefix comes from Api:XmlNamespaces in precept.json. XPath has no default namespace and
a data contract's XML is always in one, so an unprefixed path such as /policy/status selects
nothing in <policy xmlns="urn:sales"> — declare the prefix once and every path can use it:
{ "Api": { "XmlNamespaces": { "s": "urn:sales" } } }
Two things about the serializer decide whether the XML matches the service's schema, and both are
said on the contract rather than in Precept. [DataContract(Namespace = …)] replaces the
serializer's default of http://schemas.datacontract.org/2004/07/…, which no service expects.
[DataMember(Order = n)] fixes the element order, which is alphabetical otherwise — and the
serializer reads in that order too, skipping an element that arrives out of place and leaving the
property at its default with no error. Both methods take a DataContractSerializerSettings for
anything beyond that, passed through untouched. A schema that uses XML attributes is outside what a
data contract can express: send that body with WithBody(xml, "application/xml") and read the
answer with XmlAt, which handles attributes, or from Body.
Writing your own#
An assertion is an extension method on Subject<T> that hands an Expectation<T> to the engine. There is no registration, no base class, and no difference between one you write and one Precept ships:
using Precept.Assertions;
public static class InvoiceAssertions
{
public static Task ToBeOverdueAsync(this Subject<Invoice> subject) =>
subject.ExpectAsync(Expectation.For<Invoice>(
"be overdue", // completes "Expected … to …"
invoice => !invoice.Paid && invoice.DueDate < DateTime.UtcNow,
invoice => $"an invoice due {invoice.DueDate:d} that was {(invoice.Paid ? "paid" : "unpaid")}"));
}
Expected invoice to be overdue, but found an invoice due 12/08/2026 that was unpaid.
That one method now supports everything the built-in assertions do — .Not inverts it, Assert.Eventually retries it, .Because and .Within reach it, and Assert.MultipleAsync collects it — because those live in the engine rather than in the assertion.
Two more pieces are there when a module needs them:
subject.Map(selector, name)narrows a subject to a part of itself, keeping every modifier and the re-reading.Assert.That(response).Status()is one line ofMap, and it composes:Assert.Eventually(…).JsonAt("state").ToBeEqualAsync("Done")re-reads the whole response and re-selects the field on every attempt.new Expectation<T>(description, evaluate)is the raw form, for a check that waits on its own or writes its own failure text. It seesAssertionContext.IsNegatedand.Timeoutand answers withAssertionOutcome.SatisfiedorAssertionOutcome.Failed(actual, detail)— which is exactly howPrecept.Webhands negation and the timeout down to Playwright'sExpect()and keeps its call log.
For a one-off, ToSatisfyAsync(predicate, "be …") needs no method at all.