Web application testing checks that a browser-based application does what its users and its owner need, on the browsers, devices and networks those users actually have. That covers seven areas: functional behaviour, content, layout, cross-browser behaviour, accessibility, security and performance. This guide covers the process we follow for web application testing.
Most guides list those areas and stop. This one also runs them. We wrote ten Playwright checks, pointed them at a public practice application built to misbehave, and ran them on five browser targets. Twelve of the fifty results failed. Reading those failures teaches more about web application testing than any list of testing types, because three of the twelve were not defects at all.
You will get the process in seven steps, a browser matrix built from market data, the code we ran, every result, a checklist table and a release gate.
Start from risk and traffic, not from a tool
A test plan built around a tool tests what the tool is good at. A plan built around risk tests what would hurt the business if it broke. Start with two lists before you write a single test.
List the flows that make or lose money
Write down every flow where a defect costs money, data or trust. For most web applications that means sign-up and login, the core task the product exists for, payment or checkout, and anything that changes stored data. Add every user role, because an admin and a customer see different screens and break in different ways.
Rank the list by what a failure would cost. A broken password reset locks customers out. A misaligned footer annoys nobody. The ranking decides where your deepest testing goes.
Build the browser matrix from data, not habit
Test the rendering engine, not the brand name. Chrome, Edge, Samsung Internet and Opera all run on Chromium. Safari runs on WebKit, and every browser on an iPhone has historically used WebKit too. Firefox runs on Gecko.
Three engines cover all six browsers in the table. That makes the minimum matrix simple: one Chromium browser, WebKit and Gecko. Then add the phones. Mobile accounts for 58.99% of page views worldwide, so at least one Android and one iPhone profile belong in every run.
Your own analytics beat world averages. A B2B tool used inside large companies may see far more Edge, and an Indian consumer app far more Android. Pull the last 90 days of browser and device data before you fix the matrix.
The web application testing process, step by step
The steps below are the ones we follow on client engagements. Steps four to six repeat for every build until someone makes the release decision.
Analyse and plan
Analysis turns the risk list into test conditions. Each condition gets one line in the plan: what you will test, how, on which environment, and what counts as a pass. A plan without pass criteria produces arguments at release time, because nobody agreed what "working" meant.
Prepare the environment and the data
Most flaky web tests trace back to the environment, not the code. You need a stable environment that matches production configuration, accounts seeded for every role, and data you can reset to a known state. Shared test accounts that other people change mid-run are a common cause of false failures.
Execute, report and retest
Run the automated suite on every build. Spend the human time on exploratory sessions against new or changed features, where scripted tests have nothing to say yet. Every defect report needs steps, the expected and actual result, the browser, the build and a severity. After each fix, retest the defect and then re-run the regression suite, because fixes break neighbouring code. Our guide to choosing what to re-run covers that trade-off in detail.
Close with a written record
Closure is a short document: what was tested, what was not, and which known risks the release carries. It protects the team when a defect appears later in an area that was explicitly out of scope.
Seven areas every web application test plan covers
Each area fails in a different way, so each needs its own check. The right column of the figure below shows what our run found in each one.
Functional behaviour
Functional testing confirms every flow does what the requirement says, for every role. Test the happy path, then the failures: wrong password, expired session, empty form, duplicate submission, the back button in the middle of a multi-step flow. Error paths are where most functional defects live, because developers test the path they built.
Content integrity
Content integrity means every image renders and every link resolves. It sounds trivial, and it is the first thing a visitor notices when it fails. It is also easy to automate, which makes it a poor use of a tester's time by hand.
Layout and responsiveness
Check each key page at a small phone width, a large phone and a desktop. The cheapest useful automated check is whether the page scrolls sideways: if the document is wider than the viewport, something is overflowing.
Cross-browser behaviour
Cross-browser testing confirms the same results on Chromium, WebKit and Gecko. Run the same suite on each engine rather than separate suites, so a difference in results points straight at an engine difference.
Accessibility
Test against WCAG 2.2, levels A and AA. The W3C published WCAG 2.2 as a Recommendation and "advises the use of WCAG 2.2 to maximize future applicability of accessibility efforts". Automated scanners find part of the problem. A manual pass with a keyboard and a screen reader finds the rest. Our guide to Playwright accessibility testing shows the automated half in more depth, and the WCAG compliance audit guide covers buying a full audit.
Security
The OWASP Web Security Testing Guide is the reference most testers work from. Its stable release is version 4.2, and version 5.0 is in development. A functional tester can cover the baseline: security headers, cookie flags, session expiry, access control between roles, and input handling. Deeper work belongs in a penetration test of the web application. Pentesting without the owner's written permission can be illegal, for example under the UK Computer Misuse Act 1990. That permission, and an agreed scope, are what separate a pentester from a hacker.
Performance
Google's Core Web Vitals set three targets: Largest Contentful Paint within 2.5 seconds, Interaction to Next Paint of 200 milliseconds or less, and Cumulative Layout Shift of 0.1 or less. web.dev measures them "at the 75th percentile of page loads, segmented across mobile and desktop devices". Load testing, which asks how the application behaves under many users at once, is a separate exercise with its own tools.
A real run: ten checks, five browser targets
To show what these areas look like in practice, we ran them. The application under test is the-internet.herokuapp.com, a practice site owned by Sauce Labs. Its own README describes it as "an example application that captures prominent and ugly functionality found on the web". In other words, some of its defects are deliberate.
The setup
We used Playwright 1.64.1 and axe-core 4.14.0. One configuration file defines five targets: desktop Chrome, desktop Firefox, desktop Safari through WebKit, a Pixel 7 profile and an iPhone 15 profile.
import { defineConfig, devices } from '@playwright/test';
// One spec set, five browser targets. Desktop share and mobile share in the
// article come from StatCounter (Sept 2026), not from this file.
export default defineConfig({
testDir: './tests',
timeout: 30_000,
retries: 0,
reporter: [['list'], ['json', { outputFile: 'results.json' }]],
use: { baseURL: 'https://the-internet.herokuapp.com', actionTimeout: 5_000 },
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 15'] } },
],
});The checks
Here is the spec file exactly as we ran it. Checks that do not depend on the browser engine, such as response headers, run once on Chromium and skip elsewhere.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
// Web application checks against https://the-internet.herokuapp.com, a practice
// app that ships deliberate defects. Some tests are written to fail: the post
// reports what each one found.
test.describe('functional', () => {
test('valid login reaches the secure area', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Username').fill('tomsmith');
await page.getByLabel('Password').fill('SuperSecretPassword!');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL(/\/secure$/);
await expect(page.locator('#flash')).toContainText('You logged into a secure area!');
});
test('wrong password is rejected with a message', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Username').fill('tomsmith');
await page.getByLabel('Password').fill('wrong-password');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL(/\/login$/);
await expect(page.locator('#flash')).toContainText('Your password is invalid!');
});
test('content that loads late is waited for, not slept for', async ({ page }) => {
await page.goto('/dynamic_loading/2');
await page.getByRole('button', { name: 'Start' }).click();
await expect(page.locator('#finish')).toHaveText('Hello World!', { timeout: 10_000 });
});
});
test.describe('content integrity', () => {
test('every image on the page actually renders', async ({ page }) => {
await page.goto('/broken_images');
const imgs = page.locator('.example img');
const n = await imgs.count();
const broken: string[] = [];
for (let i = 0; i < n; i++) {
const ok = await imgs.nth(i).evaluate((el: HTMLImageElement) => el.complete && el.naturalWidth > 0);
if (!ok) broken.push(await imgs.nth(i).getAttribute('src') ?? '?');
}
console.log(`IMAGES ${n} checked, broken: ${JSON.stringify(broken)}`);
expect(broken, 'broken images').toEqual([]);
});
test('every link on the home page resolves without a 4xx or 5xx', async ({ page, request }) => {
await page.goto('/');
const hrefs = await page.locator('a[href]').evaluateAll(as => as.map(a => (a as HTMLAnchorElement).href));
const internal = [...new Set(hrefs.filter(h => h.startsWith('https://the-internet.herokuapp.com')))];
const bad: string[] = [];
for (const h of internal) {
const r = await request.get(h, { maxRedirects: 5, failOnStatusCode: false });
if (r.status() >= 400) bad.push(`${r.status()} ${h.replace('https://the-internet.herokuapp.com', '')}`);
}
console.log(`LINKS ${internal.length} internal links checked, failing: ${JSON.stringify(bad)}`);
expect(bad, 'links returning 4xx/5xx').toEqual([]);
});
});
test.describe('layout', () => {
test('login page has no horizontal scroll at this viewport', async ({ page }) => {
await page.goto('/login');
const { sw, cw } = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }));
console.log(`LAYOUT viewport ${page.viewportSize()?.width}px scrollWidth ${sw} clientWidth ${cw}`);
expect(sw).toBeLessThanOrEqual(cw);
});
});
test.describe('accessibility', () => {
test('login page has no WCAG 2.2 A/AA violations axe can detect', async ({ page }, info) => {
test.skip(info.project.name !== 'chromium', 'one engine is enough for an axe scan');
await page.goto('/login');
const r = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']).analyze();
const summary = r.violations.map(v => `${v.id} (${v.impact}, ${v.nodes.length} node${v.nodes.length > 1 ? 's' : ''})`);
console.log(`AXE ${r.violations.length} rules violated: ${JSON.stringify(summary)} | passes ${r.passes.length} | incomplete ${r.incomplete.length}`);
expect(r.violations.map(v => v.id)).toEqual([]);
});
});
test.describe('security', () => {
test('login response sends the baseline security headers', async ({ request }, info) => {
test.skip(info.project.name !== 'chromium', 'headers do not depend on the browser');
const r = await request.get('/login');
const h = r.headers();
const want = ['strict-transport-security', 'content-security-policy', 'x-content-type-options', 'x-frame-options', 'referrer-policy', 'permissions-policy'];
const missing = want.filter(k => !(k in h));
console.log(`HEADERS status ${r.status()} present: ${JSON.stringify(want.filter(k => k in h))} missing: ${JSON.stringify(missing)}`);
expect(missing, 'missing security headers').toEqual([]);
});
test('session cookie is Secure and HttpOnly after login', async ({ page, context }, info) => {
test.skip(info.project.name !== 'chromium', 'one engine is enough for cookie flags');
await page.goto('/login');
await page.getByLabel('Username').fill('tomsmith');
await page.getByLabel('Password').fill('SuperSecretPassword!');
await page.getByRole('button', { name: 'Login' }).click();
const cookies = await context.cookies();
console.log(`COOKIES ${JSON.stringify(cookies.map(c => ({ name: c.name, secure: c.secure, httpOnly: c.httpOnly, sameSite: c.sameSite })))}`);
for (const c of cookies) {
expect.soft(c.secure, `${c.name} Secure`).toBe(true);
expect.soft(c.httpOnly, `${c.name} HttpOnly`).toBe(true);
}
});
});
test.describe('performance', () => {
test('home page LCP in a lab run (single sample)', async ({ page }, info) => {
test.skip(info.project.name !== 'chromium' && info.project.name !== 'mobile-chrome', 'LCP entries are Chromium-only');
await page.goto('/', { waitUntil: 'load' });
const lcp = await page.evaluate(() => new Promise<number>(res => {
new PerformanceObserver(l => { const e = l.getEntries(); res(e[e.length - 1].startTime); }).observe({ type: 'largest-contentful-paint', buffered: true });
}));
const nav = await page.evaluate(() => { const n = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; return { ttfb: n.responseStart, load: n.loadEventEnd }; });
console.log(`PERF ${info.project.name} LCP ${Math.round(lcp)} ms, TTFB ${Math.round(nav.ttfb)} ms, load ${Math.round(nav.load)} ms`);
expect(lcp).toBeLessThan(2500);
});
});The results
The run took 86 seconds with four parallel workers. Two more runs, the last using the code copied back from this page, produced the same 23 passes, 12 failures and 15 skips.
The functional checks passed on all five targets. Valid login, rejected login and late-loading content behaved the same on Chromium, WebKit and Gecko, on desktop and on phones. The layout check passed at 1,280, 412 and 393 pixels wide. Every failure came from content, security or the way a check was written.
Reading the failures: three of twelve were not defects
A failed test is a question, not a verdict. Each of the twelve failures needed a decision before it became a ticket.
Two broken images: a real defect
The image check found three images on the page and two that never load: asdf.jpg and hjkl.jpg. The result was identical in every browser, which tells you the problem is the files, not a rendering engine. This is the cheapest kind of defect to catch automatically and the most embarrassing to ship.
Three links returning 401: the check was wrong
The link check found 51 internal links and three that answered 401 Unauthorized: /basic_auth, /digest_auth and /download_secure. The application's README documents those pages as login-protected, with the credentials given. The pages work. The check assumed every link should open without a login, and that assumption was wrong.
This is the most common way an automated suite loses the team's trust. Give link checks an allow-list of protected routes, or accept 401 and 403 on routes you know require authentication. Otherwise the suite fails every night, people learn to ignore it, and a real broken link slips through unseen.
Missing headers and a cookie flag: findings to raise
The login page sent two of six baseline security headers.
Strict-Transport-Security, Content-Security-Policy, Referrer-Policy and Permissions-Policy were all absent. The OWASP HTTP headers cheat sheet notes that the CSP frame-ancestors directive "obsoletes X-Frame-Options" in browsers that support it, so the X-Frame-Options header that was present is the older of the two protections against clickjacking.
The session cookie, rack.session, was HttpOnly, which keeps scripts from reading it. It was not marked Secure, so a browser would also send it over plain HTTP. On a production login, that is a finding worth raising.
Four analytics cookies: not the application's to fix
The same check listed four optimizely cookies with neither Secure nor HttpOnly set. They come from a third-party script, not from the application's own code. They still belong in the report, because they are on the page and the site owner is accountable for them. But the fix is a vendor setting, so they go to a different owner than the session cookie.
What the tools could not decide
Zero accessibility violations is not a pass
The axe scan of the login page reported no violations against WCAG 2.2 levels A and AA.
It also left one rule undecided. On eight elements, including both form labels and both inputs, something on top of them hid the background colour from the scanner. Contrast on those elements is unknown, not passed. A person needs to check them, and a report that says "0 violations" without the undecided count overstates what was tested.
One speed number is not a result
We measured Largest Contentful Paint on desktop Chrome and on the Pixel 7 profile, once per run, across three runs.
Neither profile gave a stable number on an unchanged page. Every sample was under the 2.5-second target, and none of them proves anything on its own. Core Web Vitals are judged on real users at the 75th percentile. Use lab runs to catch regressions between builds, and field data to judge the page.
The web application testing checklist
The table below turns the seven areas into checks with pass criteria, and names the kind of tool that does each job.
Where automation helps and where people do
Automate a check when it has one right answer and you need it on every build. Keep a check manual when it needs judgement, or when the feature is too new to have settled behaviour.
Web application testing tools in 2027
Pick tools by area, not by brand. One tool rarely covers more than two areas well. The versions below are current as of late October 2026.
End-to-end and cross-browser
- Playwright (1.64.1) drives Chromium, WebKit and Firefox through one API, which is why we used it for the run above.
- Selenium (4.51.0, released 9 October 2026) is the long-standing standard, with bindings for Java, Python, C#, Ruby and JavaScript.
- Cypress (16.2.0) supports Chrome-family browsers, Edge and Firefox, plus experimental WebKit, according to its own documentation.
Accessibility
- axe-core (4.14.0) is the rules engine inside many accessibility tools, and plugs into Playwright, Selenium and Cypress.
Security
- ZAP is a free, open-source web application scanner. It left OWASP in September 2023 and describes itself as "an independent Open Source project controlled by the ZAP Core Team and supported by Checkmarx", which is why it is also known as ZAP by Checkmarx.
Performance and load
- Lighthouse and the browser's own performance APIs measure lab values per page.
- Grafana k6 is an open-source load testing tool where tests are written in JavaScript and run as code.
If your list of web testing tools still includes QTP or HP UFT, update it: that product is now sold by OpenText. Our broader guide to Web Testing covers simpler sites where a full application suite is more than you need.
Where web application testing differs from website and mobile app testing
A brochure website is mostly content: the main risks are broken links, layout and speed. A web application holds state. Users log in, change data, and expect the next screen to reflect what they did. That adds whole categories of risk: sessions, permissions between roles, data integrity, and concurrent users editing the same record.
A native mobile app adds device concerns that a web application in a phone browser does not have: installation, updates, push notifications, offline behaviour and operating system permissions. A web application tested in a phone browser still needs mobile viewports, touch targets and real-device checks, but not app-store builds.
Scope decides the plan, and it differs from client to client.
The cases that defeat a standard test plan
Login-protected and role-specific pages
As the 401 results showed, an automated crawl sees a protected page as broken. Crawl once per role, with an authenticated session, and treat an unauthenticated 401 on a protected route as the correct answer.
Third-party scripts
Analytics, chat widgets and payment frames run code you did not write, set cookies you did not choose, and can slow pages you did optimise. List every third-party script on the key pages, and test the page with each one blocked to see what it costs.
Experiments and feature flags
A/B tests and feature flags mean two testers on the same build can see different pages. Pin the variant in the test environment, or the suite fails at random and nobody can reproduce it.
Caching and content delivery
A fix can be live on the server and invisible behind a cache. When a fix does not appear, check the response headers for cache status before reopening the defect.
Release gate: seven checks before you ship
Pull the sections above into one gate. If any line fails, the release waits, or ships with a written risk that someone with authority has accepted.
Conclusion
Web application testing is not a list of testing types. It is a risk map, a browser matrix built from data, seven areas each with its own check, and a habit of reading every failure before filing it. In our runs, three of twelve failures were not defects and one accessibility "pass" was really eight undecided elements. Those details are the difference between a test report and a list of red ticks.
QAble is a QA company in Ahmedabad, India that tests web applications for clients across healthcare, fintech, SaaS and e-commerce. If you want a test plan and a browser matrix built for your application, talk to our web application testing team about a scoped engagement.