Guides

Customizing

How customizers and defaults work together, and why their order matters.

The one rule

A default is useful only if the test can choose a different value. For example, changing an invitation's creation time should also change its default expiry, without replacing an expiry the test supplied. That is why Biome runs derived defaults after customizers.

Customizers choose the values; derived and global defaults fill what is still unset. More specific defaults run before global ones.

For every entity in an Add step, Biome runs the same pipeline, in this order:

  1. Factory creates the entity. IDs are set here with ctx.NewId.
  2. Traits (.With) run, in declaration order.
  3. Call-site customizers run.
  4. Derives run, fill-only and in declaration order. Sequences first reserve every explicit value in the step, then fill the rest.
  5. Global defaults (options.Default) run, fill-only.
  6. The entity is recorded, with its parent link and step.

There is no OnCreated hook that can overwrite a value. Steps 4 and 5 only fill properties that are still unset.

An input blueprint runs steps 2 to 4 on its input object (traits and customizers for every input in the step first, then each input's Derives followed by the factory), and then applies global defaults to the built entity (step 5).

Set only the property under test

await TestBiome
    .Start(out _)
    .AddOne(Blueprints.Repository, out var disabled, r => r.Enabled = false)
    .Add(
        Blueprints.Repository,
        out var pair,
        r => r.OwnerQualifiedName = "zeeq-ai/api",
        r => r.OwnerQualifiedName = "zeeq-ai/web"
    )
    .BuildAsync();
A repeated default looks just as deliberate as a value chosen for the assertion. Set only the properties the test depends on, so the reader can see why this scenario exists.

Traits: comma makes a new entity, plus stacks

If several tests make the same choice, give it a name. A trait is an Action<T> value that you can pass anywhere a customizer goes.

Repositories.cs
public static class Repositories
{
    public static readonly Action<CodeRepository> Disabled = r => r.Enabled = false;
    public static readonly Action<CodeRepository> Hidden = r =>
        r.VisibleInLibraryPicker = false;
}
Traits in a chain
await TestBiome
    .Start(out _)
    .Add(
        Blueprints.Repository,
        out var repos,
        Repositories.Disabled, // repos[0]: disabled
        Repositories.Disabled + Repositories.Hidden
    ) // repos[1]: disabled and hidden
    .BuildAsync();
Choose + when one entity needs both traits, and a comma when the test needs separate entities. For example, PullRequests.Closed + PullRequests.Merged creates one pull request with both traits; PullRequests.Closed, PullRequests.Merged creates two.

Bake a trait into a variant with .With. The base blueprint is not changed:

Blueprints.cs
public static readonly Blueprint<ZeeqSeed, CodeRepository> DisabledRepository =
    Repository.With(Repositories.Disabled);

Context-aware customizers

A customizer that needs the context takes two parameters:

Relative time
await TestBiome
    .Start(out _)
    .AddCodeRepository(out _)
    .AddOne(
        Blueprints.Repository,
        out var old,
        (r, ctx) => r.CreatedAtUtc = ctx.Ago(TimeSpan.FromDays(30))
    )
    .BuildAsync();
C# cannot mix one-parameter and two-parameter lambdas in a single call. When you mix them, write all of them in the two-parameter form. Generated grafts accept Action<T> (or Action<TInput> for an input blueprint) unless the app has added a context overload for that type; otherwise a context-aware customizer goes through the core Add / AddOne.

Derived defaults are fill-only

Some defaults depend on the entity's final values: a title may use its number, or expiry may use its creation time. .Derive runs after traits and customizers for that reason. It assigns a property only if it still has its default value; a non-default value supplied by the test is kept.

Blueprints.cs
public static readonly Blueprint<
    ZeeqSeed,
    OrganizationMembership
> PendingInvitation = Blueprint
    .For<ZeeqSeed, OrganizationMembership>(ctx =>
        new()
        {
            Id = ctx.NewId("mem"),
            OrganizationId = ctx.Seed.Organization.Id,
            Status = MembershipStatus.Pending,
            InvitedEmail = "invited@example.test",
            CreatedByUserId = ctx.Seed.Owner.Id,
            CreatedAtUtc = ctx.Now,
        }
    )
    .Derive(m => m.ExpiresAtUtc, (m, _) => m.CreatedAtUtc.AddDays(7));
Membership tests
var created = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
await TestBiome
    .Start(out _)
    .Add(
        Blueprints.PendingInvitation,
        out var invitations,
        i => i.CreatedAtUtc = created, // ExpiresAtUtc derived → Jan 8
        i => i.ExpiresAtUtc = created.AddDays(-1)
    ) // explicit → kept
    .BuildAsync();

The same pattern from the library's own tests (Membership/Domain.cs): expiry derives after the customizer has changed the creation time.

Membership/Domain.cs
internal static readonly Blueprint<
    MembershipSeed,
    PendingInvitation
> Invitation = Blueprint
    .For<MembershipSeed, PendingInvitation>(ctx =>
        new() { CreatedAt = ctx.Now }
    )
    .Derive(i => i.ExpiresAt, (i, _) => i.CreatedAt.AddDays(7));

What counts as "unset"

TypeUnset means
reference types, T?null
numbers, bool0, false
DateTimeOffsetdefault(DateTimeOffset)
stringnull only. "" counts as set.
Biome uses the type's default value to recognize an unset property. For a string that means null, so "" is kept as an explicit value. Leave derived strings null in the factory. If a test needs to retain null, use a base blueprint without that Derive.

For nullable value types the distinction holds too: int? null fills, but 0 survives.

Derives run in order

A title that includes a sequence number needs the final number first. Derives run in declaration order, so place the title derivation after the sequence:

Blueprints.cs
    .Derive(pr => pr.PullRequestNumber, PullRequestNumbers)
    .Derive(pr => pr.Title, (pr, _) => $"Pull request {pr.PullRequestNumber}")
    .Derive(pr => pr.Branch, (pr, _) => $"feature/{pr.PullRequestNumber}")

Properties must be settable

A customizer cannot assign an init property (CS8852), so a property that a test varies on the entity itself needs a set. If the model must stay init or required, keep production code as it is and customize an input instead: see Input blueprints. A blueprint factory method with optional named parameters also works, but it creates a new blueprint on every call.

The property you select in Derive or options.Default must be a directly writable instance property, like e => e.Name. Anything else throws ArgumentException at declaration time. init properties are accepted here, because Derive and Default assign through a compiled setter.