Assertions

Collection assertions

assertThatCollection(name): existence, counts, indexes, and per-document conditions on a collection that is re-read at the moment each assertion runs.

How reading works

assertThatCollection("orders") does not read anything yet. Every method on the returned assertion re-reads the collection through the shim when it runs, so an assertion chain always describes current state. An assertion written after a successful await sees what the await saw. Unqualified names resolve against the default database; "billing.invoices" targets another one.

Every failure names the collection in angle brackets (<test.orders>) and, by default, appends the collection's contents as a fixed-width table. The failure dump shown on the overview.

exists and isEmpty

exists() verifies the collection physically exists in its database. isEmpty() verifies it holds no documents. It may still exist, with its indexes.

Java
// The service is supposed to create the collection on first write
orderService.place(order);

assertThatCollection("orders").exists();

// The reaper is supposed to clear the staging area
reaper.run();

assertThatCollection("order_staging").isEmpty();

On failure, exists() lists what the database actually holds: Expected collection <test.orders> to exist, but database <test> holds [audit_log, outbox].. This catches the misspelled-collection-name mistake immediately. isEmpty() reports the count it found, plus the dump.

Counting documents

hasDocumentCount(n) is exact; its predicate overload counts only documents whose nested assertions pass, which makes a total-plus-breakdowns chain easy to read.hasDocumentCountAtLeast(n) and its predicate overload are the tolerant forms for collections that a background writer may still be appending to. hasSingleDocument() is the same check as hasDocumentCount(1), named for the case that dominates single-aggregate and upsert tests, where “there should be exactly one row” is the point of the test, and reading it as such beats a bare literal 1.

Java
assertThatCollection("orders").hasDocumentCount(2);

// Assert the total, then the exact owner/status breakdown
assertThatCollection("orders")
    .hasDocumentCount(100)
    .hasDocumentCount(50, d -> d.hasField("owner", "GORDON").hasField("status", "AVAILABLE"))
    .hasDocumentCount(50, d -> d.hasField("owner", "ENACT").hasField("status", "AVAILABLE"));

// Exactly one row: reads as the intent of a single-aggregate or upsert test
assertThatCollection("orders").hasSingleDocument();

// When a background writer may still be appending, assert the floor
assertThatCollection("audit_log").hasDocumentCountAtLeast(1);
assertThatCollection("audit_log").hasDocumentCountAtLeast(1,
    d -> d.hasField("event", "ORDER_PLACED"));

The failure message states both numbers: Expected collection <test.orders> to hold 3 documents, but it holds 2., followed by the dump, which is usually the whole diagnosis. hasSingleDocument() reads in the same shape: Expected collection <test.orders> to hold a single document, but it holds 2.

Since 3.4.0 both count methods also take a Hamcrest Matcher<? super Long>, which is how you express a range without inventing a second assertion. The two-argument form applies the matcher to the number of documents that passed the nested assertions, exactly as the long overload does.

Java
import static org.hamcrest.Matchers.*;

// A range instead of an exact number: the count is a long
assertThatCollection("orders").hasDocumentCount(greaterThan(3L));
assertThatCollection("orders").hasDocumentCount(both(greaterThan(3L)).and(lessThan(10L)));

// The matcher applies to the count of documents passing the nested assertions
assertThatCollection("orders")
    .hasDocumentCount(greaterThanOrEqualTo(2L),
        d -> d.hasField("status", "CONFIRMED"));

Index assertions

hasIndexOn(field) passes when any index covers the field, including as part of a compound key. hasUniqueIndexOn(field) requires the covering index to be unique. The assertion that a @Indexed(unique = true) really reached the store. A non-unique index on the same field satisfies the first while doing none of the work the test cares about, which is why the second exists.

Java
// Did @Indexed(unique = true) on orderNumber actually reach the store?
assertThatCollection("orders")
    .hasIndexOn("customerId")
    .hasUniqueIndexOn("orderNumber");

