Assertions

Document assertions

assertThatDocument(document): the record-level assertion for ids, fields, dotted paths, JsonPath, and whole-document structural comparison.

Getting one

Most of the time a MongoDocumentAssert is handed to you by a collection assertion's containsDocumentSatisfying, allDocumentsSatisfy or noDocumentSatisfies. assertThatDocument(...) builds one directly from a document you already hold.

Java
// Usually nested inside a collection assertion...
assertThatCollection("orders").containsDocumentSatisfying(d -> d
    .hasField("status", "CONFIRMED"));

// ...or built directly from a document you already hold
Document order = MongoCollectionReader.readDocuments("orders").get(0);
assertThatDocument(order).hasField("status", "CONFIRMED");

hasId

Verifies the document's _id, compared by value. Numbers match across BSON numeric types, the way {_id: 42} would match server-side.

Java
assertThatDocument(order).hasId("ord-1");

// Numbers compare by value: an int 42 matches a stored NumberLong 42
assertThatDocument(counter).hasId(42);

// Shape rather than value, when the id is generated (3.4.0+)
assertThatDocument(order).hasIdMatching(validObjectIdHex());

The failure shows both sides and the whole document: Expected document to have _id <ord-2>, but it was <ord-1>, followed by in {...the document as relaxed Extended JSON...}. An absent _id renders as (absent), which keeps a missing id distinguishable from a stored null.

When the id is generated the value is different every run, so assert its shape instead: hasIdMatching(matcher) (3.4.0+) hands the stored _id to any Hamcrest matcher. See Field matchers. It is a separate name rather than an overload of hasId on purpose: overloading an Object parameter with a Matcher would silently rebind hasId(null), which today means “the _id equals null”. An absent _id fails before the matcher is consulted, so a matcher never sees a missing value.

Field assertions

Four methods cover presence, equality, matcher-based checks, and absence. Every failure message ends with the document itself, so you never have to go looking for what was actually there.

Java
assertThatDocument(order)
    .hasField("status")                       // present, any value (explicit null counts)
    .hasField("status", "CONFIRMED")          // present and equal
    .hasField("customer.tier", "GOLD")        // dotted path walks the nested document
    .hasField("orderNumber", matchesRegex("ORD-\\d{8}"))
    .doesNotHaveField("cancelledAt");         // absent; not merely null
  • hasField(name): the field is present, whatever its value. An explicit null counts as present. On failure the message lists the keys the document does have.
  • hasField(name, expected): present and equal: Expected field <status> to be <CONFIRMED>, but it was <NEW>.
  • hasFieldMatching(name, matcher): present and satisfying a Hamcrest matcher; the failure carries the matcher's own mismatch description, which names the value's type as well as its rendering. See Field matchers.
  • doesNotHaveField(name): absent, not merely null. A stored null fails this assertion, and the message shows the value it found.

Since 3.5.0, present() completes the presence ladder: hasField(name) means present, including an explicit null;hasFieldMatching(name, present()) means present and non-null; typed matchers then assert the value's shape. Failures distinguish an absent field from an explicit null and name the rung that fits the claim.

Java-time fields

Since 3.5.2, hasField(name, expected) compares stored BSON dates with LocalDate, LocalDateTime, Instant, OffsetDateTime, and ZonedDateTime at millisecond precision. A LocalDate means the start of that day; a stored value later that day does not match it. Date values inside lists follow the same equality rules.

Local values use ZoneId.systemDefault(), matching Spring Data's standard Java-time converters. The three-argument overload accepts the timezone used by your persistence mapping: pass ZoneOffset.UTC for native MongoDB Java-time codecs. Bonsai does not infer custom converters from a raw document. Strings and numeric epoch values remain distinct from BSON dates.

Java
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;

assertThatCollection("calendarEntries").containsExactlyOneDocumentSatisfying(d -> d
    .hasField("day", LocalDate.of(2025, 7, 18))
    .hasField("scheduledAt", LocalDateTime.of(2025, 7, 18, 14, 23)));

// If persistence uses UTC instead of the JVM timezone:
assertThatDocument(entry)
    .hasField("day", LocalDate.of(2025, 7, 18), ZoneOffset.UTC);

Pass expected values directly to hasField. Hamcrest matchers belong in hasFieldMatching and receive the raw stored value, which is a java.util.Date for a BSON date. A plain Hamcrest equalTo(LocalDate) does not apply BSON-aware conversion; valueEqualTo uses the shared comparison rules with the JVM timezone. For date ranges, use dateAfter, dateBefore and their inclusive variants, which accept Java-time thresholds directly.

