Test Coverage at Scale: The Complete Layer-by-Layer Breakdown Every QA Manager Needs
Why every layer of your test architecture exists, what it uniquely protects against, and the exact JavaScript code to implement it —…
Test Coverage at Scale: The Complete Layer-by-Layer Breakdown Every QA Manager Needs
Why every layer of your test architecture exists, what it uniquely protects against, and the exact JavaScript code to implement it — covering both web and native mobile applications from end to end.
In this article
- Introduction — why your tests are green, and production is still on fire
- Why the scale problem is harder than it looks — the combinatorial explosion
- Why legacy approaches collapse at scale
- The five-layer architecture overview
- Layer 1 — Unit & contract testing with Vitest and Zod
- Layer 2 — API integration testing with Supertest
- Layer 3 — Web E2E testing with Playwright and the Page Object Model
- Layer 4 — Native mobile testing with Detox
- Layer 5 — Coverage orchestration, gates, and the full CI pipeline
- The QA manager’s week-by-week rollout plan
- Conclusion — from coverage theatre to genuine confidence
Introduction
Why your tests are green, and production is still on fire
Let me start with a story you will probably recognise.
It is a Tuesday afternoon. A sprint just closed. The CI pipeline is green. Every test passed. The QA sign-off is done. The release goes out at 4 pm. By 6 pm, the customer support inbox is filling up. By 8 pm, engineering is in a war room. The payments flow is broken — silently, for a specific combination of iOS Safari users with items from a particular product category. It has been broken since the deployment. The incident report will later read: “No tests covered this scenario.”
I have been in that war room. Multiple times. At different companies, with different stacks, with teams of different sizes. After a decade managing QA for distributed systems — from 10-person startups to enterprise engineering organisations with hundreds of engineers — I can tell you that the story above is not the result of lazy developers or careless testers. It is the result of a structural gap in how the industry thinks about test coverage.
Most teams treat coverage as a single number. They aim for 80% line coverage, they hit it, they put a badge in the README, and they call themselves covered. But line coverage is only one of five distinct dimensions of quality risk that exist in a modern distributed application. The other four — contract coverage, integration coverage, journey coverage, and platform coverage — are almost entirely invisible to the standard unit test runner. And those four dimensions are exactly where production bugs hide.
“We had 3,000 tests and 85% code coverage. We still missed a critical regression that took down payments for six hours. Coverage numbers without architecture are meaningless.”
This article is my attempt to close that gap permanently. It is written from the perspective of a QA manager who has built and rebuilt test architectures from scratch, watched them succeed, watched them fail, and learned exactly why each layer exists and what happens when it is missing.
Who this article is for
This is for QA managers, lead engineers, and senior developers who are responsible for the quality of a modern application and who want to build a test architecture that actually gives them confidence — not just a green badge. It is for anyone who has experienced the war room scenario above and does not want to experience it again.
You do not need to be a testing expert to follow this. Every concept is explained from first principles. But you should be comfortable reading JavaScript, and you should be working on an application that is distributed, meaning it has multiple services, an API, a web frontend, and ideally a mobile app.
What you will walk away with
By the end of this article, you will have a complete, five-layer test architecture that covers every class of quality risk in your application. You will understand not just what each layer does, but why it exists as a distinct layer, what specific bugs it catches that no other layer can catch, and the exact JavaScript code to implement it for both web and native mobile platforms.
Every code block in this article is production-ready. The tools are: Vitest and Zod for Layer 1, Supertest for Layer 2, Playwright for Layer 3, Detox for Layer 4, and a custom aggregation script wired into GitHub Actions for Layer 5. All JavaScript. All open source. All battle-tested at scale.
There is also a week-by-week implementation plan at the end — because the biggest mistake teams make is trying to implement everything at once. This architecture is designed to be rolled out incrementally, starting with the layer that has the highest return on investment per hour of effort.
A note on philosophy before we start
The goal of this architecture is not perfect coverage. Perfect coverage is a myth — no team can test every combination of states in a distributed system. The goal is intentional coverage: knowing exactly what class of risk each layer addresses, knowing exactly where your dark zones are, and making deliberate, informed decisions about both.
A test suite with 500 strategically placed tests across five layers will give you more genuine confidence than 5,000 unit tests that all mock the database and call toBeTruthy(). Architecture matters more than volume. Understanding why matters more than knowing how. That is the spirit of everything that follows.
Let’s build something that actually works.
The Problem
Why scale breaks coverage — the combinatorial explosion
Before we go layer by layer, you need to fully internalise why this problem is hard. The challenge isn’t writing tests. The challenge is that in a modern distributed application, the number of meaningful test scenarios grows combinatorially, not linearly.
Consider a single user action: “place an order.” That action flows through your frontend, your auth service, your cart service, your inventory service, your payments service, and your notification service. Each of those services has multiple states. Each state combination produces a different system behaviour. And this is just one user action in your entire product.

The answer is not to test every combination, that is, mathematically impossible. The answer is to assign each class of risk to the layer most efficient at catching it. Unit tests for logic risk. Contract tests for shape risk. Integration tests for wiring risk. E2E tests for journey risk. Mobile tests for platform risk. Each layer covers what the others fundamentally cannot.
The five coverage dimensions teams conflate into one
The root of most coverage failures is that teams think of “coverage” as a single number — typically the line coverage percentage from their unit test runner. In reality, you have at least five distinct coverage dimensions that must each be addressed separately:
Code coverage: Did execution pass through this line of code? This is what most tools measure, and most teams fixate on. It tells you the least about real quality.
Branch coverage — did every if/else, every ternary, every switch case get exercised? A function can have 100% line coverage and still have uncovered logic branches.
Contract coverage — does the service that consumes your API still understand what you return after the last deploy? This is invisible to unit tests and the #1 source of silent integration bugs.
Journey coverage — can a real user complete the critical workflows in your application from the browser or mobile app? Unit tests tell you nothing about this.
Platform coverage — does your application work correctly across the browsers, OS versions, and device types your users actually use? This requires real browser engines and real device simulators.
Why legacy fails
Why the traditional approach collapses at scale
The classic approach — write unit tests, write some E2E tests, call it done — made sense in the monolith era. Your application was one codebase, one deployment, one team. Testing it was hard, but bound.
Modern applications are distributed by nature. Three specific failure patterns emerge repeatedly at scale:
The pyramid collapses. Teams write many unit tests at the bottom and a handful of E2E tests at the top, with almost nothing in the integration middle. The unit tests pass because they mock everything. The E2E tests pass because they only cover the happy path. The middle layer — where services actually talk to each other — is completely dark.
The coverage theatre. Teams reach 80% code coverage and stop. But code coverage doesn’t tell you what behaviour was exercised, only that a line was executed. You can execute a line in a test without asserting anything meaningful about what it does. A test that calls a function and asserts toBeTruthy() on the result counts as 100% coverage and tests absolutely nothing.
The platform blind spot. Web test suites run on Chromium in CI. Mobile test suites might not run at all, or run only on one device in an emulator. Real users are on Safari on iOS 15, on Android 11 with a flaky 3G connection, on a 4-year-old Samsung with 2GB RAM. The gap between “it works on my machine” and “it works for every user” is where customer-facing bugs live.
Architecture Overview
The five-layer architecture that actually works
After years of building and refining test architectures across teams of different sizes, here is the structure I now deploy on every new project. The critical design principle is that each layer has a single, clear responsibility that no other layer can fulfil.