On failure both methods describe every index the collection actually has, by name and key: but its indexes are [_id_ on {_id=1}]. The implicit _id_ index counts as a unique index on _id, matching the server, even though listIndexes() reports it without a unique flag.

Since 3.5.0, hasCompoundIndexOn asserts the complete key definition: field order and direction both matter. It gives no prefix credit, so an index on {status, nextRetryAt, attempt} does not prove that the intended two-field index was created. hasTtlIndexOn also requires the exact expiry duration. Its failure calls out prefix, reversed-order, and single-field near misses.

Java
assertThatCollection("retries")
    .hasCompoundIndexOn("status", "nextRetryAt")      // ascending, exactly this order
    .hasCompoundIndexOn(new Document("priority", -1))  // directions via the Document form
    .hasTtlIndexOn("failedAt", Duration.ofDays(30));    // expireAfterSeconds must equal

containsDocumentMatching

Takes a real MongoDB query document, using the same syntax you would type into a shell, parsed with Document.parse and evaluated by the same filter engine your application's queries run through. {amount: {$gt: 100}} means exactly what it means in a repository method.

Java
assertThatCollection("orders")
    .containsDocumentMatching("{status: 'CONFIRMED', amount: {$gt: 100}}");

The failure restates the filter and the count: Expected collection <test.orders> to contain a document matching <{status: 'CONFIRMED'}>, but none of its 2 documents did. Because the filter goes through the real engine, an operator the emulation refuses fails fast here exactly as it would in application code. See the compatibility matrix.

Per-document conditions

Three quantifiers hand each candidate document to a nested document assertion:

  • containsDocumentSatisfying(d -> ...): at least one document passes.
  • containsExactlyOneDocumentSatisfying(d -> ...): the collection holds exactly one document and that document passes. The strict sibling of containsDocumentSatisfying: where that one passes the moment any of many documents matches, this one makes the count part of the claim.
  • allDocumentsSatisfy(d -> ...): every document passes; an empty collection passes vacuously.
  • noDocumentSatisfies(d -> ...): no document passes.
Java
// At least one confirmed order for this customer
assertThatCollection("orders").containsDocumentSatisfying(d -> d
    .hasField("status", "CONFIRMED")
    .hasField("customerId", "cust-42"));

// Exactly one order, and it is the confirmed one: the upsert wrote a single aggregate.
// Dotted field names walk into nested documents, exactly as a query would.
assertThatCollection("orders").containsExactlyOneDocumentSatisfying(d -> d
    .hasField("status", "CONFIRMED")
    .hasField("customer.tier", "GOLD")
    .hasJsonPath("$.shipping.city", "London"));

// Every document belongs to the tenant under test
assertThatCollection("orders").allDocumentsSatisfy(d -> d
    .hasField("tenantId", "t-1"));

// The saga compensated: nothing is left half-done
assertThatCollection("orders").noDocumentSatisfies(d -> d
    .hasField("status", "PAYMENT_PENDING"));

The failure messages carry the evidence for their quantifier: containsDocumentSatisfying appends the first document's own failure (“First document's failure: …”), allDocumentsSatisfy names the index of the offending document and nests its failure, and noDocumentSatisfies names the index of the document that unexpectedly passed.

containsExactlyOneDocumentSatisfying checks the count first. A collection holding zero or more than one document fails on the count alone: Expected collection <test.orders> to hold a single document satisfying the assertions, but it holds 2., and the nested assertions never run, because “there is exactly one” is itself what the test asserts. When there is exactly one document but it fails the nested chain, the message is Expected the single document in <test.orders> to satisfy the assertions, but it did not. followed by that document's own failure. Both forms append the dump like every other collection assertion.

Prefer containsDocumentSatisfying over containsDocumentMatching when you want a precise field-level failure rather than a yes-or-no: the nested assertion says which field was wrong and how.

Since 3.4.0 all four take a Matcher<? super Document> as well as a Consumer. Use this form when the claim needs allOf, anyOf or not, or when the suite is already Hamcrest-native. The matchers come from MongoDocumentMatchers, which mirrors the document vocabulary name for name. A lambda still binds to the Consumer overload, because Matcher is not a functional interface, so nothing you have written changes meaning.

