Assertions
Field matchers
Hamcrest matchers for values that are generated rather than fixed, such as ids, timestamps, and references. Assert by shape instead of by value.
Why matchers
A shim-generated _id or a service-stamped timestamp cannot be asserted with hasField(name, value). The value is different every run. What is stable is its shape. MongoFieldMatchers supplies matchers for the common shapes, and they pair with every matcher-taking method in the package, chiefly hasFieldMatching. They compose with the rest of Hamcrest: jsonPath and decimal128 take an inner matcher of your choosing.
One rule governs the whole class: they match the stored value, not its rendering. A shim-generated id comes back as an ObjectId and a BSON date as a java.util.Date, while a fixture that seeded a string keeps a string. The identity matchers are type-flexible for exactly that reason; the BSON-type matchers are strict for the opposite one.
Presence: the present() ladder
present() (3.5.0) matches any non-null value of any BSON type, completing a three-rung ladder: hasField(name) means the field exists, an explicit null included; hasFieldMatching(name, present()) means it exists and is non-null; a typed matcher such as bsonDate() then pins the shape. The gap between the first two rungs is where bugs hide: a field written as an explicit null passes hasField while carrying no value. The failure message distinguishes an absent field from an explicit null and names the rung you probably wanted.
That distinction belongs to hasFieldMatching alone. hasFieldValues, allFieldValues and extractingField flatten an absent field to null before any matcher runs, and they hand the matcher the whole extracted list, once, which is never null. A bare present() there would assert nothing, so both collection-level methods refuse it and point at everyItem(present()). Reach for allDocumentsSatisfy(d -> d.hasFieldMatching(field, present())) when absence and a stored null must be told apart.
Identity: ObjectIds and UUIDs
import static io.typecheck.bonsai.assertions.MongoFieldMatchers.*;
assertThatDocument(order)
.hasField("_id", validObjectIdHex()) // ObjectId, or a valid 24-hex string
.hasField("traceId", validUuid()); // UUID, or canonical 8-4-4-4-12 textvalidObjectIdHex(): anObjectId, or a 24-character hex string that is a valid one. The string form is what a fixture-seeded id looks like when it was written as{"$oid": "…"}and read back through a JsonPath.validUuid(): aUUID, or a string in canonical 8-4-4-4-12 form. The check round-trips throughUUID.fromString, so the lenient group lengths that method tolerates do not slip through.
Strings: instants, blanks, regexes
assertThatDocument(order)
.hasField("placedAt", validIsoInstant()) // "2026-08-10T12:00:00Z", or with offset
.hasField("notes", nonBlankString())
.hasField("orderNumber", matchesRegex("ORD-\\d{8}"));validIsoInstant(): a string that parses as an ISO-8601 instant, withZor an offset. For a value stored as a BSON date this fails by design. UsebsonDate()to check a stored BSON date's type, or the date-comparison matchers below to compare its value.nonBlankString(): a string with at least one non-whitespace character.matchesRegex(regex): the regex must match the whole value (Pattern.matches), because “the reference looks likeORD-\d{6}” almost always means the whole value. An invalid regex throwsIllegalArgumentExceptionat construction, not a mismatch.
Payloads: validJson and jsonPath
assertThatDocument(outboxEntry)
.hasField("payload", validJson())
.hasField("payload", jsonPath("$.event", equalTo("ORDER_PLACED")))
// jsonPath also reads nested Documents and lists, not just JSON strings
.hasField("customer", jsonPath("$.tier", equalTo("GOLD")));validJson(): a string holding a well-formed JSON object or array, parsed by MongoDB's own parser, so Extended JSON and the shell's relaxed syntax count as valid. A bare scalar is not JSON for this purpose: the payloads worth asserting on are objects and arrays.jsonPath(path, matcher): the value's JsonPath resolves and satisfies the inner matcher. The value may be a JSON string, a nestedDocument, or a list; aDocumentis read in the same relaxed Extended JSON form the rest of the library speaks. A path that does not resolve is a mismatch, not an error.
BSON types: bsonDate and decimal128
assertThatDocument(invoice)
.hasField("issuedAt", bsonDate())
.hasField("total", decimal128(comparesEqualTo(new BigDecimal("19.99"))));bsonDate(): a BSON date, i.e. ajava.util.Date, which is what the driver materialises one as. A string that merely looks like a date fails.decimal128(matcher): a BSONdecimal128whose exactBigDecimalvalue satisfies the inner matcher.
Date comparisons with Java time
Since 3.5.2, pass a LocalDate, LocalDateTime, or Instant directly to a date matcher. They also accept Date, BsonDateTime, OffsetDateTime, and ZonedDateTimeas either the threshold or the actual value. No manual conversion is needed.
dateAfter(value)anddateBefore(value)are strict.dateAtOrAfter(value)anddateAtOrBefore(value)include equality.dateEqualTo(value)compares at BSON millisecond precision.
import static io.typecheck.bonsai.assertions.MongoFieldMatchers.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
LocalDate day = LocalDate.of(2025, 7, 18);
LocalDateTime cutoff = day.atTime(14, 23);
assertThatCollection("calendarEntries").containsExactlyOneDocumentSatisfying(d -> d
.hasField("day", dateEqualTo(day))
.hasField("scheduledAt", dateAfter(cutoff))
.hasField("scheduledAt", dateBefore(cutoff.plusHours(1)))
.hasField("scheduledAt", dateAtOrAfter(cutoff))
.hasField("scheduledAt", dateAtOrBefore(cutoff.plusHours(1))));
// Select UTC if cutoff denotes a UTC date/time (for example, to match a UTC seed value):
assertThatDocument(entry)
.hasField("scheduledAt", dateAfter(cutoff, ZoneOffset.UTC));A LocalDate means start of day. dateAfter(day) therefore accepts later times on that same day; it does not mean “a later calendar day”. Local dates and date-times use the JVM timezone captured when the matcher is created. Each matcher accepts a second ZoneId argument to use your persistence mapping's timezone, including ZoneOffset.UTC for native driver codecs. Instants and offset/zoned values already identify an instant.
Sub-millisecond differences do not make a stored BSON date strictly before or after the threshold. Missing fields, nulls, strings, numeric epochs, and BSON's separate internal timestamp type do not match. An unsupported or out-of-range threshold or a null timezone throws IllegalArgumentException when creating the matcher. Seed BSON dates with $date Extended JSON; a timestamp written as a plain JSON string stays a string.
Value equality across BSON types
Two matchers (3.4.0+) exist because Hamcrest equality is equals() and MongoDB's is not. hasField("total", 4999) passes against a stored NumberLong, because the library compares numbers by value across BSON types; hasFieldMatching("total", equalTo(4999)) fails against the same document, because Integer.equals(Long) is false. That gap is the trap: the two forms look interchangeable and are not.
valueEqualTo(expected): the library's own comparison, as a matcher. Numbers by value across BSON types,Decimal128againstBigDecimal, lists element by element, everything else byequals.hasFieldMatching(f, valueEqualTo(v))andhasField(f, v)return the same verdict for every value class.numberComparing(matcher): normalises any stored number (Integer,Long,Double,Decimal128,BigInteger) to aBigDecimaland hands that to the inner matcher. This is the general form ofdecimal128(...), and it is what makes a range work without knowing which numeric type the write happened to land on.
// equalTo is equals(): this FAILS against a stored NumberLong 4999
assertThatDocument(order).hasField("total", equalTo(4999));
// valueEqualTo is the library's own comparison: identical to hasField("total", 4999)
assertThatDocument(order).hasField("total", valueEqualTo(4999));
// Ranges regardless of the stored numeric type
assertThatDocument(order)
.hasField("total", numberComparing(greaterThan(new BigDecimal("4000"))))
.hasJsonPathMatching("$.lines[0].price", numberComparing(lessThan(new BigDecimal("100"))));A non-numeric value under numberComparing is a mismatch, not an error, and so are the numeric edge cases that have no BigDecimal: NaN and ±Infinity, stored as a Double, Float, or Decimal128, never match and never throw. A test that finds a NaN in the store gets a failing assertion rather than an exception three frames down.
MongoDocumentMatchers
MongoDocumentMatchers (3.4.0+) is the document vocabulary as standalone Matcher<Document> factories, a name-for-name mirror of MongoDocumentAssert: hasField(f), hasField(f, value), hasFieldMatching(f, m), hasJsonPath(p), hasJsonPath(p, value), hasJsonPathMatching(p, m), hasId(value), hasIdMatching(m), doesNotHaveField(f), and matchesJson(json, ignoringPaths…). Every one delegates to the same resolver and comparison the assert methods use, so the verdicts are identical, including the cross-type numeric rule.
Being matchers, they go two places the assert methods cannot: into allOf/anyOf/not, and into any Hamcrest-native suite A bare assertThat(document, …) on a Document you already hold with no harness and no Bonsai entry point in sight. Inside the vocabulary they feed matchesDocument and the four …Satisfying collection methods.
import static io.typecheck.bonsai.assertions.MongoDocumentMatchers.*;
import static org.hamcrest.Matchers.*;
// A plain Document, no harness and no assertThat… entry point involved
Document order = MongoCollectionReader.readSingleDocument("orders", "{_id: 'ord-1'}");
assertThat(order, allOf(
hasField("channel", "web"),
hasField("total", valueEqualTo(4999)),
not(hasField("error"))));
// …and the same matchers inside the Bonsai vocabulary
assertThatCollection("orders")
.noDocumentSatisfies(hasField("status", "FAILED"))
.containsExactlyOneDocumentSatisfying(allOf(
hasId("ord-1"),
hasJsonPathMatching("$.lines.length()", equalTo(3)),
doesNotHaveField("cancelledAt")));
// "absent, or present and matching" is spelled explicitly
assertThat(order, anyOf(not(hasField("discount")), hasField("discount", valueEqualTo(0))));Two rules decide the cases people trip over:
- An absent field is a mismatch, whatever the value matcher says. The matcher is never invoked on a missing value, and the mismatch text names the field as absent alongside the keys the document does have. “Absent or matching” therefore has to be spelled out:
anyOf(not(hasField(f)), hasFieldMatching(f, m)). - A
nullor non-Documentactual never matches Standard Hamcrest semantics mean that a barenot(hasField("error"))passes on anulldocument. Guard it withboth(notNullValue()).and(not(...))when the actual may be null. The Bonsai-invoked paths null-check before the matcher runs, so this only bites on the standalone route.
Bonsai's own leaf matchers describe a mismatch as field, expected, and actual only. the document itself is appended once, by the method that invoked them. Hamcrest's anyOf and not print the item themselves, so expect the document to appear one extra time under those two; that is Hamcrest's rendering, not a leak.
How mismatches read
Every matcher describes a mismatch as the value's type and its rendering, because “was a String” and “was 665f1e…” answer different halves of “why did this fail?”. Through hasFieldMatching the document itself is appended too. A representative failure: a test expecting a string timestamp where the application stored a BSON date:
Expected field <placedAt> to match <a valid ISO-8601 instant>, but was a Date <...>
in {"_id": "ord-1", "placedAt": {"$date": "..."}, ...}A null matcher passed to any composing method is an IllegalArgumentException naming the signature. This is a test-authoring mistake, reported as one rather than as a NullPointerException three frames down.