Guides

Seeds and TestBiome

The baseline every test starts from, and the one-call entry point your tests use.

The seed

Many scenarios share a small starting point: an owner, an organization, or a tenant ID. A seed (TSeed) carries that baseline into the blueprints so each test does not have to pass it around. Biome never inspects its shape, so it can be a record, a string, or an existing fixture object.

ZeeqSeed.cs
public sealed record ZeeqSeed(User Owner, Organization Organization)
{
    public static ZeeqSeed Generate()
    {
        var owner = new User
        {
            Id = $"user_{Guid.CreateVersion7():N}",
            DisplayName = "Owner",
        };
        var organization = new Organization
        {
            Id = $"org_{Guid.CreateVersion7():N}",
            DisplayName = "Test Organization",
            CreatedByUserId = owner.Id,
        };
        return new(owner, organization);
    }
}

The seed is created outside the session for each test, so a new test never shares rows with another.

Entities and parents must be reference types. The seed is the one thing that may have any shape, such as public sealed record TenantSeed(string Actor, int Tenant);.

Seed rows

A seed can contain configuration as well as entities, so Biome cannot assume which objects belong in the database. Select those rows with SeedEntities. They are saved before the created entities, in the order you list them.

TestBiome.cs
private static readonly BiomeOptions<ZeeqSeed> Options =
    new BiomeOptions<ZeeqSeed>().SeedEntities(seed =>
        [seed.Owner, seed.Organization]
    ); // foreign-key-safe order

Seed rows also count as owned by the session, so a test can anchor children to one with .Under(seed.Owner). A null entry in the list throws immediately.

TestBiome: one call to start

Most tests use the same seed factory and options. Wrapping Biome.Start once keeps that wiring in one place and gives each test a short entry point.

public static partial class TestBiome
{
    private static readonly BiomeOptions<ZeeqSeed> Options =
        new BiomeOptions<ZeeqSeed>().SeedEntities(seed =>
            [seed.Owner, seed.Organization]
        );

    // No database: NullStore, entities still exist after each Add.
    public static BiomeBuilder<ZeeqSeed, ZeeqSeed> Start(
        out ZeeqSeed seed,
        IBiomeStore? store = null,
        TimeProvider? time = null
    )
    {
        seed = ZeeqSeed.Generate();
        return Biome.Start(seed, Options, store, time);
    }

    // With EF Core: same session, saved through a DbContext.
    public static BiomeBuilder<ZeeqSeed, ZeeqSeed> Start(
        DbContext db,
        out ZeeqSeed seed,
        TimeProvider? time = null
    ) => Start(out seed, db.AsBiomeStore(), time);
}

Biome.Start(seed, options, store, time) returns a BiomeBuilder<TSeed, TSeed>. A null store means NullStore. A null time means a frozen clock.

When several methods need the same starting graph, call TestBiome.Start in the test class's instance constructor and keep the baseline builder in an instance field. Each test instance gets a fresh session and extends it for its own scenario. See Constructor setup for TUnit and xUnit examples.

Options are shared and frozen

Sharing configuration is useful until one test changes it for another. Biome freezes options on first use and keeps each scenario's mutable state in its own session:

  • Options are configured once, as a static readonly field.
  • They freeze on the first Start. Every later change (SeedEntities, Default, BeforeSave, AfterSave, TimePrecision, Ids) throws InvalidOperationException.
  • Each Start creates a new session, so each test gets its own entities, sequences and clock. Parallel tests are safe.
Never change shared options inside a test. If one test needs different options, give TestBiome a factory and build a fresh instance, as the library's own CustomSeed tests do:
CustomSeed/Domain.cs
internal static class TestBiome
{
    internal static BiomeOptions<CustomSeed> NewOptions() =>
        new BiomeOptions<CustomSeed>().Default<IHasCreatedBy, string?>(
            n => n.CreatedBy,
            (_, ctx) => ctx.Seed.Actor
        );

    private static readonly BiomeOptions<CustomSeed> Options = NewOptions();

    internal static BiomeBuilder<CustomSeed, CustomSeed> Start(
        out CustomSeed seed,
        IBiomeStore? store = null,
        BiomeOptions<CustomSeed>? options = null
    )
    {
        seed = new("actor", 42);
        return Biome.Start(seed, options ?? Options, store);
    }
}

A test then builds its own options, for example TestBiome.NewOptions().BeforeSave(...), and passes them in. See Custom seed shapes.

A seed with timestamps

The seed is created before the session starts, so it cannot use ctx.Now. If seed timestamps are compared with database values, round them yourself, or set them from the same TimeProvider you pass to Start.

An existing fixture can be the seed directly:

TestBiome.cs
new BiomeOptions<AppFixtureData>().SeedEntities(seed => seed.GetSeedEntities());