Skip to content

AI-Assisted Test Automation: What LLM Agents Actually Automate

  • by

Most teams evaluating AI for test automation ask the wrong question, run a two-hour demo, and come out with either hype or disappointment. Both outcomes are useless because the thing being measured was never defined.

A test is not a single artifact. It is the output of a chain of decisions, and an LLM agent is good at some links in that chain and actively harmful on others. The job of a QA lead is to find the boundary, not to rate the tool.

The Question Isn’t “Can AI Write Tests” but “Which Step Does It Take Over”

Break the lifecycle into steps that can be assigned separately:

  1. Discovery — understanding what the feature does and what can break.
  2. Scenario design — deciding which cases are worth automating.
  3. Locator selection — binding a scenario to real UI elements.
  4. Code generation — turning the scenario into a runnable test.
  5. Review — deciding whether the test verifies the right thing.
  6. Maintenance — keeping it green when the product changes.
  7. Debugging — diagnosing a failure as product bug, test bug, or flake.

An LLM agent with browser access is genuinely fast at steps 3 and 4, and useful as a hypothesis generator in step 7. It is weak at step 2 because scenario design requires knowing which failures cost money, and it is a net cost at step 5 — every generated test adds review load that did not exist before.

So the honest framing is: the agent moves work from writing to reviewing. Whether that is a win depends entirely on whether your review is cheaper than your writing. If your team has no review discipline, AI generation will make quality worse, faster.

Where an LLM Agent Sits in the Test Workflow

The agent is not a participant in your pipeline. It is a code producer that sits before the pipeline, on a developer machine or inside a pull request, and its output passes through the same gate as human-written code.

Acceptance criteria and Playwright MCP's accessibility tree feed the LLM agent, whose test code goes review→repo→CI run.

The critical property: determinism belongs to the generated code, not to the agent. The agent is non-deterministic by design — ask twice, get two different tests. That is tolerable precisely because its output is frozen into a file, reviewed, and then replayed identically forever. The moment you let the agent run inside CI and decide things at runtime, you lose that property and you no longer have a regression suite.

Playwright MCP: Giving the Agent the Real Page State

A language model looking at a screenshot guesses. It infers that a blue rectangle is probably a submit button and writes page.click('.btn-primary'). That locator breaks on the next CSS refactor and tells you nothing when it does.

The Playwright MCP server removes the guessing. MCP (Model Context Protocol) exposes tools to the agent; the Playwright server’s tools drive a real browser and return the page as a structured accessibility snapshot — roles, accessible names, states — instead of pixels. The agent reads button "Create invoice" as a fact, not an inference. That is why its locators come out as getByRole('button', { name: 'Create invoice' }): the role and name are what it was given.

This also makes the generated locator align with how a screen reader resolves the element, which means a broken locator usually signals a real accessibility or labeling change rather than a cosmetic one.

Setup, for an MCP-capable client (Claude Code, VS Code Copilot agent mode, Cursor — the config file location differs, the block does not):

{
  "mcpServers": {
    "playwright": {
      "command": "npx",
      "args": ["-y", "@playwright/mcp@latest", "--browser", "chromium"]
    }
  }
}

A session then looks like this — you give it a goal and the application URL, it navigates, snapshots, and writes a file:

> Open http://localhost:3000/invoices, create an invoice for
> customer "Acme Ltd" with amount 1250.00, and write a Playwright
> test for it in tests/invoices/create-invoice.spec.ts.

[browser_navigate] http://localhost:3000/invoices
[browser_snapshot]
  - button "New invoice"
  - table "Invoices"
[browser_click] button "New invoice"
[browser_snapshot]
  - combobox "Customer"
  - textbox "Amount"
  - button "Save"
[browser_type] textbox "Amount" -> "1250.00"
[browser_click] button "Save"
[browser_snapshot]
  - alert "Invoice INV-1043 created"

The resulting file:

import { test, expect } from '@playwright/test';

test('creates an invoice for an existing customer', async ({ page }) => {
  await page.goto('/invoices');
  await page.getByRole('button', { name: 'New invoice' }).click();
  await page.getByRole('combobox', { name: 'Customer' }).selectOption('Acme Ltd');
  await page.getByRole('textbox', { name: 'Amount' }).fill('1250.00');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page.getByRole('alert')).toContainText('created');
});

That is a reasonable skeleton and a bad test. The next section explains why.

Test Generation: Where the Agent Is Good and Where It Isn’t

The agent is fast at three things: producing a skeleton that navigates a flow correctly, writing repetitive CRUD variants, and adapting an existing test to a new data set. All three are mechanical translation work, and that is exactly what a model trained on code does well.

It fails in predictable patterns. It invents business rules it was never told about. It writes presence assertions instead of value assertions. It adds waits to make a flaky step pass instead of diagnosing it. And it reuses whatever data it saw on the page, which couples the test to a seeded database.