Java
import static io.typecheck.bonsai.assertions.MongoDocumentMatchers.*;
import static org.hamcrest.Matchers.*;

// The same four quantifiers, given a Matcher<Document> instead of a lambda
assertThatCollection("orders")
    .containsExactlyOneDocumentSatisfying(allOf(
        hasField("status", "CONFIRMED"),
        hasField("customer.tier", "GOLD"),
        hasIdMatching(validObjectIdHex())))
    .allDocumentsSatisfy(hasField("tenantId", "t-1"))
    .noDocumentSatisfies(anyOf(
        hasField("status", "FAILED"),
        hasField("status", "PAYMENT_PENDING")));

// Still the server-side query: a real filter document, not a matcher
assertThatCollection("orders")
    .containsDocumentMatching("{status: 'CONFIRMED', amount: {$gt: 100}}");

extractingField

Hands every document's value for one field to AssertJ's ListAssert. The field may be a dotted path; a document without the field contributes null.

Java
assertThatCollection("orders")
    .extractingField("status")
    .containsExactlyInAnyOrder("NEW", "CONFIRMED");

// Dotted paths walk into nested documents
assertThatCollection("orders")
    .extractingField("customer.tier")
    .containsOnly("GOLD");

hasFieldValues

hasFieldValues(field, matcher) (3.4.0+) is the chainable extractingField: it collects the same List<Object> of one field's values across every document, hands it to a single Hamcrest matcher, and returns the MongoCollectionAssert so the chain carries on. It exists because extractingField cannot. Its return type is AssertJ's, and negation (not(hasItem(...))) is the claim that reads worst when split across two statements.

Java
import static org.hamcrest.Matchers.*;

// The chainable form: every document's value for one field, to one matcher
assertThatCollection("orders")
    .hasFieldValues("status", allOf(hasItem("CONFIRMED"), not(hasItem("FAILED"))))
    .hasFieldValues("customer.tier", hasSize(2))
    .hasDocumentCount(2);          // still a MongoCollectionAssert; the chain continues

The whole list goes to one matcher, so hasItem, hasItems, hasSize, everyItem and not(hasItem(...)) all fit. The field may be a dotted path. Exactly like extractingField, a document without the field contributes null. That is the documented extraction contract for both methods; to tell an absent field apart from a stored null, assert it as such with allDocumentsSatisfy(d -> d.hasField(f)) or doesNotHaveField(f).

allFieldValues

allFieldValues(name, matcher) (3.5.0+) extracts values in store order, with null for an absent field, then applies one Hamcrest matcher to the whole list. It is the explicit-name form for assertions that want one matcher over every document's value.

Java
assertThatCollection("workflowStates")
    .allFieldValues("status", everyItem(equalTo("COMPLETED")))
    .allFieldValues("owner", everyItem(present()));

An everyItem-style mismatch attributes offending documents by index, up to ten. Empty collections pass everyItem vacuously, so pair it with a count assertion when emptiness would hide a failure.

documents()

Takes an ordered snapshot and switches to the ordered-snapshot assertion. Unlike every other method on this class, the snapshot is taken at the moment documents() is called and not re-read afterwards. That is what makes sortedBy and the diff table describe one consistent set of documents.

Java
assertThatCollection("audit_log").documents()
    .sortedBy("timestamp")
    .ignoringFields("_id", "createdAt")
    .containsExactlyJson(
        """
        {"event": "ORDER_PLACED"}""",
        """
        {"event": "PAYMENT_TAKEN"}""");

Controlling the failure dump

logOnFailure() is the default: every failure appends the collection's contents, capped at 50 documents, values abbreviated at 96 characters. silentOnFailure() turns the dump off for that assertion. For a collection so large that printing it would bury the assertion itself.

Java
assertThatCollection("orders")
    .logOnFailure()      // default: dump the collection when an assertion fails
    .silentOnFailure()   // opt out when the dump is noise
    .hasDocumentCount(1);