ScreenshotNeo

BlogHow-to

How to Test a Web UI with Functional Tests

Test the browser workflows users depend on with isolated, maintainable functional tests. Learn what to cover, how to choose assertions, and how to debug failures.

By the ScreenshotNeo team4 October 202610 min read

Functional tests check whether a web application does what a user expects through its interface. Choose a small set of important workflows, drive the rendered page as a user would, and assert visible outcomes and persisted effects. Keep each test isolated, control its data and browser state, and run it in CI against the browsers your product supports.

This guide uses Playwright with JavaScript for runnable browser examples. The same testing principles apply with Cypress or Selenium. End-to-end tests provide confidence in the integrated application; component and API tests add faster, narrower coverage, but cannot substitute for checking that the browser UI works.

1. Decide what the functional test must prove

Start with a user journey and its expected outcome, before choosing selectors or writing browser steps. A useful functional test answers: “Can a user complete this task, and can they see that it succeeded?”

  1. Name the user and goal. For example: a signed-in customer submits an order, or an administrator changes a setting.
  2. Write the observable outcome. The confirmation is displayed, the saved setting survives a reload, or a record appears on the next screen.
  3. List prerequisites and data. Identify the account, permissions, records, feature flags, and environment state needed for the scenario.
  4. Decide what belongs in this test. Include only steps required to establish the outcome. Use a test setup API for expensive or irrelevant prerequisites where appropriate.

Choose release-critical journeys: commonly authentication, purchase or checkout, a multi-screen action whose data must persist, and a small pre-release smoke check. These examples only apply when the product has those workflows. Prefer tests that cover a failure with real user impact over a large number of tests that repeat the same path.

2. Put each check at the right test layer

Layer Good for What it cannot establish alone
End-to-end functional A complete workflow through the rendered UI and integrated services It is slower to set up and maintain; a failure may have several possible causes
Component An isolated component’s states, interactions, and rendering That routing, services, authentication, and the whole application work together
API Service contracts, backend behavior, and fast test-data setup That the browser renders the right content or responds correctly to user input

Use end-to-end tests where confidence depends on the actual interface and the integrated system. Use component tests for focused UI behavior and API tests for service boundaries or setting up state. API setup can be faster than driving a long form, but retain browser assertions for the behavior users see. Cypress documents these scopes and tradeoffs in its testing types guide.

3. Set up a Playwright functional test

Install Playwright in a JavaScript project and install its browser binaries:

npm init -y
npm install --save-dev @playwright/test
npx playwright install

Add a test file such as tests/settings.spec.js. This example assumes the application has a sign-in route, a settings page, and accessible labels. Replace the paths, credentials, and expected setting with your application’s test environment.

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

test('a user can update and retain a setting', async ({ page }) => {
  await page.goto('http://127.0.0.1:3000/login');

  await page.getByLabel('Email').fill(process.env.E2E_EMAIL);
  await page.getByLabel('Password').fill(process.env.E2E_PASSWORD);
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page).toHaveURL(/dashboard/);
  await page.getByRole('link', { name: 'Settings' }).click();

  const emailUpdates = page.getByRole('checkbox', {
    name: 'Email me about account updates'
  });
  await emailUpdates.check();
  await page.getByRole('button', { name: 'Save changes' }).click();
  await expect(page.getByRole('status')).toContainText('Settings saved');

  await page.reload();
  await expect(emailUpdates).toBeChecked();
});

Run the test with npx playwright test. The example verifies a visible success message and persistence after reload. A test that only checks that a button can be clicked would miss whether the change was saved.

For a purchase journey, similarly assert the visible order confirmation and an order detail or persisted result that matters. Use a test payment provider or application-specific test setup; never place real credentials or live payment details in test source code.

4. Use locators that reflect the test contract

  • Role and accessible name: use getByRole for controls such as buttons, links, and checkboxes. This usually corresponds closely to how people identify controls.
  • Label: use getByLabel for form controls whose label is part of the interaction.
  • Visible text: use text when the wording itself is the behavior under test, such as a confirmation message.
  • Test attribute: use a stable attribute such as data-testid when copy or styling may change independently of the behavior. Keep the attribute intentional and stable.

Choose the locator based on what a change should mean. If changing the button’s wording should make the test fail, assert the wording. If copy changes should not affect the behavioral test, a stable test attribute may be appropriate. Avoid selectors coupled to CSS classes, DOM nesting, or framework internals; these often break during unrelated refactoring.

A role-based locator does not prove the page is accessible. Add explicit accessibility assertions and assessment for accessibility requirements rather than treating selector choice as an audit. See Playwright’s best practices and Cypress best practices.

5. Isolate state, data, and test order

Each test should be able to run alone, in a different order, and after a retry without inheriting an accidental state from another test. Shared mutable accounts or records can make a suite pass locally and fail under parallel CI execution.

  • Create or seed the required data for each test, and remove it or use unique identifiers when tests mutate it.
  • Use a programmatic login or saved authentication state when login UI itself is not the subject of the test. Keep at least a focused test for the login workflow.
  • Reset or control feature flags, permissions, locale, and relevant server state.
  • Do not depend on test execution order or on a previous test having created a record.
  • Keep credentials in CI secrets or environment variables, not in committed files or logs.

Playwright fixtures can establish per-test state. For example, create data through a test-only API before visiting the page, then assert through the UI. Keep setup scoped to the scenario so that the browser test still exercises the interface behavior in question.

6. Cover browsers and run functional tests in CI

Test the browsers and device profiles your product claims to support. There is no universal browser matrix: select it from your users, support commitments, and known compatibility risks. Playwright projects can run a configured suite in Chromium, Firefox, and WebKit.