The layers run bottom-up in CI. If Layer 1 fails, there is no point running Layer 3. If Layer 2 fails, the E2E suite would be testing a broken API. This sequential dependency keeps the pipeline fast — failures surface at the cheapest layer first.

Layer 1 Deep Dive
Layer 1: unit and contract testing with Vitest and Zod
What this layer is — and what it is not
Unit testing is the most misunderstood layer in the stack. Teams write unit tests for everything — API handlers, database calls, React components — and mock all the dependencies away. The result is a test suite that is fast, green, and tells you absolutely nothing meaningful about your system in production.
The correct rule for this layer is strict: only test pure functions. A pure function takes inputs and returns an output with zero side effects — no database calls, no network requests, no file system access, no mutation of external state. These functions are deterministic, fast to test, and represent the most critical logic in your codebase: your actual business rules.
The second job of this layer is contract testing. As services grow and teams work independently, the shape of data shared between services drifts. One team changes a field name. Another change a type. The consumers of that data break — silently, in production, at 2 am. Zod schemas used as shared contracts prevent this entire class of bugs before it ever reaches integration testing.
The contract drift bug — the scenario Zod was made for
Here is a real scenario that plays out constantly on distributed teams. The backend team changes the createdAt field on the Order API from a Unix timestamp (a number) to an ISO 8601 string. They update their unit tests. Those tests pass. They deploy. The frontend team’s code that was doing new Date(order.createdAt * 1000) now silently produces an Invalid Date. Dates display as “NaN” in the UI. No test caught it because the contract between the two services was never machine-enforced.
With a shared Zod schema, this scenario is impossible. Both services import from the same schema file. The moment the backend changes the type, the contract test fails before the change is even merged.
src/schemas/order.schema.js
/**
* SHARED SCHEMA — import this in BOTH the backend service that produces
* order data AND in any frontend/consumer service that reads it.
* This file is the contract. Changing the shape here is a breaking change
* and both sides of the contract will fail tests until they are aligned.
*/
import { z } from 'zod';
export const LineItemSchema = z.object({
productId: z.string().uuid(),
sku: z.string().min(1),
name: z.string().min(1),
quantity: z.number().int().positive(),
unitPrice: z.number().positive(),
discount: z.number().min(0).max(1).default(0), // 0.0–1.0 as a decimal fraction
});
export const AddressSchema = z.object({
line1: z.string().min(1),
line2: z.string().optional(),
city: z.string().min(1),
postalCode: z.string().min(1),
country: z.string().length(2), // ISO 3166-1 alpha-2, e.g. "US"
});
export const OrderSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
status: z.enum([
'pending', 'confirmed', 'processing',
'shipped', 'delivered', 'cancelled', 'refunded',
]),
items: z.array(LineItemSchema).min(1),
shippingAddress: AddressSchema,
subtotal: z.number().positive(),
taxAmount: z.number().min(0),
shippingCost: z.number().min(0),
total: z.number().positive(),
currency: z.string().length(3), // ISO 4217, e.g. "USD"
createdAt: z.string().datetime(), // ISO 8601 string — NOT a unix timestamp
updatedAt: z.string().datetime(),
});
export const parseOrder = (data) => OrderSchema.parse(data);
export const safeParseOrder = (data) => OrderSchema.safeParse(data);
src/utils/pricing.js
/**
* All pricing logic lives here as pure functions.
* No framework imports. No database access. No HTTP calls.
* Takes data in, returns data out. Fully deterministic.
* This is exactly the kind of code that belongs in Layer 1.
*/
export function calculateLineItemTotal(item) {
const grossPrice = item.unitPrice * item.quantity;
const discountValue = grossPrice * (item.discount ?? 0);
// Round to 2 decimal places to avoid floating-point drift (e.g. 0.1 + 0.2 = 0.30000000000000004)
return Math.round((grossPrice - discountValue) * 100) / 100;
}
export function calculateSubtotal(items) {
return items.reduce((sum, item) => sum + calculateLineItemTotal(item), 0);
}
export function calculateTax(subtotal, taxRatePercent) {
if (taxRatePercent < 0 || taxRatePercent > 100)
throw new Error(`Invalid tax rate: ${taxRatePercent}`);
return Math.round((subtotal * taxRatePercent / 100) * 100) / 100;
}
export function applyPromoCode(subtotal, promoCode) {
const PROMO_RULES = {
SAVE10: { type: 'percent', value: 10 },
FLAT20: { type: 'fixed', value: 20 },
NEWUSER: { type: 'percent', value: 15 },
};
const rule = PROMO_RULES[promoCode?.toUpperCase()];
if (!rule) return subtotal;
if (rule.type === 'percent') return subtotal * ((100 - rule.value) / 100);
return Math.max(0, subtotal - rule.value);
}
export function canPlaceOrder(user, cartItems) {
if (!user.isVerified) return { allowed: false, reason: 'email_not_verified' };
if (user.isBanned) return { allowed: false, reason: 'account_banned' };
if (cartItems.length === 0) return { allowed: false, reason: 'empty_cart' };
if (cartItems.some(i => !i.inStock))
return { allowed: false, reason: 'out_of_stock_items' };
return { allowed: true };
}
//src/utils/pricing.test.js
import { describe, it, expect } from 'vitest';
import {
calculateLineItemTotal, calculateSubtotal,
calculateTax, applyPromoCode, canPlaceOrder,
} from './pricing.js';
import { safeParseOrder } from '../schemas/order.schema.js';
describe('calculateLineItemTotal', () => {
it('calculates a line item with no discount', () => {
expect(calculateLineItemTotal({ unitPrice: 20, quantity: 3, discount: 0 })).toBe(60);
});
it('applies a 20% discount correctly', () => {
expect(calculateLineItemTotal({ unitPrice: 50, quantity: 2, discount: 0.2 })).toBe(80);
});
it('returns a value with maximum 2 decimal places', () => {
const result = calculateLineItemTotal({ unitPrice: 9.99, quantity: 3, discount: 0.1 });
expect(result.toString().split('.')[1]?.length ?? 0).toBeLessThanOrEqual(2);
});
});
describe('applyPromoCode', () => {
it('applies SAVE10 percentage discount', () => {
expect(applyPromoCode(100, 'SAVE10')).toBe(90);
});
it('applies FLAT20 fixed discount', () => {
expect(applyPromoCode(50, 'FLAT20')).toBe(30);
});
it('does not go below zero for large fixed discounts', () => {
expect(applyPromoCode(10, 'FLAT20')).toBe(0);
});
it('is case-insensitive', () => {
expect(applyPromoCode(100, 'save10')).toBe(90);
});
it('returns original subtotal for unknown promo codes', () => {
expect(applyPromoCode(100, 'FAKECODE')).toBe(100);
});
});
describe('canPlaceOrder — business rule enforcement', () => {
const validUser = { isVerified: true, isBanned: false };
const validItems = [{ inStock: true }];
it('allows a verified user with in-stock items', () => {
expect(canPlaceOrder(validUser, validItems)).toEqual({ allowed: true });
});
it('blocks an unverified user', () => {
expect(canPlaceOrder({ ...validUser, isVerified: false }, validItems))
.toEqual({ allowed: false, reason: 'email_not_verified' });
});
it('blocks a banned account', () => {
expect(canPlaceOrder({ ...validUser, isBanned: true }, validItems))
.toEqual({ allowed: false, reason: 'account_banned' });
});
it('blocks an empty cart', () => {
expect(canPlaceOrder(validUser, []))
.toEqual({ allowed: false, reason: 'empty_cart' });
});
it('blocks a cart containing out-of-stock items', () => {
expect(canPlaceOrder(validUser, [{ inStock: true }, { inStock: false }]))
.toEqual({ allowed: false, reason: 'out_of_stock_items' });
});
});
// ─────────────────────────────────────────────────────────────────────
// CONTRACT TESTS — verify real API response shapes against shared schema
// These run against response fixtures captured from the actual API.
// ─────────────────────────────────────────────────────────────────────
describe('OrderSchema — contract validation', () => {
it('accepts a well-formed order object', () => {
const result = safeParseOrder({
id: '123e4567-e89b-12d3-a456-426614174000',
userId: '987fcdeb-51a2-43d7-9012-345678901234',
status: 'confirmed',
items: [{
productId: 'aaa-bbb-ccc', sku: 'SKU-001', name: 'Widget',
quantity: 2, unitPrice: 19.99, discount: 0,
}],
shippingAddress: { line1: '1 Main St', city: 'Austin', postalCode: '78701', country: 'US' },
subtotal: 39.98, taxAmount: 3.30, shippingCost: 5.99, total: 49.27',
currency: 'USD',
createdAt: '2026-04-19T10:00:00.000Z',
updatedAt: '2026-04-19T10:01:00.000Z',
});
expect(result.success).toBe(true);
});
it('rejects a unix timestamp for createdAt — catches the shape-drift bug', () => {
// This test would have caught the "timestamp vs ISO string" incident described above
const result = safeParseOrder({ createdAt: 1713520800 });
expect(result.success).toBe(false);
expect(result.error.issues[0].path).toContain('createdAt');
});
it('rejects a status value not in the enum', () => {
const result = safeParseOrder({ status: 'dispatched' }); // not a valid status
expect(result.success).toBe(false);
});
it('rejects an order with an empty items array', () => {
const result = safeParseOrder({ items: [] });
expect(result.success).toBe(false);
});
}); it('blocks an empty cart', () => {
expect(canPlaceOrder(validUser, []))
.toEqual({ allowed: false, reason: 'empty_cart' });
});
//vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
reporters: ['verbose', 'json'],
outputFile: { json: './coverage-reports/vitest-results.json' },
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'html'],
reportsDirectory: './coverage-reports/vitest-coverage',
include: ['src/**/*.js'],
exclude: ['src/**/*.test.js', 'src/db.js', 'src/app.js'],
// These thresholds are hard limits — Vitest exits non-zero if missed.
// perFile prevents one well-tested file from masking poorly-tested ones.
thresholds: {
lines: 90,
branches: 85,
functions: 90,
statements: 90,
perFile: { lines: 80, branches: 75 },
},
},
},
}); // These thresholds are hard limits — Vitest exits non-zero if missed.
// perFile prevents one well-tested file from masking poorly-tested ones.
thresholds: {
lines: 90,
branches: 85,
functions: 90,
statements: 90,
perFile: { lines: 80, branches: 75 },
},
},
},
});

