Getting Started

Quick start

Write a seed, a blueprint, a chain and a saved test in six steps.

We'll continue with the multi-tenant school system from the introduction, so each choice has a concrete purpose. A District has Schools, a school has classes (SchoolClass) and Teachers, and a class has Students. The entities are plain classes, trimmed here to the properties that matter. Entities and parents must be reference types.

Domain.cs
public sealed class District
{
    public string Id { get; set; } = "";
    public string Name { get; set; } = "";
    public string TimeZoneId { get; set; } = "";
    public bool IsArchived { get; set; }
    public DateTimeOffset CreatedAtUtc { get; set; }
    public DateTimeOffset UpdatedAtUtc { get; set; }
}

public sealed class School
{
    public string Id { get; set; } = "";
    public string DistrictId { get; set; } = "";
    public string Name { get; set; } = "";
    public string Code { get; set; } = "";
    public DateTimeOffset CreatedAtUtc { get; set; }
    public DateTimeOffset UpdatedAtUtc { get; set; }
}

public sealed class Teacher
{
    public string Id { get; set; } = "";
    public string DistrictId { get; set; } = "";
    public string SchoolId { get; set; } = "";
    public string FullName { get; set; } = "";
    public DateTimeOffset CreatedAtUtc { get; set; }
}

public sealed class SchoolClass
{
    public string Id { get; set; } = "";
    public string DistrictId { get; set; } = "";
    public string SchoolId { get; set; } = "";
    public string? TeacherId { get; set; } // null: no teacher assigned yet
    public string Name { get; set; } = "";
    public int Capacity { get; set; }
    public DateTimeOffset CreatedAtUtc { get; set; }
}

public sealed class Student
{
    public string Id { get; set; } = "";
    public string DistrictId { get; set; } = "";
    public string ClassId { get; set; } = "";
    public int Seat { get; set; }
    public string? FullName { get; set; }
    public DateTimeOffset EnrolledAtUtc { get; set; }
}

Every row carries its DistrictId, because the district is the tenant. That repeated wiring is exactly what the blueprints below will hold in one place.

1. A seed and a TestBiome entry point

Most tests in this app start from the same district settings. Keeping them in a seed gives each scenario a known starting point. We'll wrap Biome.Start in TestBiome so individual tests do not have to repeat that setup.

TestBiome.cs
using Zeeq.Biome;

public sealed record SchoolSeed(string TimeZoneId);

public static partial class TestBiome
{
    private static readonly BiomeOptions<SchoolSeed> Options = new();

    public static BiomeBuilder<SchoolSeed, SchoolSeed> Start(
        out SchoolSeed seed,
        IBiomeStore? store = null,
        TimeProvider? time = null
    )
    {
        seed = new SchoolSeed("America/Chicago");
        return Biome.Start(seed, Options, store, time);
    }
}

Here the seed is only a time zone. If your tests also always need baseline rows, such as an administrator account, select them with SeedEntities and Biome saves them first. See Seeds and TestBiome.

Options are shared by every test. They freeze on the first Start, and changing them afterwards throws. Each Start creates a new session, so parallel tests are safe.

2. A blueprint

A district needs an ID, a name, a time zone, and creation times even when the test is about something else. Put those routine choices in a blueprint. Its factory receives a context (ctx) with the seed, clock, and ID generation.

Blueprints.cs
[BiomeGraft]
public static partial class Blueprints
{
    public static readonly Blueprint<SchoolSeed, District> District =
        Blueprint.For<SchoolSeed, District>(ctx =>
            new()
            {
                Id = ctx.NewId("dist"), // "dist_<uuidv7>"
                Name = "Riverside Unified",
                TimeZoneId = ctx.Seed.TimeZoneId,
                CreatedAtUtc = ctx.Now, // never DateTimeOffset.UtcNow
                UpdatedAtUtc = ctx.Now,
            }
        );
}

The [BiomeGraft] attribute makes Blueprints a catalog. The source generator creates BlueprintsGrafts in the same namespace; the root blueprint above is available as .AddDistrict(out var district).

3. Your first test

With those defaults in place, the test can ask for a district and check its relationship to the seed. There is no need to repeat the values the blueprint already supplies. Neither version below touches a database yet: step 6 adds persistence.

public sealed class DistrictTests
{
    [Test]
    public async Task District_starts_in_the_seed_time_zone()
    {
        await TestBiome
            .Start(out var seed)
            .AddDistrict(out var district)
            .BuildAsync();

        await Assert.That(district.TimeZoneId).IsEqualTo(seed.TimeZoneId);
    }
}

AddDistrict builds the entity immediately, so district exists as soon as it returns. BuildAsync then saves everything once through the session's store. With no store, that is a no-op.