Typical weak output:

import { test, expect } from '@playwright/test';

test('invoice test', async ({ page }) => {
  await page.goto('/invoices');
  await page.getByRole('button', { name: 'New invoice' }).click();
  await page.getByRole('combobox', { name: 'Customer' }).selectOption('Acme Ltd');
  await page.getByRole('textbox', { name: 'Amount' }).fill('1250.00');
  await page.getByRole('button', { name: 'Save' }).click();
  await page.waitForTimeout(2000);
  await expect(page.getByRole('alert')).toBeVisible();
  await expect(page.locator('table tbody tr')).toHaveCount(4);
});

Three defects. toBeVisible() on the alert passes for an error alert too. waitForTimeout(2000) hides a race and adds two seconds to every run. toHaveCount(4) encodes the state of one developer’s database.

The same scenario, corrected:

import { test, expect } from '@playwright/test';
import { createCustomer } from '../fixtures/customer';

test('creates an invoice and shows it in the list with the entered amount', async ({ page, request }) => {
  const customer = await createCustomer(request, { name: `Acme-${Date.now()}` });

  await page.goto('/invoices');
  await page.getByRole('button', { name: 'New invoice' }).click();
  await page.getByRole('combobox', { name: 'Customer' }).selectOption(customer.name);
  await page.getByRole('textbox', { name: 'Amount' }).fill('1250.00');
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByRole('alert')).toContainText(/Invoice INV-\d+ created/);

  const row = page.getByRole('row', { name: new RegExp(customer.name) });
  await expect(row).toBeVisible();
  await expect(row.getByRole('cell', { name: '1,250.00' })).toBeVisible();
  await expect(row.getByRole('cell', { name: 'Draft' })).toBeVisible();
});

The difference is not style. The first test passes when the amount is saved as 0.00; the second does not. An agent will produce the first unless you tell it what a correct invoice looks like — which is the subject of the next section. The pattern is general enough to name: generated tests fail open. They assert that something happened, not that the right thing happened.

Test data deserves its own discipline beyond review; if your suite still depends on a shared seed, every test producing its own data is the prerequisite, not an optimization.

From Acceptance Criteria to Tests: Input Quality Decides Output Quality

A vague user story produces a vague test, and no prompt engineering fixes that. The agent cannot assert a rule nobody wrote down.

Compare the input “As a user I want to create invoices so that I can bill customers” with this:

Feature: Invoice creation

  Background:
    Given a customer "Acme Ltd" exists with status "active"
    And no invoice exists for that customer in the current period

  Scenario: Creating a draft invoice
    When I create an invoice for "Acme Ltd" with amount 1250.00
    Then the invoice is listed with status "Draft"
    And the displayed amount is "1,250.00"
    And the invoice number matches the pattern "INV-<digits>"

  Scenario: Amount below the allowed minimum
    When I create an invoice for "Acme Ltd" with amount 0.00
    Then no invoice is created
    And the form shows the error "Amount must be greater than 0.00"

Given that, the agent can produce the negative case without inventing anything:

import { test, expect } from '@playwright/test';
import { createCustomer } from '../fixtures/customer';

test('rejects an invoice with an amount of zero', async ({ page, request }) => {
  const customer = await createCustomer(request, { name: `Acme-${Date.now()}`, status: 'active' });

  await page.goto('/invoices');
  await page.getByRole('button', { name: 'New invoice' }).click();
  await page.getByRole('combobox', { name: 'Customer' }).selectOption(customer.name);
  await page.getByRole('textbox', { name: 'Amount' }).fill('0.00');
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByText('Amount must be greater than 0.00')).toBeVisible();
  await expect(page.getByRole('alert')).toHaveCount(0);

  await page.goto('/invoices');
  await expect(page.getByRole('row', { name: new RegExp(customer.name) })).toHaveCount(0);
});

Note what the criteria supplied: the exact error string, the preconditions, and the post-state to verify. Those three things are the difference between a test and a click script. If your backlog does not contain them, the bottleneck is your refinement process, not your tooling.

Six Things to Check When Reviewing a Generated Test

Use this as a PR checklist with explicit rejection thresholds.

  1. Does the assertion verify a business outcome? Reject if the only assertions are toBeVisible() or toHaveCount(>0) on containers. At least one assertion must compare a value the feature is responsible for producing.
  2. Is the locator semantic? Reject any getByRole-less chain built on CSS structure — .card > div:nth-child(2), generated class hashes, XPath with indices. Role, label, or data-testid only.
  3. Does the test create its own data? Reject if it references a record it did not create, or asserts a count that depends on pre-existing rows.
  4. Are there fixed waits? Reject any waitForTimeout without a code comment naming the external constraint it compensates for. Web-first assertions already wait.
  5. Is it order-dependent? Reject if the test only passes when another test ran first. Run it alone with --repeat-each=3 before approving.
  6. Does the failure message explain the cause? Reject if a failure produces only expected visible, got hidden with no indication of which step or which entity. Add a test title that names the rule and use scoped locators so the error points at a row, not the page.