Layer 2 Deep Dive
Layer 2: API integration testing with Supertest
The unique problem this layer solves
Unit tests mock everything. This means a unit test can verify that your calculateOrderTotal function works correctly while your actual route handler silently miscalculates the response because it calls a different function. The wiring between your route, your middleware, your controller, and your data layer is completely invisible to unit tests.
Integration tests spin up your real Express or Fastify application against a real test database and fire real HTTP requests at it. No mocking of the HTTP layer. No mocking of the database connection. The test exercises the entire request-response cycle exactly as production would — minus the external third-party services, which you stub at the network boundary.
Here is a concrete list of the bugs this layer catches that Layer 1 cannot:
- Route is registered to the wrong path — GET /orders/:id vs GET /order/:id
- Auth middleware is missing from a route that should require authentication
- Response body shape has drifted from the shared schema after a refactor
- A user can access another user’s resource — broken row-level security
- Validation middleware returns a 500 instead of a 400 for invalid input
- A database query silently fails and returns an empty response instead of a 404
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { db } from '../../src/db.js';
/**
* Creates a test user and returns { userId, token }.
* The token is a real JWT signed with the test secret, so auth
* middleware will accept it exactly as it would in production.
* Never hard-code test tokens — always generate real ones.
*/
export async function seedTestUser(overrides = {}) {
const defaults = {
email: `test-${Date.now()}@qa.local`,
password: await bcrypt.hash('TestPass123!', 10),
isVerified: true,
isBanned: false,
role: 'customer',
};
const [user] = await db('users').insert({ ...defaults, ...overrides }).returning('*');
const token = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET_TEST,
{ expiresIn: '1h' },
);
return { ...user, token };
}
/**
* Creates a realistic order with line items for a given user.
* Uses a seeded product so foreign key constraints are respected.
*/
export async function seedTestOrder(userId, overrides = {}) {
const [product] = await db('products')
.insert({ name: 'Test Widget', sku: 'TST-001', price: 19.99, stock: 100 })
.returning('*');
const [order] = await db('orders').insert({
userId, status: 'pending', subtotal: 19.99,
taxAmount: 1.80, shippingCost: 5.99, total: 27.78, currency: 'USD',
...overrides,
}).returning('*');
await db('order_items').insert({
orderId: order.id, productId: product.id, quantity: 1, unitPrice: 19.99, discount: 0,
});
return order;
}
/** Wipes all test data. Run in afterAll to keep the test DB clean. */
export async function clearTestData() {
await db.raw('TRUNCATE order_items, orders, products, users CASCADE');
}/**
* Creates a test user and returns { userId, token }.
* The token is a real JWT signed with the test secret, so auth
* middleware will accept it exactly as it would in production.
* Never hard-code test tokens — always generate real ones.
*/
export async function seedTestUser(overrides = {}) {
const defaults = {
email: `test-${Date.now()}@qa.local`,
password: await bcrypt.hash('TestPass123!', 10),
isVerified: true,
isBanned: false,
role: 'customer',
};
const [user] = await db('users').insert({ ...defaults, ...overrides }).returning('*');
const token = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET_TEST,
{ expiresIn: '1h' },
);
return { ...user, token };
}
//tests/integration/orders.test.js
import request from 'supertest';
import { app } from '../../src/app.js';
import { db } from '../../src/db.js';
import { safeParseOrder } from '../../src/schemas/order.schema.js';
import { seedTestUser, seedTestOrder, clearTestData } from '../helpers/seed.js';
let ownerUser, otherUser, testOrderId;
beforeAll(async () => {
ownerUser = await seedTestUser();
otherUser = await seedTestUser();
testOrderId = (await seedTestOrder(ownerUser.id)).id;
});
afterAll(async () => {
await clearTestData();
await db.destroy();
});
// ─────────────────────────────────────────────────────────────
// GET /api/v1/orders/:id
// ─────────────────────────────────────────────────────────────
describe('GET /api/v1/orders/:id', () => {
it('returns 401 when no auth token is provided', async () => {
const res = await request(app).get(`/api/v1/orders/${testOrderId}`);
expect(res.status).toBe(401);
expect(res.body).toHaveProperty('error');
});
it('returns 401 for a malformed token', async () => {
const res = await request(app)
.get(`/api/v1/orders/${testOrderId}`)
.set('Authorization', 'Bearer not.a.real.jwt');
expect(res.status).toBe(401);
});
it('returns a valid order that passes contract schema validation', async () => {
const res = await request(app)
.get(`/api/v1/orders/${testOrderId}`)
.set('Authorization', `Bearer ${ownerUser.token}`);
expect(res.status).toBe(200);
// This is the critical contract check — if the response shape drifts, this fails
const parsed = safeParseOrder(res.body);
expect(
parsed.success,
`Schema validation failed: ${JSON.stringify(parsed.error?.issues)}`,
).toBe(true);
});
it('returns 403 when a different user requests this order — tests row-level security', async () => {
const res = await request(app)
.get(`/api/v1/orders/${testOrderId}`)
.set('Authorization', `Bearer ${otherUser.token}`);
expect(res.status).toBe(403);
});
it('returns 404 for a non-existent order ID', async () => {
const res = await request(app)
.get('/api/v1/orders/00000000-0000-0000-0000-000000000000')
.set('Authorization', `Bearer ${ownerUser.token}`);
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────
// POST /api/v1/orders — Create a new order
// ─────────────────────────────────────────────────────────────
describe('POST /api/v1/orders', () => {
const validPayload = {
items: [{ productId: 'some-product-id', quantity: 2 }],
shippingAddress: { line1: '1 Test Lane', city: 'Austin', postalCode: '78701', country: 'US' },
};
it('creates an order and returns 201 with a contract-valid response', async () => {
const res = await request(app)
.post('/api/v1/orders')
.set('Authorization', `Bearer ${ownerUser.token}`)
.send(validPayload);
expect(res.status).toBe(201);
expect(safeParseOrder(res.body).success).toBe(true);
});
it('returns 400 with field-level validation errors for an empty items array', async () => {
const res = await request(app)
.post('/api/v1/orders')
.set('Authorization', `Bearer ${ownerUser.token}`)
.send({ ...validPayload, items: [] });
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('validationErrors');
expect(res.body.validationErrors).toContainEqual(
expect.objectContaining({ field: 'items' }),
);
});
it('returns 422 with OUT_OF_STOCK code when a product has no stock', async () => {
const [outOfStock] = await db('products')
.insert({ name: 'Sold Out Item', sku: 'OOS-001', price: 9.99, stock: 0 })
.returning('*');
const res = await request(app)
.post('/api/v1/orders')
.set('Authorization', `Bearer ${ownerUser.token}`)
.send({ ...validPayload, items: [{ productId: outOfStock.id, quantity: 1 }] });
expect(res.status).toBe(422);
expect(res.body.code).toBe('OUT_OF_STOCK');
});
});
Common mistake
Never mock the database in integration tests. If you mock the DB, you are testing your mocks — not your integration. Use a real Postgres instance running in Docker via GitHub Actions services. The extra setup time is worth it every single time.

Layer 3: web E2E testing with Playwright
What E2E tests are for — and what they are not for
E2E tests verify that a user can complete a meaningful journey through your application from the browser’s perspective. They simulate a real user — clicking, typing, navigating — and assert on what the user actually sees and experiences.
This is fundamentally different from integration tests. Integration tests call your API directly and inspect the JSON response. E2E tests open a real browser, render your React app, and click the “Place Order” button. They catch UI state bugs, navigation failures, loading state regressions, and incorrect error feedback that integration tests cannot see.
Playwright runs your tests in parallel across Chromium, Firefox, and WebKit from a single config file. It produces video recordings and full traces of failures — meaning your team can replay exactly what happened without manually reproducing it.
The Page Object Model — non-negotiable at scale
Without the Page Object Model (POM), E2E suites become unmaintainable within weeks. When your checkout screen adds a new field, you update a single selector in a single file rather than hunting through 50 test files. POM is the architectural pattern that makes E2E tests survive real product velocity.
/**
* Base class all page objects extend.
* Contains common actions and utilities present on every page.
* Centralising these prevents duplication across page objects.
*/
export class BasePage {
constructor(page) {
this.page = page;
// Global UI elements present on every authenticated page
this.navCart = page.locator('[data-testid="nav-cart-icon"]');
this.navAccount = page.locator('[data-testid="nav-account-menu"]');
this.toast = page.locator('[data-testid="toast-notification"]');
this.loadingSpinner = page.locator('[data-testid="global-loading-spinner"]');
}
// Wait for the page to be fully interactive before proceeding
async waitForPageReady() {
await this.loadingSpinner.waitFor({ state: 'hidden', timeout: 10000 });
await this.page.waitForLoadState('networkidle');
}
async getToastMessage() {
await this.toast.waitFor({ state: 'visible', timeout: 5000 });
return await this.toast.innerText();
}
async getCartCount() {
const text = await this.page
.locator('[data-testid="cart-item-count"]')
.innerText().catch(() => '0');
return parseInt(text, 10);
}
} // Global UI elements present on every authenticated page
this.navCart = page.locator('[data-testid="nav-cart-icon"]');
this.navAccount = page.locator('[data-testid="nav-account-menu"]');
this.toast = page.locator('[data-testid="toast-notification"]');
this.loadingSpinner = page.locator('[data-testid="global-loading-spinner"]');
}
//tests/e2e/pages/CheckoutPage.js
import { BasePage } from './BasePage.js';
export class CheckoutPage extends BasePage {
constructor(page) {
super(page);
// Payment details
this.cardNumberInput = page.locator('[data-testid="card-number-input"]');
this.expiryInput = page.locator('[data-testid="card-expiry-input"]');
this.cvvInput = page.locator('[data-testid="card-cvv-input"]');
this.nameOnCard = page.locator('[data-testid="card-holder-name"]');
// Shipping address
this.shippingLine1 = page.locator('[data-testid="shipping-address-line1"]');
this.shippingCity = page.locator('[data-testid="shipping-city"]');
this.shippingPostal = page.locator('[data-testid="shipping-postal"]');
this.shippingCountry = page.locator('[data-testid="shipping-country-select"]');
// Promo code
this.promoCodeInput = page.locator('[data-testid="promo-code-input"]');
this.applyPromoBtn = page.locator('[data-testid="apply-promo-button"]');
this.promoFeedback = page.locator('[data-testid="promo-feedback"]');
// Order summary
this.totalDisplay = page.locator('[data-testid="summary-total"]');
this.discountDisplay = page.locator('[data-testid="summary-discount"]');
// Actions and outcomes
this.placeOrderBtn = page.locator('[data-testid="place-order-button"]');
this.successMessage = page.locator('[data-testid="order-success-message"]');
this.paymentError = page.locator('[data-testid="payment-error-banner"]');
this.fieldErrors = page.locator('[data-testid="field-error"]');
}
async goto() {
await this.page.goto('/checkout');
await this.waitForPageReady();
await expect(this.page).toHaveURL(/\/checkout/);
}
async fillShippingAddress({ line1, city, postalCode, country = 'US' }) {
await this.shippingLine1.fill(line1);
await this.shippingCity.fill(city);
await this.shippingPostal.fill(postalCode);
await this.shippingCountry.selectOption(country);
}
async fillPaymentDetails({ cardNumber, expiry, cvv, nameOnCard = 'Test User' }) {
await this.cardNumberInput.fill(cardNumber);
await this.expiryInput.fill(expiry);
await this.cvvInput.fill(cvv);
await this.nameOnCard.fill(nameOnCard);
}
async applyPromoCode(code) {
await this.promoCodeInput.fill(code);
await this.applyPromoBtn.click();
await this.promoFeedback.waitFor({ state: 'visible', timeout: 3000 });
return await this.promoFeedback.innerText();
}
async getFieldErrorCount() { return await this.fieldErrors.count(); }
async getTotalAmount() { return await this.totalDisplay.innerText(); }
async placeOrder() { await this.placeOrderBtn.click(); }
}
//tests/e2e/helpers/auth.js
/**
* Logs in by calling the API directly and injecting the token into
* localStorage. This bypasses the login UI — which we test separately.
* Doing this saves 5–10 seconds per test suite. At 50 tests, that is
* 4–8 minutes per pipeline run saved for free.
*/
import { expect } from '@playwright/test';
export async function loginAs(page, { role = 'customer' } = {}) {
const credentials = {
customer: { email: 'test-customer@qa.local', password: 'TestPass123!' },
admin: { email: 'test-admin@qa.local', password: 'AdminPass123!' },
};
const res = await page.request.post('/api/v1/auth/login', {
data: credentials[role],
});
expect(res.status(), 'Login failed during test setup').toBe(200);
const { token } = await res.json();
// Navigate to the origin to gain localStorage access, then inject
await page.goto('/');
await page.evaluate((t) => localStorage.setItem('auth_token', t), token);
}
//tests/e2e/checkout.spec.js
import { test, expect } from '@playwright/test';
import { CheckoutPage } from './pages/CheckoutPage.js';
import { loginAs } from './helpers/auth.js';
const VALID_CARD = { cardNumber: '4242 4242 4242 4242', expiry: '12/28', cvv: '123' };
const DECLINED_CARD = { cardNumber: '4000 0000 0000 0002', expiry: '12/28', cvv: '123' };
const VALID_ADDRESS = { line1: '1 Playwright Ave', city: 'Austin', postalCode: '78701' };
test.describe('Checkout — full purchase journey', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, { role: 'customer' });
await page.request.post('/api/v1/cart/seed-for-test'); // seed cart via API
});
test('happy path: completes checkout and shows confirmation', async ({ page }) => {
const checkout = new CheckoutPage(page);
await checkout.goto();
await checkout.fillShippingAddress(VALID_ADDRESS);
await checkout.fillPaymentDetails(VALID_CARD);
await checkout.placeOrder();
await expect(checkout.successMessage).toBeVisible();
await expect(page).toHaveURL(/\/order-confirmation\/.+/);
await expect(page).toHaveTitle(/order confirmed/i);
});
test('declined card: shows error and keeps user on checkout for retry', async ({ page }) => {
const checkout = new CheckoutPage(page);
await checkout.goto();
await checkout.fillShippingAddress(VALID_ADDRESS);
await checkout.fillPaymentDetails(DECLINED_CARD);
await checkout.placeOrder();
await expect(checkout.paymentError).toBeVisible();
await expect(checkout.paymentError).toContainText(/card was declined/i);
await expect(page).toHaveURL('/checkout'); // stayed on the page
await expect(checkout.placeOrderBtn).toBeEnabled(); // retry is possible
});
test('empty form: shows field-level errors without crashing', async ({ page }) => {
const checkout = new CheckoutPage(page);
await checkout.goto();
await checkout.placeOrder(); // no fields filled
const errorCount = await checkout.getFieldErrorCount();
expect(errorCount).toBeGreaterThan(0);
await expect(page).toHaveURL('/checkout');
});
test('promo code SAVE10 reduces total by 10%', async ({ page }) => {
const checkout = new CheckoutPage(page);
await checkout.goto();
const originalTotal = await checkout.getTotalAmount();
const feedback = await checkout.applyPromoCode('SAVE10');
expect(feedback).toContain('10% discount applied');
expect(await checkout.getTotalAmount()).not.toBe(originalTotal);
await expect(checkout.discountDisplay).toBeVisible();
});
});
//playwright.config.js
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0, // retries in CI only — locally, failures are real
workers: process.env.CI ? 4 : undefined,
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'coverage-reports/playwright-results.json' }],
['github'], // annotates PRs with inline failure details
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10_000,
navigationTimeout: 30_000,
},
projects: [
// Desktop browsers
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
// Mobile web viewports
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 14 Pro'] } },
// Tablet
{ name: 'tablet', use: { ...devices['iPad Pro 11'] } },
],
});
QA Manager rule
The data-testid attribute is a contract between developers and the test suite. Establish this as a team standard from day one: every interactive element and every meaningful outcome element gets a data-testid. Without this discipline, your E2E tests rely on CSS classes and text content that break with every design update.