ShapeCreatesout
AddOne(bp, out var x)1 defaultT
AddOne(bp, out var x, x => …)1 customizedT
Add(bp, out var xs)1 defaultT[]
Add(bp, out var xs, 3)3 defaultsT[]
Add(bp, out var xs, x => …, x => …)one per customizerT[]
Every Add takes an out. Write out _ when the test doesn't need the value. Calling BuildAsync a second time throws.

4. Customize only what the test is about

When the behavior depends on a particular value, set that value at the call site. This keeps the reason for the scenario next to the test that needs it. One test may need an archived district, and a tenant-isolation test needs two districts that differ only by name:

DistrictTests.cs
await TestBiome
    .Start(out _)
    .AddOne(Blueprints.District, out var archived, d => d.IsArchived = true)
    .Add(
        Blueprints.District,
        out var pair,
        d => d.Name = "Lakeview",
        d => d.Name = "Hillcrest"
    )
    .BuildAsync();

A comma makes a new entity. + stacks traits (named Action<T> values) on the same entity:

Traits.cs
public static class Districts
{
    public static readonly Action<District> Pacific =
        d => d.TimeZoneId = "America/Los_Angeles";
    public static readonly Action<District> Archived =
        d => d.IsArchived = true;
}

await TestBiome.Start(out _)
    .Add(Blueprints.District, out var districts,
        Districts.Archived,                       // districts[0]: archived
        // districts[1]: archived, in the Pacific time zone
        Districts.Pacific + Districts.Archived)
    .BuildAsync();

5. Children: chain, branch, fan out

A school needs the district it belongs to. Passing that district into a child blueprint keeps the relationship explicit. For a relationship further up the chain, such as the tenant on a class, use ctx.Ancestor<T>(). ctx.Index is the position within one Add, which is enough for now to give each school a distinct name and code.

Blueprints.cs
public static readonly ChildBlueprint<SchoolSeed, District, School> School =
    Blueprint.ChildOf<SchoolSeed, District, School>(
        (district, ctx) =>
            new()
            {
                Id = ctx.NewId("sch"),
                DistrictId = district.Id, // typed parent
                Name = $"School {ctx.Index + 1}",
                Code = $"S{ctx.Index + 1}",
                CreatedAtUtc = ctx.Now,
                UpdatedAtUtc = ctx.Now,
            }
    );

public static readonly ChildBlueprint<SchoolSeed, School, SchoolClass> Class =
    Blueprint.ChildOf<SchoolSeed, School, SchoolClass>(
        (school, ctx) =>
            new()
            {
                Id = ctx.NewId("cls"),
                DistrictId = ctx.Ancestor<District>().Id, // grandparent
                SchoolId = school.Id,
                Name = "Algebra I",
                Capacity = 25,
                CreatedAtUtc = ctx.Now,
            }
    );

public static readonly ChildBlueprint<SchoolSeed, School, Teacher> Teacher =
    Blueprint.ChildOf<SchoolSeed, School, Teacher>(
        (school, ctx) =>
            new()
            {
                Id = ctx.NewId("tch"),
                DistrictId = school.DistrictId,
                SchoolId = school.Id,
                FullName = $"Teacher {ctx.Index + 1}",
                CreatedAtUtc = ctx.Now,
            }
    );

Every Add re-anchors to its own output, so a chain reads top to bottom:

await TestBiome
    .Start(out _)
    .AddOne(Blueprints.District, out var district)
    .AddOne(Blueprints.School, out var school) // parent: district
    .AddOne(Blueprints.Class, out var algebra) // parent: school
    .BuildAsync();
A child Add compiles only when the current anchor is its parent type. Adding a Class straight after a district is a compile error.

.Under(x) jumps back to an earlier output, so one parent can have several kinds of children. Teachers appear here only because this test is about a class with an assigned teacher; most class tests leave TeacherId empty.

BranchTests.cs
await TestBiome
    .Start(out _)
    .AddOne(Blueprints.District, out _)
    .AddOne(Blueprints.School, out var school)
    .AddOne(Blueprints.Teacher, out var teacher) // anchor: the teacher
    .Under(school) // back to the school
    .AddOne(Blueprints.Class, out var algebra, c => c.TeacherId = teacher.Id)
    .BuildAsync();

Students belong to a class. Here is a student blueprint, then a fan-out. Fan-out is per parent: each customizer or count runs once for every parent.

Blueprints.cs
public static readonly ChildBlueprint<SchoolSeed, SchoolClass, Student> Student =
    Blueprint.ChildOf<SchoolSeed, SchoolClass, Student>(
        (schoolClass, ctx) =>
            new()
            {
                Id = ctx.NewId("stu"),
                DistrictId = schoolClass.DistrictId,
                ClassId = schoolClass.Id,
                Seat = ctx.Index + 1, // position within this Add
                FullName = $"Student {ctx.Index + 1}",
                EnrolledAtUtc = ctx.Now,
            }
    );
