Generated grafts and scenario helpers
Generated graft methods
Calling the core API with a blueprint works, but repeating the blueprint name adds noise to every test. Mark a blueprint catalog with [BiomeGraft]; the generator adds a {Catalog}Grafts extension class in the same namespace. The Zeeq.Biome project references the generator as an analyzer and includes it when the core project is packaged.
[BiomeGraft]
public static class Blueprints
{
[Graft("CodeRepository", Plural = "CodeRepositories")]
public static readonly Blueprint<ZeeqSeed, CodeRepository> Repository = null!;
public static readonly ChildBlueprint<ZeeqSeed, CodeRepository, PullRequestRecord> PullRequest =
null!;
[NoGraft]
public static readonly Blueprint<ZeeqSeed, CodeRepository> InternalVariant = null!;
}
[BiomeGraft] marks a class; [Graft] and [NoGraft] apply to fields or properties. The catalog must be a top-level, non-generic, non-file-local static class; partial is not required. The generator reads public, internal, or protected-internal non-const static readonly fields and static getter-only properties with no setter, whose types are Blueprint, InputBlueprint, ChildBlueprint, or ChildInputBlueprint. Traits, sequences, private members, mutable fields, and other types are skipped. [NoGraft] excludes a member before name and collision diagnostics.
By default the field or property name supplies the singular suffix after Add. Pluralization changes a consonant + y to ies, adds es after s, x, z, ch or sh, and adds s otherwise. [Graft] accepts an optional positional name and an optional settable Plural argument to override either suffix for a domain word, an irregular plural, or a method collision. Both names must be valid C# identifiers. [Graft] changes names only; it does not make an unsupported member eligible.
public static class BlueprintsGrafts
{
public static BiomeBuilder<ZeeqSeed, CodeRepository[]> AddCodeRepository<TAnchor>(
this BiomeBuilder<ZeeqSeed, TAnchor> b,
out CodeRepository repo,
Action<CodeRepository>? customize = null) =>
b.AddOne(Blueprints.Repository, out repo, customize);
public static BiomeBuilder<ZeeqSeed, CodeRepository[]> AddCodeRepositories<TAnchor>(
this BiomeBuilder<ZeeqSeed, TAnchor> b,
out CodeRepository[] repos,
int count) =>
b.Add(Blueprints.Repository, out repos, count);
public static BiomeBuilder<ZeeqSeed, CodeRepository[]> AddCodeRepositories<TAnchor>(
this BiomeBuilder<ZeeqSeed, TAnchor> b,
out CodeRepository[] repos,
params Action<CodeRepository>[] customize) =>
b.Add(Blueprints.Repository, out repos, customize);
}
public static BiomeBuilder<ZeeqSeed, PullRequestRecord[]> AddPullRequest(
this BiomeBuilder<ZeeqSeed, CodeRepository[]> b,
out PullRequestRecord pr,
Action<PullRequestRecord>? customize = null) =>
b.AddOne(Blueprints.PullRequest, out pr, customize);
Generator diagnostics
These rules are enabled by default:
| ID | Severity | Meaning |
|---|---|---|
BIOME001 | Warning | Two members produce an overlapping signature on the same builder. Rename one with [Graft("Name")]. |
BIOME002 | Warning | The singular and plural suffixes are identical. Set a distinct Plural value. |
BIOME003 | Error | The catalog is not a top-level, non-generic, non-file-local static class. |
BIOME004 | Error | A singular or plural name is not a valid C# identifier. |
The generated class follows the catalog's accessibility, and each generated method follows its member's accessibility.
Every eligible blueprint catalog member with valid names produces three overloads. The singular method has one optional Action<T> customizer. The plural methods take either a count or params Action<T>[]. For input blueprints, the customizer type is TInput, while out and the returned builder use output type T.
- Root and root-input grafts are generic in
TAnchor, so they are available after any step with the same seed. Their count or customizer list applies once at the current anchor. - Child and child-input grafts require an anchor of
BiomeBuilder<TSeed, TParent[]>, so they appear only after their declared parent. They do not create missing ancestors. Child grafts fan out for each parent; their count and customizers apply to each parent. - Every method takes
out. A singular child graft requires exactly one parent at runtime. A plural call with no customizers creates one default child per parent, or one root output for a root graft. Each method re-anchors to its output array.
The generated customizers use Action<T> (or Action<TInput> for inputs); no context-aware overload is generated. For a one-off context-aware customizer, use the core AddOne or Add API with the blueprint directly:
await TestBiome
.Start(out _)
.AddOne(Blueprints.Repository, out var repo, (repo, ctx) =>
repo.CreatedAtUtc = ctx.Ago(TimeSpan.FromDays(30)))
.BuildAsync();
With grafts, a chain names the entities, not the blueprints:
await TestBiome
.Start(_db, out var seed)
.AddCodeRepository(out var repo)
// the property under test
.AddPullRequest(out var pr, p => p.State = PullRequestState.Closed)
.BuildAsync();
await TestBiome
.Start(_db, out var seed)
.AddOne(Blueprints.Repository, out var repo)
.AddOne(
Blueprints.PullRequest,
out var pr,
p => p.State = PullRequestState.Closed
)
.BuildAsync();
AddPullRequest is missing from IntelliSense, the anchor isn't a CodeRepository[]: add the parent first, or .Under(parent).Graft conventions
- Name a blueprint member for the domain operation, and use
[Graft("Name", Plural = "Names")]when its member name is not the desired call-site word. - Use a context overload only for a type whose tests repeatedly need the clock or parent. Handwrite only that overload or another app-specific exception; ordinary graft methods come from the catalog.
- Never create ancestors inside a child graft or extension. Write a named scenario helper instead.
x => … and (x, ctx) => … lambdas in one call. C# can't infer both. Generated grafts accept one-parameter customizers, so use the core API for a one-off context-aware callback.Context overloads: when a test needs them
The generated overload set covers defaults, simple customizers, and fan-out. When a type's tests repeatedly need the clock or parent, you can handwrite just a named context overload for that type:
public static class CodeReviewContextExtensions
{
extension<TAnchor>(BiomeBuilder<CodeReviewSeed, TAnchor> b)
{
public BiomeBuilder<CodeReviewSeed, CodeRepository[]> AddCodeRepository(
out CodeRepository entity,
Action<CodeRepository, BiomeContext<CodeReviewSeed>> customize
) => b.AddOne(Blueprints.Repository, out entity, customize);
}
}
.AddCodeRepository(
out var second,
(repo, ctx) => repo.CreatedAt = ctx.Ago(TimeSpan.FromMinutes(5))
)
C# picks the overload by the lambda's parameter count, so existing r => … calls still compile. A child overload takes Action<T, ChildContext<TSeed, TParent>>. Every lambda in one call must still use the same form.
Scenario helpers
A lock test needs the repository, pull request, and review that lead to the lock, but usually asserts only on the lock itself. When that branch recurs, give it a scenario helper. This is an app-level chain declared on the seed anchor, returning the leaf while keeping all the construction steps visible in its definition:
public static class CodeReviewScenarioHelpers
{
// Scenario helper: a whole default chain, offered from the seed only.
extension(BiomeBuilder<ZeeqSeed, ZeeqSeed> b)
{
public BiomeBuilder<ZeeqSeed, ActiveCodeReviewLock[]> AddLockedReview(
out ActiveCodeReviewLock activeLock
) =>
b.AddCodeRepository(out _)
.AddPullRequest(out _)
.AddCodeReview(out _)
.AddActiveLock(out activeLock);
}
}
A lock test now names only the lock:
await TestBiome.Start(_db, out _).AddLockedReview(out var activeLock).BuildAsync();
// Every test that needs a lock repeats the whole graph.
var repo = NewRepository(seed);
var pr = NewPullRequest(repo);
var review = NewCodeReview(pr);
var activeLock = NewActiveLock(review);
_db.AddRange(seed.Owner, seed.Organization, repo, pr, review, activeLock);
await _db.SaveChangesAsync();
Scenario helpers can take parameters. The library's Learning domain passes a deadline through:
public static class LearningScenarioHelpers
{
extension(BiomeBuilder<LearningSeed, LearningSeed> b)
{
public BiomeBuilder<LearningSeed, Submission[]> AddLateSubmission(
out Submission leaf,
DateTimeOffset dueAt
) =>
b.AddSchool(out _)
.AddCourse(out _)
.AddSection(out _)
.AddEnrollment(out _)
.AddAssignment(out _, a => a.DueAt = dueAt)
.AddSubmission(out leaf);
}
}
Where to put things
Tests.Common/Biome/
ZeeqSeed.cs seed type + Generate()
TestBiome.cs options + Start overloads (no-DB, DbContext)
CodeReviews/
Blueprints.cs sequences, blueprints, variants (one file: init order)
Traits.cs PullRequests.Closed, …
ContextExtensions.cs optional context overloads
ScenarioHelpers.cs named multi-step chains
Membership/ …