main*
📝e2e-testing/12-two-line-tests.md
📅January 31, 20254 min read

Putting It Together: 2-Line E2E Tests That Stay Honest

#e2e-testing#2-line-tests#fluent#aaa

category: "E2E Testing"

Putting It Together: 2-Line E2E Tests That Stay Honest

Two Lines Is a Goal, Not a Religion

At this point in the series, the phrase "2-line test" needs a disclaimer.

The goal is not to force every test into exactly two lines.

The goal is to make tests:

  • short enough to scan quickly
  • honest enough to debug
  • focused on one workflow

If that takes three or four clear lines instead of two, that is still a win.

What Makes Short Tests Legitimate

Short tests only work if the lower layers already carried their weight.

By now we already have:

  • direct API coverage for endpoint behavior and validation
  • seeding builders for dependency-heavy setup
  • page components for structure
  • form builders for repetitive browser interaction

That means the final E2E spec does not need to prove every low-level detail again.

It just needs to prove the workflow.

Start from the Bottom, Then Climb

Take a favorite workflow.

At the lowest level, direct API tests already proved:

  • creating a post works
  • favoriting a post works
  • unauthorized favorite attempts fail
  • bad payloads are handled correctly

That coverage belongs in the API suites, not in the UI spec.

Then the builder layer gives us state quickly:

const { article, author } = await testData.post()
  .withAllDeps()
  .withTitle('Seeded for favorite flow')
  .create();

Then the UI test only needs to verify the browser experience:

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

That is not tiny because we cheated. It is tiny because the other layers already did their jobs.

Before and After

Here is the style we are trying to escape.

test('user can publish a post', async ({ page, request }) => {
  const author = await createUser(request);
  const login = await loginUser(request, author);
 
  await page.goto('/login');
  await page.fill('[name=email]', author.email);
  await page.fill('[name=password]', author.password);
  await page.click('button[type=submit]');
  await page.waitForURL('/');
 
  await page.goto('/editor');
  await page.fill('input[placeholder="Article Title"]', 'My Post');
  await page.fill('input[placeholder="What\'s this article about?"]', 'Summary');
  await page.fill('textarea[placeholder="Write your article"]', 'Body');
  await page.click('button:has-text("Publish Article")');
 
  await expect(page.locator('h1')).toHaveText('My Post');
});

That is not catastrophic, but it scales badly.

Now the same idea with the full stack in place:

test('author can publish a post', async ({ page, testData }) => {
  const { user } = await testData.user().withDefaults().create();
  await loginAs(page, user);
 
  const editorPage = new EditorPage(page);
  await editorPage.open();
  await editorPage.form()
    .withTitle('My Post')
    .withDescription('Summary')
    .withBody('Body')
    .fill();
  await editorPage.form().submit();
 
  await expect(page.locator('h1')).toHaveText('My Post');
});

Still honest. Much easier to read.

A Better Example of the End State

Here is the kind of test I actually want us to end up with.

test('reader sees seeded comments on an article', async ({ page, testData }) => {
  const { article, comment } = await testData.comment()
    .withBody('This is the comment that matters')
    .create();
 
  const articlePage = new ArticlePage(page);
  await articlePage.open(article.slug);
  await expect(articlePage.commentBody(comment.body)).toBeVisible();
});

That is short, but more importantly it is obvious.

The interesting fact is visible right in the test: we seeded a comment, opened the article, and verified the user can see it.

Where People Usually Cheat

There are two bad ways to make tests look short.

First, hide half the setup in shared hooks.

test.beforeEach(async () => {
  // creates six records nobody can see from the test body
});

Second, push too much into magical helpers.

await completePublishingFlow(page, 'My Post');

Both make the spec look elegant while making failures harder to investigate.

The version we want is short, but still tells the truth.

The Practical Rule

If someone opens the test file cold, they should be able to answer three questions fast:

  1. What state did this test need?
  2. What user action happened in the browser?
  3. What visible outcome proved it worked?

If the test is "short" but hides those answers, it is not actually better.

The End-to-End Story

This series is really one continuous story:

  • direct API suites make lower-level behavior trustworthy
  • builders make setup local and composable
  • page components and form builders make browser actions readable
  • short E2E tests become possible because the groundwork is already done

That is why API-first matters so much here.

Without it, short UI tests are just theater.

What Comes Next

Now that the structure is in place, the remaining problem is maintenance.

How do you keep coverage moving forward without manually auditing every endpoint and every gap by hand?

That is where coverage tracking and AI-assisted generation finally become useful.


Previous: Inline Data Seeding: The .as() Method Next: Maintaining 100% Coverage ->