FanOutTests.cs
await TestBiome
    .Start(out _)
    .AddOne(Blueprints.District, out _)
    .AddOne(Blueprints.School, out _)
    .Add(Blueprints.Class, out var classes, 2)
    // 3 per class → 6 students, ordered by parent
    .Add(Blueprints.Student, out var students, 3)
    .BuildAsync();

6. Sequences, then save

Numbering by position works for one Add, but restarts when you add more entities later. A class-capacity test might add students in two calls, and the second would repeat seat 1. Declare a sequence so numbering continues across calls. Its state lives in each session, keeping tests independent. Declare the sequence above the blueprint that uses it, because static fields initialize in order.

Blueprints.cs
public static readonly Sequence<int> Seats = Sequence
    .Of(1, n => n + 1)
    .PerParent(); // restarts for each class

public static readonly ChildBlueprint<SchoolSeed, SchoolClass, Student> Student =
    Blueprint
        .ChildOf<SchoolSeed, SchoolClass, Student>(
            (schoolClass, ctx) =>
                new()
                {
                    Id = ctx.NewId("stu"),
                    DistrictId = schoolClass.DistrictId,
                    ClassId = schoolClass.Id,
                    EnrolledAtUtc = ctx.Now,
                }
        )
        .Derive(s => s.Seat, Seats)
        .Derive(s => s.FullName, (s, _) => $"Student {s.Seat}");

Derive fills a property only while it is unset, so a test that sets Seat itself keeps that value. After AddStudents the anchor is the new students, so use .Under(...) to add more to the same class:

SeatTests.cs
await TestBiome
    .Start(out _)
    .AddDistrict(out _)
    .AddSchool(out _)
    .AddClass(out var schoolClass, c => c.Capacity = 3)
    .AddStudents(out var first, 2) // seats 1, 2
    .Under(schoolClass)
    .AddStudents(out var last, 1) // seat 3, not 1
    .BuildAsync();

This chain uses generated grafts: short extension methods that name the entity rather than the blueprint. A root such as District generates methods on a generic BiomeBuilder<SchoolSeed, TAnchor>, so it is available from any anchor for this seed. A child such as School generates methods only for BiomeBuilder<SchoolSeed, District[]>, so it appears after a district.

Each eligible blueprint with valid names generates three overloads: singular with an optional Action<T> customizer, plural with a count, and plural with params Action<T>[]. For input blueprints the customizer receives the input type, while out holds the output. Generated methods always take out. Root grafts work once from the current anchor, with the count or customizer list setting the number of outputs. Child grafts fan out for each parent, and a child count or customizer applies to each parent. Use the core Add / AddOne API with a blueprint for a one-off context-aware customizer. Handwrite an AddX extension only for a repeated context overload or another app-specific exception.

Generated graft calls
await TestBiome.Start(out _)
    .AddDistrict(out var district)
    .AddSchool(out var school)
    .AddClass(out var schoolClass, c => c.Capacity = 3)
    .BuildAsync();

Finally, save with EF Core. Add a second Start overload that wraps EfCoreStore; _db below is your app's DbContext:

TestBiome.cs
using Zeeq.Biome.EntityFrameworkCore;

public static BiomeBuilder<SchoolSeed, SchoolSeed> Start(
    DbContext db,
    out SchoolSeed seed,
    TimeProvider? time = null
) => Start(out seed, db.AsBiomeStore(), time);

A class-capacity test now saves a full class. Both versions create the same rows and write them to the same database:

await TestBiome
    .Start(_db, out _)
    .AddDistrict(out _)
    .AddSchool(out _)
    .AddClass(out var fullClass, c => c.Capacity = 3)
    .AddStudents(out var students, 3)
    .BuildAsync(); // AddRange in order, one SaveChangesAsync

_db.ChangeTracker.Clear(); // so the code under test reads rows from the database

When a chain repeats across tests, give it a name. A scenario helper is a chain you write once, offered from the seed, that keeps every construction step visible in its definition. Unlike the regular graft methods, this multi-step helper is app-specific:

ScenarioHelpers.cs
public static class SchoolScenarioHelpers
{
    // Scenario helper: a whole default chain, offered from the seed only.
    extension(BiomeBuilder<SchoolSeed, SchoolSeed> b)
    {
        public BiomeBuilder<SchoolSeed, Student[]> AddFullClass(
            out SchoolClass schoolClass,
            out Student[] students
        )
        {
            var classStep = b.AddDistrict(out _)
                .AddSchool(out _)
                .AddClass(out schoolClass, c => c.Capacity = 3);
            return classStep.AddStudents(out students, 3); // as many students as seats
        }
    }
}

A test now names only the class it cares about: TestBiome.Start(_db, out _).AddFullClass(out var fullClass, out var students).BuildAsync().

See Generated grafts and scenario helpers for the conventions behind both. Biome does not create missing ancestors on its own, so a helper spells them out.

Next