Fluent Form Builders: The FormBuilder Pattern
category: "E2E Testing"
Fluent Form Builders: The FormBuilder Pattern
Forms Are Usually Where Readability Dies
Even after you clean up setup and page structure, tests still get noisy around form entry.
You see the same pattern repeated over and over:
await page.fill('input[placeholder="Article Title"]', 'My Article');
await page.fill('input[placeholder="What\'s this article about?"]', 'Summary');
await page.fill('textarea[placeholder="Write your article"]', 'Body text');
await page.fill('input[placeholder="Enter tags"]', 'testing');
await page.click('button:has-text("Publish Article")');That is not wrong, but it is repetitive and easy to smear across many tests.
What a Form Builder Should Do
A form builder should convert input mechanics into intent.
For an article editor, the vocabulary is obvious:
- title
- description
- body
- tags
- publish
So that is the API we should expose.
Example: ArticleEditorFormBuilder
export class ArticleEditorFormBuilder {
private titleValue?: string;
private descriptionValue?: string;
private bodyValue?: string;
private tags: string[] = [];
constructor(private readonly page: Page) {}
withTitle(title: string): this {
this.titleValue = title;
return this;
}
withDescription(description: string): this {
this.descriptionValue = description;
return this;
}
withBody(body: string): this {
this.bodyValue = body;
return this;
}
withTags(tags: string[]): this {
this.tags = tags;
return this;
}
async fill(): Promise<this> {
if (this.titleValue !== undefined) {
await this.page.getByPlaceholder('Article Title').fill(this.titleValue);
}
if (this.descriptionValue !== undefined) {
await this.page.getByPlaceholder("What's this article about?").fill(this.descriptionValue);
}
if (this.bodyValue !== undefined) {
await this.page.getByPlaceholder('Write your article').fill(this.bodyValue);
}
for (const tag of this.tags) {
await this.page.getByPlaceholder('Enter tags').fill(tag);
await this.page.keyboard.press('Enter');
}
return this;
}
async submit(): Promise<void> {
await this.page.getByRole('button', { name: /publish article/i }).click();
}
}That gives you a compact flow:
const form = new ArticleEditorFormBuilder(page);
await form
.withTitle('My New Post')
.withDescription('Short summary')
.withBody('Long-form content')
.withTags(['testing'])
.fill();
await form.submit();Notice What This Builder Is Not
It is not a data seeding builder.
It does not create backend records directly. It does not generate auth state. It does not own cleanup.
That work belongs to the earlier layers.
This builder only translates user intent into browser actions.
How It Works with Page Components
The cleanest pattern is to let a page component expose the form builder.
export class EditorPage {
constructor(private readonly page: Page) {}
async open(): Promise<void> {
await this.page.goto('/editor');
}
form(): ArticleEditorFormBuilder {
return new ArticleEditorFormBuilder(this.page);
}
}Then the test reads like this:
const editorPage = new EditorPage(page);
await editorPage.open();
await editorPage.form()
.withTitle('My New Post')
.withDescription('Short summary')
.withBody('Long-form content')
.withTags(['testing'])
.fill();
await editorPage.form().submit();That is already much easier to scan than raw fill() calls all over the spec.
Why This Matters for Short Tests
We are not chasing short tests by hiding everything.
We are getting short tests by giving each layer its own job.
- direct API suites own behavior coverage
- seeding builders own setup graphs
- page components own visible structure
- form builders own repeated browser input
When those responsibilities are separated, the final spec can be small without becoming mysterious.
A Useful Rule
If a test needs the same input sequence in three places, it probably wants a form builder.
If it needs the same setup graph in three places, it probably wants a seeding builder.
If it needs the same response assertions in three places, it probably wants a validation helper.
That rule alone keeps a lot of testing code from collapsing into mush.
What Comes Next
We have the seeded data layer. We have the page structure. We have the form vocabulary.
Now we can localize seeding directly in the test body and finally see what the short version feels like in practice.
Previous: Page Components: Beyond Simple POM Next: Inline Data Seeding: The .as() Method ->