Precept 0.10.0

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 of Map, 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 sees AssertionContext.IsNegated and .Timeout and answers with AssertionOutcome.Satisfied or AssertionOutcome.Failed(actual, detail) — which is exactly how Precept.Web hands negation and the timeout down to Playwright's Expect() and keeps its call log.

For a one-off, ToSatisfyAsync(predicate, "be …") needs no method at all.

Precept 0.10.0 · MIT · © 2026

Esc