Items 1 and 3 are the ones generated tests fail most often, because both require knowledge the agent did not have.

Why Self-Healing Locators Don’t Solve the Problem

Vendors sell self-healing as maintenance reduction: the locator breaks, the tool finds a visually or structurally similar element, the test goes green. That is exactly the failure mode you should fear.

A broken locator is information. If the “Save” button lost its accessible name, a screen reader user just lost the ability to submit the form. If a field moved from a modal to a separate page, the flow under test changed. Self-healing converts both of these into a silent green run. The test keeps passing while verifying something else.

The position is simple: a locator change is a product change and must be reviewed by a human. The way to reduce locator maintenance is to bind to things that are part of the contract — ARIA roles, labels, data-testid attributes owned by the team — not to add a layer that guesses when the binding fails. A self-healing event that does not open a pull request is a defect in the process.

Maintenance and Debugging: Where the Actual Gain Is

The highest-value use of an LLM in a test suite is not writing tests. It is reading failure evidence.

A Playwright trace contains the action log, network calls, console output, and a DOM snapshot per step. A human reading that correlates three timelines by hand. A model does that correlation quickly and produces a ranked list of hypotheses: the request returned 500 before the click; the element was detached and re-rendered between the locator resolution and the click; the assertion ran against a stale list because a polling refresh had not fired yet.

The workflow is: export the trace, hand the model the trace data plus the test source and the stack trace, ask for ranked root-cause hypotheses with the evidence for each, then verify the top hypothesis yourself by reproducing it. The model proposes; a human confirms.

This is lower-risk than generation for a structural reason. A wrong hypothesis costs you ten minutes and is discarded. A wrong generated test enters the repository and lies to you for months. Diagnosis output is consumed and thrown away; generation output is persisted.

The systematic part of this — classifying a failure as product bug, test bug, or environment flake before touching anything — is still yours to run; see diagnosing flaky tests.

Rules for Running AI-Generated Tests in CI

Two rules, both non-negotiable.

The agent never runs inside CI. Generation happens on a developer machine or in a PR, never at runtime. An agent that resolves locators during a pipeline run makes failures unreproducible, makes cost unbounded, and makes the suite unauditable — you can no longer answer “what exactly did this test check in March”.

Generated tests start in quarantine. Tag them, run them in a separate non-blocking job, and promote them to the main suite only after a fixed number of consecutive green runs — ten is a reasonable default for a suite that runs on every merge.

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

export default defineConfig({
  testDir: './tests',
  use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3000', trace: 'on-first-retry' },
  projects: [
    {
      name: 'main',
      grepInvert: /@quarantine/,
      retries: 1,
    },
    {
      name: 'quarantine',
      grep: /@quarantine/,
      retries: 0,
    },
  ],
});
// tests/invoices/create-invoice.spec.ts
test('creates an invoice and shows it in the list with the entered amount @quarantine', async ({ page }) => {
  // ...
});
# .github/workflows/e2e.yml
name: e2e
on: [push, pull_request]

jobs:
  main-suite:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test --project=main

  quarantine-suite:
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test --project=quarantine
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: quarantine-report
          path: playwright-report/

Promotion is a pull request that deletes the @quarantine tag. That makes the decision visible and reversible.

Running a Pilot: A Measurable Two-Week Trial

Pick one module with an existing manual scenario set — twenty scenarios is enough. Split it in half. Automate one half by hand, the other half with the agent using the workflow above. Same people, same sprint, same review checklist.

Two-week pilot: a 6-step loop — Pick one module (Around 20 existing manual scenarios), Split the set (Half written by hand, half by the agent), Review both the same way (Six-item checklist, measure review minutes), Quarantine the generated half (Separate CI job, no blocking), Count maintenance (Fixes needed in the first month, per test) and Compare total cost (Writing + review + maintenance, not writing alone).

Measure three numbers per test: minutes to write, minutes to review including rework, and number of maintenance commits in the following month. Also record how many generated tests were merged as-is versus rewritten from scratch — that ratio tells you more about your requirements quality than about the model.

The decision criterion is not “it wrote tests in thirty seconds”. It is whether generation + review + maintenance costs less than writing + maintenance. If the generated half needs more fixes in month one, the speed was borrowed, not earned.

Start the pilot on the module where your acceptance criteria are already written in Given/When/Then. If no such module exists, fix that first — the agent will not compensate for requirements you never wrote down.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.