A minimal configuration can start with Chromium and expand to the other required browsers:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  retries: process.env.CI ? 1 : 0,
  use: {
    baseURL: 'http://127.0.0.1:3000',
    trace: 'on-first-retry'
  },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox', use: { browserName: 'firefox' } },
    { name: 'webkit', use: { browserName: 'webkit' } }
  ],
  webServer: {
    command: 'npm run start:test',
    url: 'http://127.0.0.1:3000',
    reuseExistingServer: !process.env.CI
  }
});

Define the start:test script for your application and ensure the test environment has its required services and seeded data. Run npx playwright test in CI on changes that could affect the tested flows and before release. Browser differences can expose real compatibility problems, but running every test on every possible configuration can make feedback slow; prioritize the support matrix that matters to your product.

Use traces and the browser’s DOM and network evidence to diagnose a failure. A trace can show the action sequence, snapshots, and requests around the failure. Capturing traces for every test can add performance and storage overhead, so collect them on retry or failure according to your CI needs. Playwright’s guidance covers CI, browser projects, and trace use.

7. Add accessibility checks to the relevant states

Functional tests should cover accessibility-critical behavior in the states users actually encounter: form errors, menus, dialogs, keyboard-operated controls, and confirmation or failure messages. Assert concrete requirements such as a dialog being named, focus moving as expected, or an error being associated with its input when those are part of the requirement.

Automated accessibility scans can catch some detectable problems, but a clean scan cannot prove that an interface is accessible. Pair automated checks with manual assessment and inclusive user testing. Playwright explains this limitation in its accessibility testing guide; Cypress also describes accessibility testing within its testing types.

8. Troubleshoot common functional test failures

Symptom Likely cause Fix
Element not found or action times out The page is not in the expected state, the locator is brittle, or the element is not yet rendered Inspect the trace and page state; use a role or label locator that matches the control; wait for a meaningful state instead of adding arbitrary sleeps.
Test passes alone but fails in the suite Shared data, browser state, or execution order is leaking between tests Give the test isolated data and state, and make it safe to run independently and in parallel.
Test flakes around navigation or loading The assertion runs before the relevant visible outcome, or the application has variable network timing Use Playwright’s retrying assertions such as toHaveURL or toBeVisible against the expected state. Do not use fixed delays as a substitute for a condition.
Works in one browser but not another Browser behavior, application support, or environment differs Reproduce in the failing configured project, inspect browser-specific errors, and determine whether the application or test assumption needs correction.
Login fails only in CI Missing secrets, an unavailable identity provider, rate limits, or an environment mismatch Check secret names and test-environment access; use controlled test accounts and programmatic login for flows where authentication UI is not under test.
Trace or test output exposes credentials Sensitive values were entered into pages or emitted in logs Use test-only credentials, avoid logging secrets, and restrict access to CI artifacts that may contain page or network data.

9. Performance, reliability, and maintenance

Browser tests cost more time and maintenance than narrow component or API checks because they start browsers and cross more application boundaries. Keep the end-to-end suite focused on important integrated journeys; use component and API coverage for the large set of smaller cases. Use API setup when it removes irrelevant navigation without skipping the UI behavior under test.

For reliability, wait on conditions that express the expected state, use isolated data, and investigate recurring flakes rather than masking them with long timeouts or retries. Retries can provide diagnostic signal, but a retry that passes does not prove the original failure was harmless. Track which workflow and environment failed, then fix the underlying race, state leak, or product defect.

For cost, account for CI execution time, browser workers, retained traces, and the engineering time spent maintaining data and selectors. Run a focused smoke suite on frequent changes and broader supported-browser coverage at an appropriate release or CI cadence. The right balance depends on application risk and the speed required from feedback.

10. Inspect rendered pages with screenshots when debugging

A screenshot can help show what the browser rendered at a failure point, especially for layout regressions, missing content, overlays, and responsive behavior. It complements assertions and traces; it does not replace checking the expected text, state, or application data.

With Playwright, you can capture a page in a diagnostic script:

import { chromium } from '@playwright/test';

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: 'page.png', fullPage: true });
await browser.close();

Replace the example URL with an application environment you are authorized to inspect. In CI, attach screenshots to failed runs where they help diagnosis, and avoid storing sensitive page contents in broadly accessible artifacts.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server for developers. A single GET request returns a PNG, JPEG, WebP, or PDF. Use it when you need a rendered-page capture for visual inspection without provisioning a browser in your script; keep functional assertions in your tests to verify behavior.

For example, capture a page with cURL:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

See the ScreenshotNeo API documentation for request options and details. Cookie banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. An MCP server provides take_screenshot, get_page_info, and capture_pdf for AI agents. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.

Sign up for 1,000 free screenshots a month, with no card required.

Frequently asked questions

How many end-to-end tests should a web UI have?

There is no useful fixed number. Cover the workflows whose failure would matter to users, and keep each test focused on a meaningful outcome.

Should every functional test run on every browser?

Run against the browsers your product supports and prioritize coverage according to user impact and compatibility risk. A product’s support commitments determine its matrix.

Can a screenshot prove that a workflow works?

No. It records appearance at a point in time. Assert interaction results and persisted state separately.

Does an automated accessibility scan certify accessibility?

No. It can detect some issues, but it cannot establish that all users can use the interface. Combine scans with manual assessment and inclusive user testing.

When should login be part of an end-to-end test?

Include the login interaction when authentication is the behavior under test. For other journeys, controlled programmatic login can reduce setup noise while preserving a separate login test.