Choosing What to Automate

Automate getting in, the core action and one data-backed read path; leave props, branches and messages to component tests.

Syntaxnpx playwright test --grep @smoke

End-to-end tests are the most expensive tests you own: slowest to run, slowest to debug, first to break on a redesign. So the question is not what they could cover, it is which few are worth the maintenance.

The three journeys

First, getting in: sign in, and sign out. If this breaks, no other test result matters. Second, the action the product exists for: create the thing and see it saved, or complete the checkout. Third, one read path that goes through real data: open a list, click an item, see the detail page. Between them these exercise routing, the store, the API layer, the build and the deployed server. That is the coverage only this kind of test provides.

What belongs in a component test instead

Validation messages, each branch of a v-if, slot content, date and currency formatting, loading and empty states, disabled buttons. A rule of thumb: if you can produce the case by changing a prop, it is a component test, and it will run in milliseconds instead of seconds.

Keep the suite honest

Tag the critical specs and run those on every pull request; run the rest nightly. A test the team reruns until it goes green has stopped being a test. When a component test covers the same branch, delete the end-to-end one rather than keeping both.

Example

Example · vue
// tests/e2e/smoke.spec.ts
import { test, expect } from '@playwright/test'

test.describe('critical journeys', { tag: '@smoke' }, () => {
  test('a member signs in', async ({ page }) => {
    await page.goto('/sign-in')
    await page.getByLabel('Email').fill('[email protected]')
    await page.getByLabel('Password').fill('correct-horse')
    await page.getByRole('button', { name: 'Sign in' }).click()

    await expect(page.getByRole('heading', { name: 'Your projects' })).toBeVisible()
  })

  test('a member creates a project', async ({ page }) => {
    await page.goto('/projects/new')
    await page.getByLabel('Project name').fill('Harbour survey')
    await page.getByRole('button', { name: 'Create project' }).click()

    await expect(page.getByRole('heading', { name: 'Harbour survey' })).toBeVisible()
  })

  test('a member opens a project from the list', async ({ page }) => {
    await page.goto('/projects')
    await page.getByRole('link', { name: 'Harbour survey' }).click()

    await expect(page).toHaveURL(/\/projects\/\d+$/)
    await expect(page.getByRole('heading', { name: 'Harbour survey' })).toBeVisible()
  })
})

When to use it

  • Decide which five specs block a merge and which run overnight.
  • Cut an end-to-end suite that takes twenty minutes down to a smoke run of three.
  • Move validation-message coverage out of the browser and into component tests.
  • Give a new team member the shortlist of journeys that must never break.

More examples

The cases that stay at component level

Every case here is produced by setting a prop, which is the signal that it does not need a browser. The end-to-end suite keeps one sign-in test for the path through the router and the API; the error text, the pending button and the emitted payload are all cheaper and clearer at this level. When someone asks for an end-to-end test of a validation message, this is the file to point at.

Example · typescript
// src/components/__tests__/SignInForm.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SignInForm from '../SignInForm.vue'

describe('SignInForm', () => {
  it('shows the server error it is given', () => {
    const wrapper = mount(SignInForm, {
      props: { pending: false, error: 'Email or password is wrong' },
    })

    expect(wrapper.get('[role=alert]').text()).toBe('Email or password is wrong')
  })

  it('disables the button while a request is pending', () => {
    const wrapper = mount(SignInForm, { props: { pending: true, error: null } })

    expect(wrapper.get('button').attributes('disabled')).toBeDefined()
  })

  it('emits the credentials on submit', async () => {
    const wrapper = mount(SignInForm, { props: { pending: false, error: null } })

    await wrapper.get('#email').setValue('[email protected]')
    await wrapper.get('#password').setValue('correct-horse')
    await wrapper.get('form').trigger('submit')

    expect(wrapper.emitted('submit')).toEqual([
      [{ email: '[email protected]', password: 'correct-horse' }],
    ])
    // Output: three passing tests, no browser, no server
  })
})

Smoke on every pull request, everything nightly

The tag is what makes this split possible: one workflow, two entry points, and a pull request that waits minutes instead of tens of minutes. install --with-deps pulls the browser and its system libraries, which a fresh runner does not have. If the nightly run keeps failing on tests nobody fixes, that is the signal to delete them, not to lengthen the timeout.

Example · yaml
# .github/workflows/e2e.yml
name: e2e
on:
  pull_request:
  schedule:
    - cron: '0 3 * * *'
jobs:
  playwright:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Smoke tests on pull requests
        if: github.event_name == 'pull_request'
        run: npx playwright test --grep @smoke
      - name: Full suite nightly
        if: github.event_name == 'schedule'
        run: npx playwright test

Config settings that keep the small suite trustworthy

forbidOnly fails the CI run if someone pushed a test.only, which would otherwise silently skip the rest of the suite. retries on CI keep one unlucky run from blocking a merge, and trace: 'on-first-retry' means the retry produces a trace you can open, so a test that passes on retry still leaves evidence. Retries hide flakiness, so treat a test that needs them as a bug to investigate rather than a configuration success.

Example · typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: process.env.CI
    ? [['github'], ['html', { open: 'never' }]]
    : [['list']],
  use: {
    baseURL: 'http://localhost:4173',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
})

Discussion

  • Be the first to comment on this lesson.