Guides

Seeding & placeholders

Four ways to get data into the store, one loader underneath. Whichever entry point you pick, the formats, the conventions, and the placeholder rules are identical.

Entry points

  1. A suite-level property: bonsai.seed.locations, applied to every annotated class.
  2. @MongoSeed: the @Sql of this library. Repeatable, valid on a class or a method.
  3. MongoTestData: statics for seeding, inserting, and clearing from inside a test method.
  4. SpringMongoTestData: the typed variant, which routes entities through the application's own converter.

Class-level seeds run before method-level ones, and repeated declarations run in source order.

Directory conventions

Point the loader at a directory and it infers names from the layout. A file is a collection; a subdirectory is a database. Files the loader does not recognise are skipped with a debug log rather than a failure, so a seed directory can carry notes.

text
src/test/resources/mongo/seed/
├── orders.json              → collection "orders" in the default database
├── outbox.ndjson            → collection "outbox" in the default database
├── README.md                → skipped, with a debug log
└── billing/                 → subdirectory name is the database name
    └── invoices.json        → collection "invoices" in database "billing"

Location strings accept classpath:, file:, and plain filesystem paths. Under Spring, classpath*: and wildcards work too, because locations resolve through Spring's Resource abstraction.

Fixture formats

The format is detected, not declared. Extended JSON is parsed with the driver's own reader, so BSON types survive exactly: $oid, $date, $numberDecimal, $numberLong, $binary, and the rest.

orders.json: Extended JSON v2, canonical
[
  {
    "_id": { "$oid": "65f1a2b3c4d5e6f708192a3b" },
    "customerId": "cust-42",
    "status": "CONFIRMED",
    "amount": { "$numberDecimal": "49.99" },
    "placedAt": { "$date": "2026-03-13T09:41:00Z" }
  }
]
outbox.ndjson: one document per line
{"_id": "evt-1", "type": "ORDER_PLACED", "orderId": "ord-1"}
{"_id": "evt-2", "type": "PAYMENT_TAKEN", "orderId": "ord-1"}

A bare JSON object is a single document; a JSON array is a list of them. Both canonical and relaxed Extended JSON parse.

@MongoSeed

Java
@EnableMongoEmulator
@MongoSeed("classpath:mongo/seed/")                    // whole directory, conventions apply
class OrderQueryIT {

    @Test
    @MongoSeed(collection = "orders",
               locations = "classpath:fixtures/edge-case-orders.json")
    @MongoSeed(collection = "billing.invoices",
               locations = "classpath:fixtures/overdue-invoices.json")
    void findsOverdueInvoices() {
        // class-level seeds ran first, then these two, in source order
    }
}
PropertyDefaultDescription
value() / locations()
String[]
{}Aliases for each other. Declaring both is an error unless they are equal.
database()
String
""Empty means the subdirectory name, or else the default database. To name the database in collection(), use its dotted form instead.
collection()
String
""Empty means the fixture's filename without its extension. A dotted value such as `billing.invoices` also names its database, split at the first dot; combining it with `database()` throws `IllegalArgumentException`.
placeholders()
Class<? extends SeedPlaceholders>[]
{}Provider classes supplying placeholder bindings. Resolved as Spring beans when one exists.

Diagnosing a seed

Set logging.level.io.typecheck.bonsai=DEBUG when a fixture is not where you expect it. Seed and cleanup lines report the phase (class, method, suite, or reapply), fixture source, resolved database.collection, and document count. Never fixture contents. Cleanup also records the databases dropped and whether schema and class seeds were re-applied.

Properties
logging.level.io.typecheck.bonsai=DEBUG

MongoTestData

When the fixture is specific to one test, seed it from the method. Everything here uses raw Document values and the driver's own Extended JSON semantics.

Java
// A single collection from one file
MongoTestData.seed("orders", "classpath:fixtures/orders.json");

// Into a named database
MongoTestData.seed("billing", "invoices", "classpath:fixtures/invoices.json");

// A whole directory, applying the layout conventions
MongoTestData.seedAll("classpath:mongo/seed/");

// Raw documents, no file involved
MongoTestData.insert("orders",
    new Document("_id", "ord-1").append("status", "CONFIRMED"));

// Tear a single collection down mid-test
MongoTestData.clear("orders");
MongoTestData.clearDatabase("billing");

Typed seeding

SpringMongoTestData writes entities through the application's own MongoConverter, so a seeded document is byte-for-byte what the application would have stored: the same field names, the same date handling, and the same BigDecimal representation.

Java
// Goes through the application's own MongoConverter, so the stored shape
// matches what the application itself would have written.
SpringMongoTestData.insert("orders", new Order("ord-1", "CONFIRMED", 4999));
SpringMongoTestData.insertAll("customers", new Customer("cust-42"), new Customer("cust-43"));

