How I Test Client Sites Without a QA Team
A one-person agency's Playwright setup: the seven checks worth writing for a client site, what to skip, and how to run them against production.
Kemal EsensoyĀ·Modified on November 4, 2026
A client site I built has a contact form that is the only way anyone reaches that business. No phone number on the site. No live chat. One form. If it breaks, the business does not get quieter, it goes silent, and nobody finds out until someone happens to ask why it has been a slow month.
That is the failure I write tests to prevent. Not bugs in general. That one.
I am a one-person agency in Augsburg. There is no QA person. There is no staging sign-off meeting. There is me, a laptop, and a client who checks their own website roughly never. So when people ask what playwright testing for small agencies actually looks like in practice, the honest answer is: much smaller than you think, and pointed at exactly the things that fail quietly.
The Math That Makes You Skip Tests (And Where It Breaks)
Here is the reasoning every solo dev has run, including me. A client site is 40 pages. I build it in three weeks. Writing a test suite adds maybe a day. The client is paying a fixed price. That day comes out of my margin, and I already clicked through the whole site before launch. So: no tests.
That math is correct on launch day. It is wrong on day 200.
Because the site does not stay still. A content edit, a dependency bump, a Node version change on the host, a form provider that quietly changes their API, a CMS field someone renames. I deploy via Docker and Coolify behind Cloudflare, and I have had all of those land on a site months after handoff. And after a post-launch change I am not clicking through 40 pages. I am checking the one page I touched.
The break in the reasoning is not "tests catch bugs." It is that the cost of a bug scales with how long it stays invisible. A broken layout is reported within a day because someone sees it. A broken form submission is reported in six weeks, by which point you have no idea which deploy did it and the client has lost real leads. I wrote about the slow decay side of this in what actually breaks on websites nobody maintains, and a silent form is the most expensive item on that list.
So the question is not "should I test." It is "what is the smallest set of checks that turns a silent six-week failure into a red X in my inbox tomorrow morning."
The Seven Checks Worth Writing for a Client Site
Seven. That is the whole list I use for a 20 to 60 page marketing site or small shop. Every page returns 200 and renders something. The contact form submits and the submission arrives. Critical navigation works. The 404 page behaves. SEO essentials are present. No console errors on key pages. Mobile viewport renders without horizontal overflow.
That is the entire scope of playwright testing for small agencies as I practice it. It fits in one file per site and takes an afternoon the first time, less once you have a template.
The highest-value one by a wide margin is the page sweep, because it is nearly free. You already have a sitemap. Read it, and assert every URL returns 200 and has an h1.
import { test, expect } from '@playwright/test';
import { readFileSync } from 'fs';
const urls = [...readFileSync('dist/sitemap-0.xml', 'utf8')
.matchAll(/<loc>(.*?)<\/loc>/g)].map(m => new URL(m[1]).pathname);
for (const path of urls) {
test(`page renders: ${path}`, async ({ page }) => {
const response = await page.goto(path);
expect(response?.status()).toBe(200);
await expect(page.locator('h1')).toBeVisible();
await expect(page.locator('main')).not.toBeEmpty();
});
}
Forty pages, zero maintenance, and it catches the class of failure where a CMS change nukes a template or a route stops building. Because it reads the build output, the test list grows when the site does.
Second: the SEO block. Titles and canonicals are the thing that silently regresses on every migration, and unlike a broken button nobody will ever notice by looking.
test('SEO essentials on the homepage', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/.{20,60}/);
const head = page.locator('head');
await expect(head.locator('meta[name="description"]'))
.toHaveAttribute('content', /.{50,160}/);
await expect(head.locator('link[rel="canonical"]'))
.toHaveAttribute('href', /^https:\/\//);
await expect(head.locator('meta[property="og:image"]')).toHaveCount(1);
});
Run that across a handful of representative templates, not all 40 pages. If you are mid-replatform, this is the cheapest insurance you can buy against the failure modes in the website migration SEO checklist.
Third: the form. This is the one that justifies the entire suite.
test('contact form submits and confirms', async ({ page }) => {
await page.goto('/contact');
await page.getByLabel('Name').fill('Playwright Smoke Test');
await page.getByLabel('Email').fill('smoke+test@wunderlandmedia.com');
await page.getByLabel('Message').fill('Automated check, please ignore.');
const submission = page.waitForResponse(r =>
r.url().includes('/api/contact') && r.status() === 200);
await page.getByRole('button', { name: /send/i }).click();
await submission;
await expect(page.getByText(/thanks|received/i)).toBeVisible();
});
Note what it asserts: the network call succeeded and the user sees confirmation. Checking only the thank-you message is how you end up with a form that shows a green checkmark and drops the payload. If you are running a static Astro site with a serverless function behind the form, the setup I described in adding a contact form to a static Astro site is exactly the shape this test covers.
What I Do Not Test at This Scale
I do not write component tests for a client marketing site. Not one. The components are mostly presentational, they change when the design changes, and the tests would need rewriting every time. The cost is real and the caught-bug count is close to zero.
I do not do visual regression on every page. Screenshot diffing on 40 pages means every legitimate copy edit produces 40 red tests, and by the third time you are approving diffs without looking. That is worse than no test, because now you have a ritual producing false confidence. If one page has layout that genuinely matters, snapshot that one.
I do not test third-party widgets. The cookie banner, the embedded map, the review carousel: if they break, they break in the vendor's code, and my test failing tells me nothing I can act on. Playwright's own best practices say the same thing, to mock third-party APIs rather than testing dependencies you do not control. What I do test is that the page still renders correctly when the widget fails to load, because that is my code's job.
And I do not chase coverage numbers. Coverage is a meaningful metric for a library. For a client website it is a number that goes up when you write tests nobody needed.
Flaky Tests Are Why People Quit E2E
Everyone who abandoned end-to-end testing abandoned it for the same reason: the suite started failing for reasons unrelated to the code, and they stopped believing it. A test suite you do not believe is worse than no suite, because it costs time and produces nothing.
Four habits handle almost all of it.
Use role and label locators, not CSS. page.getByRole('button', { name: /send/i }) survives a redesign. page.locator('.btn-primary.mt-4') does not survive Tailwind class reordering. Playwright's guidance is to prefer user-facing attributes over CSS or XPath, and the practical reason is that your locators then break only when the user-visible behavior actually changed, which is when you want them to break.
Never write a fixed timeout. await page.waitForTimeout(2000) is the single biggest source of flake in every suite I have looked at. It is simultaneously too short on a slow CI runner and too slow everywhere else. Playwright locators auto-wait: they check the element is visible and enabled before acting, and web-first assertions like toBeVisible() retry until the condition holds. Use those and delete every hardcoded sleep.
Assert on state, not on isVisible(). expect(locator).toBeVisible() retries. if (await locator.isVisible()) returns instantly and races the page. Nearly identical to read, completely different in behavior.
Keep tests isolated. Each test gets its own browser context, cookies and storage. That is Playwright's default, and people break it by writing a test that depends on an earlier one having logged in or created a record. Then one failure cascades into six and you debug the wrong test for an hour.
If a test still flakes after all that, I delete it. A check I cannot trust does not belong in a suite whose entire purpose is that a red result means something.
Running It in CI Without Burning Your Minutes
Playwright's documented GitHub Actions workflow is short enough that there is no excuse for not having one:
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
One change from the docs version: install --with-deps chromium instead of all three browsers. For a client marketing site, cross-browser rendering differences are not the failure mode you are hunting, and installing one browser instead of three cuts the slowest step of the job substantially. I have not benchmarked it precisely, so treat that as an order-of-magnitude claim rather than a number.
On budget: GitHub includes 2,000 Actions minutes a month on private repos for free accounts, 3,000 on Pro and Team, and standard runners are free on public repos. A smoke suite this size is not what exhausts that. Running the full suite on every push to every branch is. Run it on pull requests into main, and again after deploy against the live URL.
That post-deploy run is the one that actually earns its keep. A green PR tells you the code is fine. A green run against the deployed site tells you the build, the environment variables, the CDN and the host all cooperated, which is a different and larger set of things that can go wrong.
Nightly against production depends on the client. Brochure site, no. Anything with a checkout or a booking flow, yes, because that failure costs money per hour.
Testing Against Production Without Emailing Your Client 40 Times
Here is the part nobody writes about, and it is the thing that trips up most attempts at playwright testing for small agencies. The moment you run a form test against the live site nightly, you are submitting a real contact form. Your client gets a lead notification at 3am. Their CRM fills with junk. Their analytics logs a session with a conversion, and their conversion rate is now a lie.
Three things solve it, and you want all three.
Mark the traffic at the browser level. Set a flag in storageState that your analytics snippet reads, and skip loading analytics when it is present. A pattern that works well is a ga_debug_mode value in localStorage, which you can wire to GA4's own developer-traffic data filter or use to short-circuit the tag entirely:
// playwright.config.ts
use: {
storageState: {
cookies: [],
origins: [{
origin: process.env.BASE_URL!,
localStorage: [{ name: 'ga_debug_mode', value: 'true' }],
}],
},
}
Give the backend a way to recognize the test. I send a header with a shared secret, and the form handler treats a matching request as a dry run: validate everything, run the same code path, return the same 200, do not send the mail and do not write the record.
extraHTTPHeaders: { 'x-smoke-test': process.env.SMOKE_SECRET! }
The dry run has to go through the real validation, otherwise you are testing a code path that only exists in tests, which proves nothing.
Use an address you own for anything that does escape. smoke+test@yourdomain.com, never the client's inbox, and filter it server-side.
Do this before the first nightly run, not after. Explaining why a client's CRM holds 200 entries named "Playwright Smoke Test" costs more goodwill than the tests are worth, and the arrangement belongs in your handoff documentation alongside everything else in the project handoff checklist.
Astro and Next.js Need One Different Line
Most of my client work is Astro or Next.js, and the suite is identical across both. The only thing that changes is how the server starts.
For Next.js, Playwright's webServer config runs the production build:
webServer: {
command: 'npm run build && npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
For a static Astro site there is a nice property: the build output is just files. You can point a static server at dist/ and test the exact artifact that will be uploaded, with no framework runtime involved at all.
webServer: {
command: 'npm run build && npx serve dist -l 4321',
url: 'http://localhost:4321',
reuseExistingServer: !process.env.CI,
},
That is closer to production than a dev server, which has HMR, unminified output and different routing behavior. Plenty of the "works locally, broken in production" bugs I have chased over eight years were dev-server illusions, the same family as the invisible problems quietly costing you customers.
Playwright in 2026: The Agents Are Real, the Value Is Mixed
Playwright 1.63 shipped on 4 September 2026. Recent releases added test locks for tests sharing a resource, cross-frame locators so you stop reaching into iframes manually, AbortSignal support, and WebAuthn passkey virtual authenticators. Docker images moved to Node 24 LTS, Ubuntu 26.04 is supported. Solid, unglamorous progress.
The headline feature is Test Agents, introduced back in 1.56 in October 2025. Three of them: a planner that explores the app and writes a Markdown test plan, a generator that turns that plan into executable tests while verifying selectors live, and a healer that replays failing steps and proposes a patch. You install them with npx playwright init-agents --loop=claude (or vscode, codex, opencode). Playwright also now bundles its MCP server, runnable via npx playwright mcp.
I use Claude Code every day, so I wanted this to be great. Honest verdict: the generator is useful and the healer makes me nervous.
The generator is good at the boring part. Point it at a page and it produces a first draft with real role-based locators, because it checks them against the live DOM instead of guessing from source. That saves the tedious half hour of writing selectors. The docs themselves note generated tests may contain errors needing the healer, which matches what I saw.
The healer is where I stop. A tool that automatically rewrites a failing test until it passes is, structurally, a tool that can make a real regression disappear. That is the exact opposite of what I want from a suite whose only job is telling me when something broke. I run the healer's suggestions as suggestions and read every one. If a locator changed because I renamed a button, great, apply it. If a locator changed because the button vanished, that is the bug, and a green test is the worst possible outcome.
So: use the generator to write the first draft faster. Do not let the healer close the loop unattended.
What I Actually Ship
Seven checks. One config file. One GitHub Actions workflow. A day of work the first time a site gets it, about an hour on every site after, because it is copy-paste with a different baseURL.
I am not going to tell you this catches most bugs. It does not. It catches one category: the change that silently breaks something nobody looks at. Narrow, and the only claim I can make honestly. Most of what goes wrong on client sites is still found by a human noticing something.
But the form test alone has changed how I feel about deploying to a site I have not touched in four months, and that is worth an afternoon.
If you are running client sites with no QA and no safety net, and you would rather have a second pair of eyes on the setup than figure out where the gaps are on your own, that is a conversation I am happy to have.
Find these posts useful? Mark Wunderlandmedia as a preferred source on Google ā my articles will then show up more often in your Search results, AI Overviews and AI Mode.
Set as preferred sourceAbout the Author
Kemal Esensoy
Kemal Esensoy, founder of Wunderlandmedia, started his journey as a freelance web developer and designer. He conducted web design courses with over 3,000 students. Today, he leads an award-winning full-stack agency specializing in web development, SEO, and digital marketing.