Customizing
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.
For every entity in an Add step, Biome runs the same pipeline, in this order:
- Factory creates the entity. IDs are set here with
ctx.NewId. - Traits (
.With) run, in declaration order. - Call-site customizers run.
Derives run, fill-only and in declaration order. Sequences first reserve every explicit value in the step, then fill the rest.- Global defaults (
options.Default) run, fill-only. - 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();
// Every property has to be spelled out, even the ones the test ignores.
var now = DateTimeOffset.UtcNow;
now = new DateTimeOffset(now.Ticks - now.Ticks % 10, TimeSpan.Zero);
CodeRepository NewRepository(string name, bool enabled) =>
new()
{
Id = $"repo_{Guid.CreateVersion7():N}",
OrganizationId = seed.Organization.Id,
OwnerQualifiedName = name,
Enabled = enabled,
CreatedAtUtc = now,
};
var disabled = NewRepository("zeeq-ai/example", enabled: false);
var api = NewRepository("zeeq-ai/api", enabled: true);
var web = NewRepository("zeeq-ai/web", enabled: true);
_db.AddRange(seed.Owner, seed.Organization, disabled, api, web);
await _db.SaveChangesAsync();
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.
public static class Repositories
{
public static readonly Action<CodeRepository> Disabled = r => r.Enabled = false;
public static readonly Action<CodeRepository> Hidden = r =>
r.VisibleInLibraryPicker = false;
}
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();
+ 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:
public static readonly Blueprint<ZeeqSeed, CodeRepository> DisabledRepository =
Repository.With(Repositories.Disabled);
Context-aware customizers
A customizer that needs the context takes two parameters:
await TestBiome
.Start(out _)
.AddCodeRepository(out _)
.AddOne(
Blueprints.Repository,
out var old,
(r, ctx) => r.CreatedAtUtc = ctx.Ago(TimeSpan.FromDays(30))
)
.BuildAsync();
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.
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));
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.
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"
| Type | Unset means |
|---|---|
reference types, T? | null |
numbers, bool | 0, false |
DateTimeOffset | default(DateTimeOffset) |
string | null only. "" counts as set. |
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:
.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
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.