DEV Community

Geminate Solutions
Geminate Solutions

Posted on

Software Acceptance Testing with Playwright Your Client Can Read

Software acceptance testing works best when the person signing off can read the tests themselves. With Playwright, you get there by writing each acceptance criterion as a Given/When/Then step, attaching a screenshot to every step and handing over the HTML report as the sign-off record.

Maybe you have sat in a sign-off call where the client asks "but does it actually do what we agreed?" while you scroll through a green CI log. This tutorial is for you. When you finish, every criterion from the spec will appear in the report in the same words, with pass or fail and a picture next to it.

Why do sign-off meetings stall even when every test passes?

They stall because the tests and the criteria are written in two different languages. The spec says "a trial customer can upgrade to the paid plan from the billing page". The test says await page.click('#btn-upg-2'). Nobody outside the dev team can connect the two.

So the client does what any careful buyer would do. They click through the app themselves, find something that looks off, and the meeting turns into a bug triage session.

The tests passed, but they proved nothing to the person who needed proof.

What does a vague acceptance process cost you?

It costs you the sign-off date. Each round of "let me just check one more thing" pushes the release back. Every disputed criterion turns into an argument about what was agreed instead of a quick look at evidence.

It also costs trust. When clients cannot see what was tested, they assume the worst and start testing everything by hand. That slows every release after this one too.

Who doesn't need this?

Skip this if you are building an internal tool and the person signing off also writes the code. A short checklist in the pull request is enough. This approach pays off when someone outside the dev team has to say yes before the work counts as done.

Step 1: Write the criteria before the tests

Turn each criterion into a numbered, testable sentence and agree it with the client before anyone writes code. A good criterion has one actor, one action and one visible outcome.

  • AC-04: Given a customer on a trial plan, when they choose the Pro plan on the billing page and confirm, then the billing page shows "Pro" as their current plan.

Reject anything that says "should work well" or "fast". Without a number, a criterion like that can never fail, so nobody can sign it off either.

How do you make each Playwright step read like the criterion?

Wrap test.step in three small helpers so the report says "Given", "When" and "Then" in plain words. Each helper also takes a screenshot, even when the step fails.

// acceptance.ts
import { test, Page } from '@playwright/test'

async function step(page: Page, label: string, body: () => Promise<void>) {
  await test.step(label, async () => {
    try {
      await body()
    } finally {
      await test.info().attach(label, {
        body: await page.screenshot({ fullPage: true }),
        contentType: 'image/png',
      })
    }
  })
}

export const given = (page: Page, text: string, body: () => Promise<void>) =>
  step(page, `Given ${text}`, body)
export const when = (page: Page, text: string, body: () => Promise<void>) =>
  step(page, `When ${text}`, body)
export const then = (page: Page, text: string, body: () => Promise<void>) =>
  step(page, `Then ${text}`, body)
Enter fullscreen mode Exit fullscreen mode

The finally block matters. Without it, a failing step throws before the screenshot is taken, and you lose the one picture the client most needs to see.

How do you set up test data the client can trust?

Create the data for each test through an API and delete it afterwards. Never rely on a shared "test user" that someone edited by hand last week. Once that account drifts, criteria start failing for reasons nobody can explain.

A Playwright fixture handles the setup and the cleanup:

// fixtures.ts
import { test as base } from '@playwright/test'

type Customer = { id: string, email: string, password: string }

export const test = base.extend<{ trialCustomer: Customer }>({
  trialCustomer: async ({ request }, use) => {
    const email = `ac-${Date.now()}@example.test`
    const password = 'Accept-Test-123'
    const res = await request.post('/api/test/customers', {
      data: { email, password, plan: 'trial' },
    })
    const { id } = await res.json()
    await use({ id, email, password })
    await request.delete(`/api/test/customers/${id}`)
  },
})
export { expect } from '@playwright/test'
Enter fullscreen mode Exit fullscreen mode

Only staging and test environments should have the /api/test/customers endpoint. Put it behind an environment flag so it can never reach production.

What does a full criterion look like as a test?

Put the criterion ID in the test title and the original sentence in an annotation. The HTML report shows both at the top of the test.

// billing.spec.ts
import { test, expect } from './fixtures'
import { given, when, then } from './acceptance'

test('AC-04: A trial customer can upgrade to Pro', async ({ page, trialCustomer }) => {
  test.info().annotations.push({
    type: 'Criterion',
    description: 'Given a customer on a trial plan, when they choose the Pro plan on the billing page and confirm, then the billing page shows "Pro" as their current plan.',
  })

  await given(page, 'a customer on a trial plan is signed in', async () => {
    await page.goto('/login')
    await page.getByLabel('Email').fill(trialCustomer.email)
    await page.getByLabel('Password').fill(trialCustomer.password)
    await page.getByRole('button', { name: 'Sign in' }).click()
    await expect(page.getByText('Trial')).toBeVisible()
  })

  await when(page, 'they choose the Pro plan and confirm', async () => {
    await page.goto('/billing')
    await page.getByRole('button', { name: 'Choose Pro' }).click()
    await page.getByRole('button', { name: 'Confirm upgrade' }).click()
  })

  await then(page, 'the billing page shows Pro as the current plan', async () => {
    await expect(page.getByTestId('current-plan')).toHaveText('Pro')
  })
})
Enter fullscreen mode Exit fullscreen mode

getByRole and getByLabel find elements by the words a person sees on screen, so even the code reads close to the criterion. Keep the "Then" step to assertions only. If it clicks anything, you are really testing two criteria.

How do you produce the report you hand over at sign-off?

Turn on the HTML reporter with a fixed output folder and keep traces for failures:

// playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  reporter: [['html', { outputFolder: 'acceptance-report', open: 'never' }]],
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'retain-on-failure',
  },
})
Enter fullscreen mode Exit fullscreen mode

Run BASE_URL=https://staging.example.com npx playwright test, then zip the acceptance-report folder. The client unzips it and runs npx playwright show-report acceptance-report, or you host the folder on any static URL, because the report needs to be served over HTTP. Each test shows its AC number, the criterion sentence, every step with a pass or fail mark, and a screenshot under each step.

What should you check before sending the report?

Run through this list first:

  • Every agreed criterion has a test. Compare the AC numbers in the spec with the test titles. A missing criterion looks like something you hid.
  • The run used the build being signed off. Put the commit hash in the zip file name and in an annotation, so nobody argues later about which build was tested.
  • Failures stay in. If AC-09 fails, send it as failed with a note. A report where everything is green looks suspicious and invites a manual retest.
  • No real customer data appears in screenshots. Use fixture data only, with obvious test emails.
  • Flaky tests get fixed, not retried until they pass. If a criterion needs retries to pass, the client will run into the same flakiness in real use.

Where do you start with software acceptance testing on your next project?

Start at your next sign-off. Pick the three criteria the client cares about most, write them as Given/When/Then tests with the helpers above, and send the report before the meeting instead of during it.

If you want help setting this up, or want to know whether your current tests cover what you agreed with your client, Geminate Solutions offers a free review of your app and test plan and replies within 24 hours. Details are on the software testing and QA page.

Top comments (0)