main*
📝e2e-testing/09-page-components.md
📅January 4, 20254 min read

Page Components: Beyond Simple POM

#e2e-testing#page-object-model#pom#components

category: "E2E Testing"

Page Components: Beyond Simple POM

Browser Code Gets Better When Setup Leaves the Room

Most Page Object Model examples fail for a simple reason: they are trying to compensate for missing structure everywhere else.

The page object ends up doing all of this:

  • navigation
  • seeding assumptions
  • selector lookup
  • field filling
  • assertions
  • retries
  • random helper logic

That is not a page object. That is a junk drawer.

Now that we have direct API coverage and local builder-based seeding, the browser layer can shrink down to what it should be:

  • page structure
  • user-visible actions
  • user-visible state

What a Page Component Should Represent

A page component should map to a visible surface in the app.

For a publishing flow, that might be:

  • the global app shell
  • the article editor page
  • the article details page
  • the comment thread component

That is much easier to reason about than one giant ConduitPage with 40 methods.

Example: AppShell

export class AppShell {
  constructor(private readonly page: Page) {}
 
  homeLink() {
    return this.page.getByRole('link', { name: /home/i });
  }
 
  newArticleLink() {
    return this.page.getByRole('link', { name: /new article/i });
  }
 
  settingsLink() {
    return this.page.getByRole('link', { name: /settings/i });
  }
 
  profileLink(username: string) {
    return this.page.getByRole('link', { name: new RegExp(username, 'i') });
  }
}

This class does not seed data. It does not invent auth state. It just exposes the shell the user sees.

Example: ArticlePage

export class ArticlePage {
  constructor(private readonly page: Page) {}
 
  async open(slug: string): Promise<void> {
    await this.page.goto(`/article/${slug}`);
  }
 
  title() {
    return this.page.locator('h1').first();
  }
 
  favoriteButton() {
    return this.page.getByRole('button', { name: /favorite/i }).first();
  }
 
  commentBody(text: string) {
    return this.page.locator('.card-text').filter({ hasText: text });
  }
 
  async favorite(): Promise<void> {
    await this.favoriteButton().click();
  }
}

That is enough for a lot of tests.

Why This Beats Raw Selectors in Specs

Without components, your tests end up repeating this everywhere:

await page.goto(`/article/${article.slug}`);
await page.getByRole('button', { name: /favorite/i }).first().click();
await expect(page.locator('h1').first()).toHaveText(article.title);

That might look fine in one test, but it scales badly.

With a component:

const articlePage = new ArticlePage(page);
await articlePage.open(article.slug);
await articlePage.favorite();
await expect(articlePage.title()).toHaveText(article.title);

The gain is not abstraction for its own sake. The gain is that test intent becomes easier to scan.

Components Should Expose Meaningful Actions

The best component methods line up with the language a human would use:

  • open(slug)
  • favorite()
  • publish()
  • openSettings()
  • commentBody(text)

The worst ones just wrap implementation details:

  • clickFavoriteButtonByCssClass()
  • typeIntoTextareaAndWaitForRender()

If the method name reads like a workaround, the component is probably too close to the DOM and not close enough to the user behavior.

What Page Components Should Not Do

Avoid folding other concerns into them.

  • Do not create backend data.
  • Do not know how test accounts are seeded.
  • Do not own giant assertion suites.
  • Do not become the place where every retry and workaround lives forever.

That separation is what keeps the browser layer maintainable.

How This Connects to the Earlier Layers

By now the flow should feel intentional:

  • direct API tests already proved endpoint behavior
  • builders already solved setup and dependency graphs
  • page components now express browser structure cleanly

That means a UI spec can stay focused.

test('reader can favorite an existing article', async ({ page, testData }) => {
  const { article } = await testData.post().withAllDeps().create();
 
  const articlePage = new ArticlePage(page);
  await articlePage.open(article.slug);
  await articlePage.favorite();
  await expect(articlePage.favoriteButton()).toContainText('1');
});

What Comes Next

Page components define the visible structure.

Form builders define how we interact with the most repetitive part of that structure: forms.

That is the last piece we need before the UI tests really start collapsing down.


Previous: Expanding Coverage: CommentBuilder & UserBuilder Next: Fluent Form Builders: The FormBuilder Pattern ->