Expanding Coverage: CommentBuilder & UserBuilder
category: "E2E Testing"
Expanding Coverage: CommentBuilder & UserBuilder
Why More Builders Matter
One builder is a nice demo. Two or three builders is where the pattern starts paying rent.
As soon as you move beyond a single seeded post, you run into workflows like:
- a reader comments on an article
- an author edits their own post
- a feed shows content from followed users
- a moderation flow needs several related records already in place
At that point the problem is no longer "how do I create one object?"
It becomes "how do I create a realistic setup graph without burying the test in helper noise?"
Important: These Builders Do Not Replace Direct API Suites
By the time we write UserBuilder or CommentBuilder, the lower-level endpoint coverage should already exist.
That means:
- registration and auth endpoints already have direct API coverage
- post creation already has direct API coverage
- comment creation already has direct API coverage
The builder layer is not where we prove those endpoints work. It is where we make setup fast and local for the next level of testing.
What a Good UserBuilder Does
For seeding, a user builder should solve four problems:
- generate unique defaults
- allow small custom overrides
- return the useful auth state the next test needs
- register cleanup or teardown metadata automatically
export class UserBuilder {
private customizations: Partial<CreateUserRequest> = {};
constructor(
private readonly api: DomainApiFactory,
private readonly helper: TestDataHelper
) {}
withUsername(username: string): this {
this.customizations.username = username;
return this;
}
withEmail(email: string): this {
this.customizations.email = email;
return this;
}
withBio(bio: string): this {
this.customizations.bio = bio;
return this;
}
withDefaults(): this {
this.customizations.username ??= this.helper.uniqueName('reader');
this.customizations.email ??= TestDefaults.email();
this.customizations.password ??= TestDefaults.password();
this.customizations.bio ??= TestDefaults.shortText();
return this;
}
async create(): Promise<{ user: AuthenticatedUser }> {
const payload = {
username: this.customizations.username ?? this.helper.uniqueName('reader'),
email: this.customizations.email ?? TestDefaults.email(),
password: this.customizations.password ?? TestDefaults.password(),
bio: this.customizations.bio,
};
const response = await this.api.users.register(payload);
this.helper.trackEntity('User', response.user);
return { user: response.user };
}
}Notice what is not here:
- no giant assertion block
- no boundary-case logic
- no negative-path handling
Those belong in direct API tests, not in a seeding builder.
What a Good CommentBuilder Does
Comments are more interesting because they depend on both a user and a post.
export class CommentBuilder {
private body = TestDefaults.longText();
private authorCustomizer?: (builder: UserBuilder) => UserBuilder | void;
private articleCustomizer?: (builder: PostBuilder) => PostBuilder | void;
constructor(
private readonly api: DomainApiFactory,
private readonly helper: TestDataHelper
) {}
withBody(body: string): this {
this.body = body;
return this;
}
withAuthor(customizer?: (builder: UserBuilder) => UserBuilder | void): this {
this.authorCustomizer = customizer;
return this;
}
withArticle(customizer?: (builder: PostBuilder) => PostBuilder | void): this {
this.articleCustomizer = customizer;
return this;
}
async create(): Promise<{
author: AuthenticatedUser;
article: Post;
comment: Comment;
}> {
const userBuilder = new UserBuilder(this.api, this.helper).withDefaults();
const postBuilder = new PostBuilder(this.api, this.helper).withAllDeps();
if (this.authorCustomizer) {
this.authorCustomizer(userBuilder);
}
if (this.articleCustomizer) {
this.articleCustomizer(postBuilder);
}
const { user: author } = await userBuilder.create();
const { article } = await postBuilder.create();
const response = await this.api.comments.createComment(article.slug, {
body: this.body,
}, author.token);
this.helper.trackEntity('Comment', response.comment);
return {
author,
article,
comment: response.comment,
};
}
}This is where builders shine:
- dependency resolution
- readable overrides
- a returned bundle you can use immediately
What Higher-Level Tests Get Out of This
Consider a higher-level API test that is really about feed ranking, not about post creation or comment validation.
test('feed returns newest followed content first', async ({ api, testData }) => {
const { author: firstAuthor } = await testData.post()
.withAllDeps()
.withTitle('Older seeded post')
.create();
const { article: newestArticle } = await testData.post()
.withAuthor((user) => user.withUsername(firstAuthor.username))
.withTitle('Newest seeded post')
.create();
const feed = await api.feed.getForUser(firstAuthor.token);
expect(feed.articles[0].slug).toBe(newestArticle.slug);
});That test is about feed behavior. The builders just make the setup obvious and compact.
The same pattern helps UI tests.
test('reader sees an existing comment thread', async ({ page, testData }) => {
const { article, comment } = await testData.comment()
.withBody('First seeded comment')
.create();
await page.goto(`/article/${article.slug}`);
await expect(page.getByText(comment.body)).toBeVisible();
});Again, the point is not the builder itself. The point is that the test starts at the interesting moment.
Design Rules Worth Keeping
As you add more builders, keep the API disciplined.
- Builders should return created objects, not hide them.
- Builders should keep defaults centralized.
- Builders should accept targeted overrides.
- Builders should register cleanup as they create data.
- Builders should not absorb endpoint assertions that belong in direct API suites.
If a builder becomes a second test framework, it is doing too much.
What Comes Next
Once seeded data is cheap, the browser layer finally gets to be honest.
That means page objects stop acting like junk drawers for selectors, waits, setup, and assertions all at once.
They can narrow down to their real job: representing the UI structure clearly.
Previous: One-Line Cleanup: The destroyWithAllDeps Pattern Next: Page Components: Beyond Simple POM ->