// Useful for asserting on the exact document an entity maps to
Document asStored = SpringMongoTestData.toDocument(new Order("ord-1", "CONFIRMED", 4999));

_class discriminator

Raw JSON fixtures for Spring Data entities need their _class discriminator written by hand. Entities written through SpringMongoTestData or aSeedSource get it through the application's converter automatically. Mixing the two shapes is the repository-returns-null trap: the document exists, but Spring Data cannot read it back as the entity you asked for.

Code fixtures: SeedSource

A SeedSource (3.4.0+) is a fixture written in code: a class or lambda that produces the documents a seed inserts. Reference it from @MongoSeed(sources = …) or the static helpers. They have the same lifecycle as file seeds (class-level sources re-apply after every cleanup, method-level ones vanish with it), instantiated fresh on every application like placeholders classes. Use it for generated data such as Instancio, DataFaker, or your own builders, or wherever a renamed field should fail at compile time instead of silently seeding a stale JSON key.

OverdueOrders.java
public final class OverdueOrders implements SeedSource {
    @Override public List<?> documents(SeedContext ctx) {
        Random random = new Random(ctx.randomSeed());              // reproducible per test
        Instant base = Instant.parse("2026-01-01T00:00:00Z");
        return IntStream.range(0, 25)
                .mapToObj(i -> new Document("orderId", "ord-" + i)
                        .append("status", "OVERDUE")
                        .append("dueDate", Date.from(base.minus(1 + random.nextInt(30), DAYS))))
                .toList();
    }
}
Java
// annotation: same lifecycle as file seeds, applied in array order
@MongoSeed(collection = "orders", sources = OverdueOrders.class)
@MongoSeed(collection = "billing.invoices", sources = InvoicesForCase.class)

// static helpers: lambdas welcome
MongoTestData.seedFrom("orders", new OverdueOrders());
MongoTestData.seedFrom("orders", ctx -> List.of(new Document("status", "NEW")));

// Spring: entities, mapped through the application's converter
SpringMongoTestData.insertAll("orders", new GeneratedOrders(clock));

The SeedContext a source receives carries the resolved target, the seed phase, a deterministic randomSeed() (override with bonsai.seed.random-seed to replay a run), and a placeholder lookup that resolves exactly as a JSON fixture would. In the Spring harness the class is resolved as a bean first. Constructor injection works; a singleton bean is the one instance Spring says it is, so stateful bean sources should be prototype-scoped. A source that throws fails the test, including during re-application after cleanup.

Since 3.5.0, ctx.testMethod() provides an optional method name, resolved@ParameterizedTest arguments, and invocation ordinal for method-scoped seeding. It is empty at class and suite scope because those seeds do not belong to one method. A source can vary a fixture per invocation with ctx.testMethod().map(m -> (String) m.arguments().get(0)).orElse("EU").

Several collections: MultiSeedSource

A SeedSource fills one collection. An aggregate rarely lives in one: an order, its invoice, and its outbox event. The whole point of the fixture is that they share ids. MultiSeedSource (3.4.0+) returns a map keyed by collection name instead of a list, so the correlated ids are generated once, in one place, from one deterministic seed.

WholeOrderCase.java
public final class WholeOrderCase implements MultiSeedSource {

    @Override public Map<String, ? extends List<?>> documents(MultiSeedContext ctx) {
        Random random = new Random(ctx.randomSeed());   // ONE seed for the whole source
        String orderId = "ord-" + random.nextInt(100_000);

        // An ordered map, so the insert order is deterministic.
        return new LinkedHashMap<>(Map.of(
                "orders", List.of(
                        new Document("_id", orderId).append("status", "NEW")),
                "billing.invoices", List.of(
                        new Document("orderId", orderId).append("total", 4999))));
    }
}
Java
// annotation: collection(), database() and locations are FORBIDDEN here,
// because the map keys already name the targets
@MongoSeed(multiSources = WholeOrderCase.class)

// static helpers
MongoTestData.seedFrom(new WholeOrderCase());
SpringMongoTestData.insertAll(new WholeOrderCase());

The map keys decide the targets, and they follow the same namespace rule as everywhere else: a plain name lands in the default database, a dotted name splits at its first dot. Because the keys already name the targets, collection(), database() and locations are forbidden alongside multiSources.

Everything else is SeedSource's lifecycle: a fresh instance per application, re-applied after cleanup, resolved as a Spring bean first where one exists. The difference is the seed: one randomSeed() for the whole source rather than one per collection, which is what makes the ids line up. On the annotation paths it is salted per source class, so two sources named in one array still generate different data.

