Builders as Seeding: Replace beforeEach Sprawl with One Line
category: "E2E Testing"
Builders as Seeding: Replace beforeEach Sprawl with One Line
This Is Where Builders Finally Earn Their Keep
Now that lower-level post, user, and comment endpoints have direct API coverage, we can introduce builders for the problem they actually solve:
setup.
Not endpoint verification. Not boundary coverage. Not contract testing.
Setup.
The Setup Mess We Are Replacing
This is the style that makes higher-level tests hard to read:
test.beforeEach(async ({ request }) => {
author = await createUser(request);
authToken = await loginUser(request, author);
article = await createArticle(request, authToken, {
title: 'Hello World',
description: 'A seeded article',
body: 'Long body',
});
comment = await createComment(request, authToken, article.slug, {
body: 'Seeded comment',
});
});That setup is:
- hidden from the test body
- shared across scenarios that may not need it
- annoying to customize per test
- easy to leak into more hooks over time
The Builder Version
Builders keep setup local to the test.
const { author, article, comment } = await testData.comment()
.withBody('Seeded comment')
.withArticle((post) => post.withTitle('Hello World'))
.withAuthor((user) => user.withUsername('alice'))
.create();That is still API-driven under the hood. It is just packaged for readability and reuse.
A PostBuilder Built for Seeding
export class PostBuilder {
private customizations: Partial<CreatePostRequest> = {};
private authorCustomizer?: (builder: UserBuilder) => UserBuilder | void;
constructor(
private readonly api: DomainApiFactory,
private readonly helper: TestDataHelper
) {}
withTitle(title: string): this {
this.customizations.title = title;
return this;
}
withDescription(description: string): this {
this.customizations.description = description;
return this;
}
withBody(body: string): this {
this.customizations.body = body;
return this;
}
withTag(tag: string): this {
const tags = this.customizations.tagList ?? [];
this.customizations.tagList = [...tags, tag];
return this;
}
withAuthor(customizer?: (builder: UserBuilder) => UserBuilder | void): this {
this.authorCustomizer = customizer;
return this;
}
withAllDeps(): this {
if (!this.authorCustomizer) {
this.withAuthor();
}
if (!this.customizations.title) {
this.withTitle(TestDefaults.articleTitle());
}
if (!this.customizations.description) {
this.withDescription(TestDefaults.shortText());
}
if (!this.customizations.body) {
this.withBody(TestDefaults.longText());
}
return this;
}
async create(): Promise<{ article: Post; author: User }> {
const authorBuilder = new UserBuilder(this.api, this.helper);
if (this.authorCustomizer) {
this.authorCustomizer(authorBuilder);
}
const { user: author } = await authorBuilder.withDefaults().create();
const payload = {
title: this.customizations.title ?? TestDefaults.articleTitle(),
description: this.customizations.description ?? TestDefaults.shortText(),
body: this.customizations.body ?? TestDefaults.longText(),
tagList: this.customizations.tagList ?? [],
};
const response = await this.api.posts.createPost(payload, author.token);
this.helper.registerCleanup(`post-${response.article.slug}`, async () => {
await this.api.posts.deletePost(response.article.slug, author.token);
});
return { article: response.article, author };
}
}The important bit is not the syntax. It is the job:
- resolve dependencies
- keep defaults in one place
- keep setup local to the test
- register cleanup automatically
This Pattern Helps Higher-Level API Tests Too
Builders are not just for UI tests. They also help when a higher-level API test depends on lower-level objects that were already verified directly.
Example:
- lower-level post creation already has data-driven coverage
- lower-level comment creation already has data-driven coverage
- now you want to test a moderation or feed endpoint that needs realistic seeded content
That is builder territory.
test('feed returns the newest followed posts', async ({ testData, api }) => {
const { author, article } = await testData.post()
.withAllDeps()
.withTitle('Seeded for feed test')
.create();
const feed = await api.feed.getForUser(author.token);
expect(feed.articles[0].title).toBe('Seeded for feed test');
});The feed endpoint is the thing under test. The builder just gets you to the starting line quickly.
Why Local Setup Beats Shared Hooks
Compare the two styles.
Shared hook style:
- hides what a specific test needs
- over-seeds data for many tests
- makes failures harder to trace
Local builder style:
- shows required state inside the test
- customizes setup per scenario
- makes failures easier to reason about
That is the real win.
What Comes Next
Once builders create dependency graphs cleanly, cleanup becomes the next problem.
If create is one line but teardown is ten, the pattern is incomplete.
So next we make cleanup follow the same philosophy.
Previous: Negative Cases, Boundary Coverage, and a bugs.md That Actually Helps Next: One-Line Cleanup: The destroyWithAllDeps Pattern ->