Layer 4: native mobile testing with Detox
Why mobile is a completely different problem
Running Playwright in a mobile viewport is not the same as testing a native mobile app. A viewport test confirms that your responsive CSS works. It does not test native gesture recognisers, iOS keyboard interactions, Android back button behaviour, push notification handling, deep links, background-to-foreground app state transitions, or device permission prompts.
If your product includes a React Native application, Detox is the testing framework built specifically for it. It attaches to a running iOS simulator or Android emulator, drives the UI through real native interactions, and gives you the same level of confidence in your mobile app that Playwright gives you for the web.
Here are the bugs that only Detox catches:
- The iOS keyboard obscures the payment form, and the user cannot scroll to the submit button
- The Android back button dismisses the checkout modal instead of navigating back one step
- The app crashes when returning from the background while a payment is in progress
- A deep link to a product page loads the wrong item due to an ID encoding edge case
- A swipe-to-dismiss gesture on a form clears user input without a confirmation dialogue
- The biometric auth prompt does not appear correctly when Face ID is enabled on the device
//.detoxrc.js
/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: { '$0': 'jest', config: 'e2e/jest.config.js' },
jest: { setupTimeout: 120000 }, // 2 min — simulators are slow to boot
},
apps: {
'ios.release': {
type: 'ios.app',
binaryPath: 'ios/build/Build/Products/Release-iphonesimulator/MyApp.app',
build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release -sdk iphonesimulator -derivedDataPath ios/build',
},
'android.release': {
type: 'android.apk',
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
build: 'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release',
},
},
devices: {
'iphone-15': { type: 'ios.simulator', device: { type: 'iPhone 15', os: 'iOS 17.2' } },
'pixel-8': { type: 'android.emulator', device: { avdName: 'Pixel_8_API_34' } },
},
configurations: {
'ios.sim.release': { device: 'iphone-15', app: 'ios.release' },
'android.emu.release': { device: 'pixel-8', app: 'android.release' },
},
};
//src/screens/CheckoutScreen.jsx
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, Text, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
/**
* RULE: Every interactive element and every outcome element MUST have a testID.
* Use the same kebab-case naming convention as your web data-testid attributes.
* Consistency across platforms makes cross-platform test maintenance easier.
*/
export const CheckoutScreen = ({ onSubmit }) => {
const [cardNumber, setCardNumber] = useState('');
const [expiry, setExpiry] = useState('');
const [cvv, setCvv] = useState('');
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
setSubmitting(true); setError(null);
try { await onSubmit({ cardNumber, expiry, cvv }); }
catch (e) { setError(e.message); setSubmitting(false); }
};
return (
<KeyboardAvoidingView
testID="checkout-screen"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView>
<TextInput
testID="card-number-input"
value={cardNumber} onChangeText={setCardNumber}
placeholder="Card number" keyboardType="number-pad" maxLength={19}
/>
<TextInput
testID="card-expiry-input"
value={expiry} onChangeText={setExpiry}
placeholder="MM/YY" keyboardType="number-pad" maxLength={5}
/>
<TextInput
testID="card-cvv-input"
value={cvv} onChangeText={setCvv}
placeholder="CVV" keyboardType="number-pad" secureTextEntry maxLength={4}
/>
{error && (
<View testID="payment-error-banner">
<Text testID="payment-error-text">{error}</Text>
</View>
)}
<TouchableOpacity
testID="place-order-button"
onPress={handleSubmit} disabled={submitting}
accessibilityRole="button"
>
<Text>{submitting ? 'Processing...' : 'Place Order'}</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
};
//e2e/checkout.e2e.js
describe('Checkout — native mobile (iOS & Android)', () => {
beforeAll(async () => {
// Launch with a pre-seeded auth token — no UI login required
await device.launchApp({
newInstance: true,
userNotification: { trigger: 'setAuthToken', payload: { token: process.env.E2E_USER_TOKEN } },
});
});
beforeEach(async () => {
await device.reloadReactNative();
await element(by.id('nav-cart-tab')).tap();
await waitFor(element(by.id('proceed-to-checkout-btn'))).toBeVisible().withTimeout(5000);
await element(by.id('proceed-to-checkout-btn')).tap();
await waitFor(element(by.id('checkout-screen'))).toBeVisible().withTimeout(5000);
});
it('completes a successful payment on mobile', async () => {
await element(by.id('card-number-input')).typeText('4242424242424242');
await element(by.id('card-expiry-input')).typeText('1228');
await element(by.id('card-cvv-input')).typeText('123');
await element(by.id('checkout-screen')).tapReturnKey(); // dismiss keyboard
await element(by.id('place-order-button')).tap();
await waitFor(element(by.id('order-success-message'))).toBeVisible().withTimeout(10000);
});
it('shows error for a declined card without crashing the app', async () => {
await element(by.id('card-number-input')).typeText('4000000000000002');
await element(by.id('card-expiry-input')).typeText('1228');
await element(by.id('card-cvv-input')).typeText('123');
await element(by.id('place-order-button')).tap();
await waitFor(element(by.id('payment-error-banner'))).toBeVisible().withTimeout(8000);
// App must NOT have crashed — the checkout form is still on screen
await expect(element(by.id('place-order-button'))).toBeVisible();
});
it('preserves form state when app is backgrounded mid-checkout', async () => {
await element(by.id('card-number-input')).typeText('4242424242424242');
await device.sendToHome(); // simulate a phone call
await device.launchApp({ newInstance: false }); // re-open the app
await expect(element(by.id('card-number-input'))).toHaveText('4242 4242 4242 4242');
});
it('deep link correctly pre-populates the checkout with the right product', async () => {
await device.openURL({ url: 'myapp://checkout?productId=abc123&qty=2' });
await waitFor(element(by.id('checkout-screen'))).toBeVisible().withTimeout(5000);
await expect(element(by.id('summary-item-qty'))).toHaveText('2');
});
});
CI note
Run iOS tests on macOS-14 GitHub Actions runners, which include Xcode. Run Android tests on ubuntu-latest with the Android emulator action. Both platforms run in parallel in the pipeline — they test different targets and have zero dependencies on each other.