The rules all fail fast, and none of them fails silently:

  • A null map is an error that tells you to return an empty map for an intentional no-op; a null key or a null list names itself.
  • A leading- or trailing-dot key, and the system databases admin, config and local, are rejected by name.
  • Every collection is encoded before anything is written, so a bad document in the last collection cannot leave the first one half-seeded.

Placeholders

Fixtures are static files, but tests need values that are not: an id the test also asserts on, a timestamp relative to now, a status that varies per case. Any fixture may contain ${name} placeholders, resolved at load time.

orders.json
[
  {
    "_id": { "$oid": "${orderId}" },
    "customerId": "${customerId}",
    "status": "${status:NEW}",
    "reference": "ORD-${customerId}",
    "createdAt": "${#now}",
    "expiresAt": "${#now+P1D}",
    "traceId": "${#uuid}"
  }
]

Built-in placeholders

PropertyDefaultDescription
${#objectId}
ObjectId
—A fresh ObjectId.
${#uuid}
UUID
—A fresh random UUID.
${#now}
Date
—The current instant.
${#now+<offset>}
Date
—The current instant plus an ISO-8601 offset: ${#now+PT2H}, ${#now+P1D}. Durations and periods both parse.
${#now-<offset>}
Date
—The current instant minus an offset: ${#now-P1D}.
${#today}
Date
—Start of the current day, UTC.

The # prefix is reserved. A #-prefixed name that is not a built-in fails immediately, listing the supported set. A typo never falls through to “unresolved placeholder” and never resolves to a user binding.

Binding values

Pass a map alongside the location, or supply a provider class:

Java
MongoTestData.seed("orders", "classpath:fixtures/orders.json",
    Map.of(
        "orderId", new ObjectId(),          // typed: stays an ObjectId
        "customerId", "cust-42",
        "amount", new BigDecimal("49.99")   // typed: stored as Decimal128
    ));
Java
public class OrderPlaceholders implements SeedPlaceholders {
    @Override
    public Map<String, Object> placeholders() {
        return Map.of("customerId", "cust-42", "status", "CONFIRMED");
    }
}

@MongoSeed(locations = "classpath:fixtures/orders.json",
           placeholders = OrderPlaceholders.class)
void findsConfirmedOrders() { }

An inline default covers the common case where a value is usually the same: ${status:NEW} resolves to NEW when nothing binds status. To keep a literal ${...} in the data, double the dollar:

JSON
{ "template": "$${orderId}" }

Typed vs textual substitution

Where the placeholder sits decides whether the binding keeps its type. This is the rule worth internalising, because it is what makes a seeded BigDecimal come back as a Decimal128 rather than a string.

  • The whole value: "amount": "${amount}" is replaced by the bound object itself. An ObjectId stays an ObjectId, a BigDecimal becomes a Decimal128, a nested Document or list is spliced in whole.
  • Inside an Extended JSON wrapper: {"$oid": "${orderId}"} is textual, because the wrapper already declares the type. Binding a non-scalar here fails fast.
  • Embedded in a longer string or in a key: "ORD-${customerId}" interpolates as text.

Resolution order

First hit wins:

  1. a per-source binding;
  2. the call or builder map;
  3. SeedPlaceholders providers, method-level before class-level;
  4. the built-ins;
  5. registered resolvers (3.5.0+); first non-empty answer wins within this tier;
  6. the Spring Environment, in the Spring module only;
  7. an inline default;
  8. failure.

Registered resolvers are PlaceholderResolver beans in the Spring test context, ordered by @Order, or classes named on @EnableMongoEmulator(placeholderResolvers = ...) in the generic module. A resolver cannot answer a built-in name. A registered resolver beats an identically named Spring property, because ambient configuration must not shadow a value the test declared.

Registered resolvers and readback

A dynamic resolver mints a value once per seed application and memoizes it, so every occurrence agrees and the test can read that same value back:

Java
@TestConfiguration
class SeedPlaceholderConfig {
    @Bean PlaceholderResolver testPlaceholders() {
        return name -> switch (name) {
            case "workspaceId" -> Optional.of("ws-" + UUID.randomUUID());
            default -> Optional.empty();
        };
    }
}

String id = MongoTestSupport.placeholderValue("workspaceId");

Readback is per method and clears between methods. Seeds run before the test body, so a value made in @BeforeEach cannot feed a seed. Let the seed mint the value, then read it back in the test.

When a placeholder is unresolved

The loader collects every unresolved placeholder before it fails, and reports each one with its source file and JSON path. Fixing a fixture with six missing bindings takes one run, not six.

Related reading: Configuration for seed mode and cleanup, and Assertions for what to do with the data once it is there.