Document arrays

inArray(field) (3.5.0+) scopes a fluent document assertion to an array of nested documents. It replaces JsonPath filter expressions for the common workflow and audit-trail case, while retaining the full document vocabulary at every selected element.

Java
assertThatCollection("workflowStates")
    .containsExactlyOneDocumentSatisfying(d -> d
        .hasId(workspaceId)
        .inArray("stageProgress").matching("stageName", "WORKSPACE_PREPARED")
            .hasField("status", "COMPLETED")
            .hasField("completedAt", present())
            .inArray("subTasks").matching("taskName", "DACT_CHECKS")
                .hasField("completed", true));

Selection is deliberate: matching(field, value) requires exactly one element. Zero and many matches both fail, listing each element with its index; ambiguity suggests a second key. Use the multi-key form to disambiguate, or choose with at(index), single(), or hasSize(n). Selector equality is BSON-aware, so an int and a long can collide.

Failures carry the full descent path, for example workflowStates > document "ws-42" > stageProgress[?stageName=...]. The chain moves strictly downward. To inspect two elements from one array, branch with the lambda overloads:

Java
d.inArray("stageProgress")
    .matching("stageName", "PREPARED", s -> s.hasField("status", "COMPLETED"))
    .matching("stageName", "VALIDATED", s -> s.hasField("status", "PENDING"))
    .at(0, s -> s.hasField("attempt", 1));

Non-document elements simply do not match, so mixed arrays remain selectable; an array with no document elements fails clearly. There is no Hamcrest twin because matcher combinators cannot express scoped selection. Use hasJsonPathMatching for a Hamcrest-native assertion.

JsonPath assertions

For arrays, indexes and predicates, hasJsonPath evaluates a JsonPath expression over the document's relaxed Extended JSON form. Therefore, $.amount is the number 4999, while an ObjectId reads as {"$oid": "…"}.

Java
assertThatDocument(order)
    .hasJsonPath("$.lines[0].sku")            // resolves to something
    .hasJsonPath("$.lines[0].qty", 2)         // resolves and equals
    .hasJsonPath("$.lines.length()", 3);

// A matcher at the path, instead of a literal (3.4.0+)
assertThatDocument(order)
    .hasJsonPathMatching("$.lines[0].sku", startsWith("WIDGET-"))
    .hasJsonPathMatching("$.total", numberComparing(greaterThan(new BigDecimal("4000"))));

A path that does not resolve fails the assertion with the document attached. A path that is not valid JsonPath at all throws IllegalArgumentException instead. A test-authoring mistake, kept distinct from a state mismatch.

hasJsonPathMatching(path, matcher) (3.4.0+) is the matcher form. The direct route that used to need hasFieldMatching wrapped around jsonPath(...). A path that does not resolve fails before the matcher runs.

matchesJson

Whole-document structural equality: both sides are parsed, so key order and whitespace are irrelevant. The paths in ignoringJsonPaths are deleted from both sides before comparing. This is how a generated _id or an audit timestamp is excused.

Java
assertThatDocument(order).matchesJson("""
        {"status": "CONFIRMED", "amount": 4999, "customerId": "cust-42"}
        """, "$._id", "$.placedAt");          // deleted from BOTH sides first

The failure prints both trees on separate lines: expected: ... / actual: ..., with an (ignoring $._id, $.placedAt) suffix naming what was excused. For “exactly these documents” across a whole collection, use an ordered snapshot instead, which shares the same ignore mechanics.

matchesDocument

matchesDocument(matcher) (3.4.0+) hands the whole document to a Matcher<? super Document>, which is what makes allOf/anyOf/not compositions of MongoDocumentMatchers usable mid-chain. The failure is the matcher's own mismatch description, followed by in {…the document…} like every other message on this page.

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

assertThatDocument(order)
    .hasId("ord-1")
    .matchesDocument(allOf(
        hasField("status", "CONFIRMED"),
        not(hasField("error")),
        anyOf(hasField("channel", "web"), hasField("channel", "app"))));

It is called matchesDocument and not matches because AssertJ's AbstractAssert already declares matches(Predicate): two matches in one chain, from two libraries, with two failure styles, is a readability trap that a longer name avoids outright.

json()

Returns the document as relaxed Extended JSON, using the same rendering every failure message here uses. Useful for building a custom message, or for a quick System.out.println while writing the test.