Layer 5: coverage orchestration, gates, and the full CI pipeline
Why is this layer the glue that makes the others matter
You can have four excellent test layers and still have zero confidence if no one is looking at the results holistically. This layer aggregates outputs from all four layers, enforces minimum thresholds, and produces a unified report that answers one question: Did every layer pass, at the required standard, today?
The most important element of this layer is the deploy gate. If any single layer fails its threshold, the CI pipeline exits non-zero, and the pull request is blocked from merging. This is what makes quality non-negotiable rather than advisory. It removes the human decision from the equation.
//scripts/coverage-report.js
#!/usr/bin/env node
/**
* Coverage Aggregator — runs after all test layers complete in CI.
* Reads JSON reports from each layer, checks thresholds, and
* exits with code 1 if any threshold is missed. A non-zero exit
* blocks the GitHub Actions pipeline and prevents merge to main.
*/
import fs from 'fs';
import path from 'path';
const REPORTS = './coverage-reports';
const THRESHOLDS = {
unit: { lines: 90, branches: 85, functions: 90 },
webE2E: { passingJourneys: 100, browsersRan: 3 },
mobile: { passingTests: 100 },
};
function readReport(filename) {
const fp = path.join(REPORTS, filename);
if (!fs.existsSync(fp)) { console.log(` [SKIP] ${filename} not found`); return null; }
return JSON.parse(fs.readFileSync(fp, 'utf-8'));
}
function check(label, actual, threshold, unit = '%') {
const ok = actual >= threshold;
console.log(` ${ok ? '✓' : '✗ FAIL'} ${label}: ${actual.toFixed(1)}${unit} (min: ${threshold}${unit})`);
return ok;
}
async function main() {
console.log('\n╔══════════════════════════════════════════╗');
console.log('║ QA COVERAGE GATE REPORT ║');
console.log('╚══════════════════════════════════════════╝\n');
let allPassed = true;
const summary = [];
// ── Layer 1: Vitest unit coverage ──
const unitCov = readReport('vitest-coverage/coverage-summary.json');
if (unitCov) {
console.log('Layer 1 — Unit & Contract Tests');
const t = THRESHOLDS.unit;
const ok = [
check('Line coverage', unitCov.total.lines.pct, t.lines),
check('Branch coverage', unitCov.total.branches.pct, t.branches),
check('Function coverage', unitCov.total.functions.pct, t.functions),
].every(Boolean);
allPassed = allPassed && ok;
summary.push({ layer: 'Unit & Contracts', passed: ok });
console.log();
}
// ── Layer 2: Integration test pass rate ──
const intRes = readReport('vitest-results.json');
if (intRes) {
console.log('Layer 2 — API Integration Tests');
const intTests = intRes.testResults.filter(t => t.testFilePath.includes('integration'));
const total = intTests.reduce((s, t) => s + t.numPassingTests + t.numFailingTests, 0);
const passing = intTests.reduce((s, t) => s + t.numPassingTests, 0);
const ok = check('Integration tests passing', (passing / total) * 100, 100);
console.log(` Total integration tests run: ${total}`);
allPassed = allPassed && ok;
summary.push({ layer: 'API Integration', passed: ok });
console.log();
}
// ── Layer 3: Playwright web E2E ──
const pwRes = readReport('playwright-results.json');
if (pwRes) {
console.log('Layer 3 — Web E2E Tests (Playwright)');
const total = pwRes.stats.expected + pwRes.stats.unexpected;
const passPct = (pwRes.stats.expected / total) * 100;
const browsers = [...new Set(pwRes.suites?.map(s => s.project))].filter(Boolean);
const ok = [
check('E2E journeys passing', passPct, THRESHOLDS.webE2E.passingJourneys),
check('Browser engines covered', browsers.length, THRESHOLDS.webE2E.browsersRan, ''),
].every(Boolean);
console.log(` Browsers: ${browsers.join(', ') || 'see report'}`);
allPassed = allPassed && ok;
summary.push({ layer: 'Web E2E', passed: ok });
console.log();
}
// ── Layer 4: Detox mobile ──
const detoxRes = readReport('detox-results.json');
if (detoxRes) {
console.log('Layer 4 — Mobile E2E Tests (Detox)');
const passPct = (detoxRes.numPassedTests / detoxRes.numTotalTests) * 100;
const ok = check('Mobile tests passing', passPct, THRESHOLDS.mobile.passingTests);
console.log(` Total: ${detoxRes.numTotalTests} | Failed: ${detoxRes.numFailedTests}`);
allPassed = allPassed && ok;
summary.push({ layer: 'Native Mobile', passed: ok });
console.log();
}
// ── Final result ──
console.log('═══════════════════════════════════════════');
console.log('Summary');
summary.forEach(r => console.log(` ${r.passed ? '✓' : '✗'} ${r.layer}`));
console.log();
if (allPassed) {
console.log('All thresholds met. Safe to merge and deploy.\n');
process.exit(0);
} else {
console.log('BLOCKED: Coverage thresholds not met. Merge is blocked.\n');
process.exit(1); // non-zero exit halts the GitHub Actions pipeline
}
}
main();
//.github/workflows/qa-pipeline.yml
name: Full QA Pipeline
on:
pull_request:
branches: [main, staging]
push:
branches: [main]
jobs:
# ── Layer 1: Unit & contracts ─────────────────────────────────────
unit-and-contracts:
name: Layer 1 — Unit & Contract Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Run Vitest with coverage
run: npx vitest run --coverage
env: { NODE_ENV: test }
- uses: actions/upload-artifact@v4
if: always()
with: { name: unit-coverage, path: coverage-reports/vitest-coverage/ }
# ── Layer 2: API integration (needs a real Postgres DB) ────────────
api-integration:
name: Layer 2 — API Integration Tests
needs: unit-and-contracts
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
POSTGRES_USER: testuser
options: --health-cmd pg_isready --health-interval 5s --health-retries 5
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Run DB migrations
run: npm run db:migrate
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
- name: Run integration tests
run: npx vitest run tests/integration --reporter=json --outputFile=coverage-reports/vitest-results.json
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
JWT_SECRET_TEST: test-secret-not-used-in-production
NODE_ENV: test
- uses: actions/upload-artifact@v4
if: always()
with: { name: integration-results, path: coverage-reports/ }
# ── Layer 3: Web E2E across all browsers ──────────────────────────
web-e2e:
name: Layer 3 — Web E2E (Playwright)
needs: api-integration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci && npx playwright install --with-deps
- name: Run Playwright across all browsers
run: npx playwright test
env:
BASE_URL: ${{ secrets.STAGING_URL }}
CI: 'true'
- uses: actions/upload-artifact@v4
if: always()
with: { name: playwright-coverage, path: coverage-reports/playwright-results.json }
# ── Layer 4a: iOS (runs in parallel with Layer 3) ─────────────────
mobile-ios:
name: Layer 4a — Mobile E2E (iOS)
needs: api-integration
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci && npx pod-install
- run: npx detox build --configuration ios.sim.release
- name: Run Detox iOS tests
run: npx detox test --configuration ios.sim.release --record-logs all --record-videos all
env:
E2E_USER_TOKEN: ${{ secrets.E2E_STANDARD_USER_TOKEN }}
- uses: actions/upload-artifact@v4
if: always()
with: { name: detox-ios-results, path: artifacts/ }
# ── Layer 4b: Android ─────────────────────────────────────────────
mobile-android:
name: Layer 4b — Mobile E2E (Android)
needs: api-integration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: default
arch: x86_64
script: npx detox test --configuration android.emu.release
# ── Layer 5: Aggregate all results and block merge if any fail ────
coverage-gate:
name: Layer 5 — Coverage Gate (blocks merge on failure)
needs:
- unit-and-contracts
- api-integration
- web-e2e
- mobile-ios
- mobile-android
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- name: Download all coverage artifacts
uses: actions/download-artifact@v4
with: { path: coverage-reports/ }
- name: Run coverage gate — blocks merge on threshold failure
run: node scripts/coverage-report.js
- uses: actions/upload-artifact@v4
if: always()
with: { name: unified-report, path: coverage-reports/ }
Architecture note
Layers 3 (web E2E), 4a (iOS), and 4b (Android) all depend on Layer 2 but run in parallel with each other. Total pipeline time is therefore: Layer 1 + Layer 2 + max(Layer 3, 4a, 4b) + Layer 5. On a typical app, that is under 20 minutes end-to-end — manageable for every pull request.
Rollout plan
The QA manager’s week-by-week implementation plan
You should not implement all five layers at once. That path leads to a half-finished architecture and frustrated developers. Here is the rollout order I recommend, based on the highest ROI per week of investment.
Week 1
Establish the schema contracts. Create Zod schemas for your 10 most critical data types. Write contract tests. This is the highest-leverage first step — it eliminates a whole class of integration bugs before they reach any other layer, and requires no changes to your application code.
Week 2
Audit and fix your unit tests. Remove anything mocking the database or HTTP layer from the unit suite. Move those to Layer 2. Set Vitest coverage thresholds and enforce them in CI. Refocus unit tests purely on business logic functions.
Week 3
Build the Supertest integration suite. Cover all auth scenarios (401, 403), all response shapes (validate against Zod schemas), and all important error conditions. Expect to find 3–5 auth holes and shape-drift bugs that were never caught before.
Week 4
Build the Playwright suite with POM from day one. Identify your 10 most critical user journeys. Write E2E tests for each using the Page Object Model. Configure Playwright for Chromium, Firefox, WebKit, and two mobile viewports. Wire into CI.
Week 5+
Add Detox and the coverage gate. If your product includes a React Native app, add Detox for iOS and Android. Then build the coverage aggregator script and add the Layer 5 gate job to the pipeline. From this point, every PR is evaluated against all five layers automatically.
“A QA architecture is not complete when there is nothing left to add — it is complete when every layer has a clear owner, a clear job, and the team understands exactly what each test protects against.”
Conclusion
From coverage theatre to genuine confidence — what changes now
Let’s return to the war room from the introduction. The payments flow is down. Engineering is scrambling. The post-mortem will blame a missing test. But the real root cause is rarely a single missing test case — it is a missing layer. The scenario that broke production was invisible to unit tests because unit tests mock everything. It was invisible to E2E tests because E2E tests only covered the happy path. And it was invisible to the mobile testing strategy because there was no mobile testing strategy.
What you have built by following this architecture is a system where that scenario has no hiding place.
What each layer actually changed
Layer 1 (Vitest + Zod) means your business logic is verified in isolation, and every contract between services is machine-enforced. The silent type-drift bug — the one where a backend developer changes a field from a Unix timestamp to an ISO string without telling anyone — is now caught before it reaches a staging environment. Contract tests are the highest-leverage investment in this architecture. They are fast, they run on every commit, and they eliminate an entire class of production incident.
Layer 2 (Supertest) means your API’s wiring is tested end to end, with real authentication, a real database, and real response shape validation. Auth holes that were invisible to unit tests are now exposed the moment they are introduced. The test that verifies a user cannot access another user’s order — the one that seems obvious but is almost never written — is now running on every pull request.
Layer 3 (Playwright) means your users’ critical journeys are verified across every major browser engine. Not just Chrome. Not just the happy path. The declined card scenario, the empty form, the promo code — all tested, all failing loudly when they break, all verified across Chromium, Firefox, WebKit, and mobile viewports. Visual regressions that only appear in Safari are caught before they reach a single user.
Layer 4 (Detox) means the native mobile experience is a first-class citizen of your quality programme, not an afterthought. The iOS keyboard that covers your payment form. The Android back button that dismisses the checkout instead of navigating back. The crash that only happens when the user backgrounds the app mid-payment. All of these are now covered. All of them run on both iOS and Android on every pre-release pipeline.
Layer 5 (the coverage gate) means quality is non-negotiable. Not advisory. Not “we should try to maintain 80%.” Non-negotiable. If any layer falls below its threshold, the pull request does not merge. The gate does not care about deadlines or sprint pressure. It enforces the standard on every merge, automatically, without requiring a human decision.
The cultural shift that makes it stick
Technical architecture is only half the battle. The other half is cultural. Here is the truth that most QA articles skip: you can deploy this entire architecture and have it collapse within six months if the team does not understand why each layer exists.
Developers will skip adding data-testid attributes if they do not understand that without them, the E2E suite becomes unmaintainable. They will mock the database in integration tests if no one explains that doing so makes the test worthless. They will add tests to the wrong layer — unit tests for API handlers, E2E tests for business logic — if the layer boundaries are not clearly communicated and enforced in code review.
Your job as QA manager is to make the architecture legible. Document the layer boundaries. Add linting rules that fail if an integration test imports a mock library for the database. Run internal sessions that explain not just what to test, but why each layer exists. The week-by-week rollout plan in the previous section is designed to create learning moments at each step — each new layer surfaces bugs the previous approach was missing, and those real-world bugs are your best teaching material.
The honest limitations of this architecture
No architecture solves everything, and this one is no exception. There are three things this five-layer approach does not address that you should be aware of.
Non-deterministic features. If your application includes AI-generated content, recommendation systems, or machine learning models, the output is not predictable and cannot be tested with traditional assertions. These features need evaluation frameworks, not test suites — a different and more complex discipline entirely.
Performance and load testing. This architecture tests correctness under normal conditions. It does not test what happens when 10,000 concurrent users hit your checkout at once. For that, you need dedicated load testing tools like k6 or Artillery, and a separate performance testing strategy.
Production observability. Even a perfect test suite cannot catch every production issue. Shift-right practices — feature flags, canary deployments, real-user monitoring, distributed tracing — complement this architecture but are not replaceable by it. Testing before deployment and observing after deployment are both necessary.
The one thing to remember
If you take nothing else from this article, take this: the question to ask about your test suite is not “what is our coverage percentage?” It is “Does every class of risk in our system have a layer that watches it?”
Logic risk — watched by Layer 1. Contract risk — watched by Layer 1. Wiring and auth risk — watched by Layer 2. Journey and UI risk — watched by Layer 3. Platform and native risk — watched by Layer 4. And the gate in Layer 5 makes sure none of those layers is allowed to silently degrade.
The teams that ship with confidence are not the teams with the most tests. They are the teams that know exactly what each test is protecting, exactly where their blind spots are, and exactly what will happen if quality drops below the line. That clarity — architectural clarity — is what this system is designed to give you.
Build it once. Enforce it always. Ship with confidence.
Complete architecture — five layers, five risk classes covered
Layer 1 — Vitest + Zod · Logic & contract riskLayer 2 — Supertest + real DB · Wiring & auth riskLayer 3 — Playwright POM · Journey & browser riskLayer 4 — Detox · Native mobile & platform riskLayer 5 — GitHub Actions gate · Threshold enforcement
Did This Help?
If this guide helped solve a testing challenge you were facing, I’d love to hear about it! Your success stories inspire better content.
About the Author: Passionate about building robust, scalable web applications with comprehensive testing strategies. Currently helping teams implement advanced testing frameworks that catch issues before they reach production.
Connect with me: LinkedIn | GitHub | Portfolio
QualityAssurance #SoftwareTesting #QA #TestAutomation #SoftwareEngineering #JavaScript #Vitest #Playwright #QAManager #Techlead
메타데이터
- post_id
- 126f9ccebd04
- slug
- test-coverage-at-scale-the-complete-layer-by-layer-breakdown-every-qa-manager-needs-126f9ccebd04
- url
- https://medium.com/@peyman.iravani/test-coverage-at-scale-the-complete-layer-by-layer-breakdown-every-qa-manager-needs-126f9ccebd04
- canonical_url
- https://medium.com/@peyman.iravani/test-coverage-at-scale-the-complete-layer-by-layer-breakdown-every-qa-manager-needs-126f9ccebd04
- author_url
- https://medium.com/@peyman.iravani
- status
- ok
- fetched_at
- 2026-07-15 12:51:37