End-to-end testing
What an end-to-end test proves
An end-to-end test drives the application through a browser and checks a user workflow across the assembled layers. Cypress describes E2E as testing from the browser through the backend, including integrations with external services. A checkout test can visit the store, add an item, submit payment details, and verify the resulting screen.
import { test, expect } from '@playwright/test';
test('adds a product to the cart', async ({ page }) => {
await page.goto('/products');
await page.getByRole('button', { name: 'Add Alpine Mug' }).click();
await expect(page.getByRole('status')).toHaveText('Added to cart');
});
That coverage has a cost. Cypress documents E2E tests as slower, more susceptible to flake, and harder to set up and maintain than component tests. Smaller tests can exercise many input combinations without booting the complete application. E2E earns its place on critical workflows where the connection between layers matters.
Wrong "If every unit test passes, the application works for a user."
Right Isolated tests can pass while routing, browser behavior, configuration, or communication between layers is broken. Keep E2E coverage for a bounded set of assembled workflows. Keep narrow logic and exhaustive edge cases in faster tests.
Select what the user can recognize
A selector is part of the test's contract with the page. Playwright recommends locators based on user-facing attributes, such as role, label, and visible text. It also supports test ids as an explicit contract. Long CSS and XPath paths depend on DOM structure and can break during harmless markup changes.
// Coupled to styling and nesting
await page.locator('.toolbar > div:nth-child(2) .btn-primary').click();
// Coupled to the button's user-visible role and name
await page.getByRole('button', { name: 'Save profile' }).click();
// Useful when no stable user-facing identity exists
await page.getByTestId('account-menu-trigger').click();
Cypress's built-in guidance emphasizes data-* attributes for selectors that should survive CSS and JavaScript changes. It also points to Cypress Testing Library when role or label queries express the behavior. The syntax differs, but the design question is the same: should this test fail when the chosen attribute changes?
// Explicit Cypress test contract
cy.get('[data-cy="save-profile"]').click();
// User-facing query with Cypress Testing Library installed
cy.findByRole('button', { name: 'Save profile' }).click();
Wrong "The most specific selector is the most stable selector."
Right Extra DOM detail creates extra reasons to fail. Choose the smallest locator that uniquely identifies the behavior you mean to test.
Waiting for behavior instead of time
Modern pages update asynchronously. A fixed sleep guesses how long the update will take and always pays that delay. Both frameworks provide retry-aware operations, although their mechanics differ.
Playwright checks actionability before actions. For a click, those checks include resolving to one element, visibility, stability, receiving events, and being enabled. Its locator assertions also retry until the condition passes or times out.
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByRole('status')).toHaveText('Order accepted');
Cypress links queries and assertions and retries that query chain. Non-query commands execute once. This is why a .should() assertion can wait for updated DOM, while a later whole-test retry is a separate feature.
cy.get('[data-cy="submit"]').click();
cy.get('[role="status"]').should('have.text', 'Order accepted');
Wrong "Auto-waiting means any arbitrary assertion will eventually pass."
Right Retry behavior belongs to documented locator, query, and assertion APIs. Playwright's isVisible() returns a current boolean, and Cypress .then() callbacks are not retried like linked queries. Use the framework's retry-aware assertion for changing page state.
Make each test independently repeatable
An E2E test should produce the same result when run alone, after another test, or on a parallel worker. Playwright creates a separate browser context for each test, with its own cookies, local storage, and session storage. Cypress also documents test isolation and warns that disabling it can leak state between tests.
Browser isolation does not reset your database. Give each test known server state and unique data. Programmatic setup keeps a test focused on the behavior it owns.
test('customer can cancel an order', async ({ page, request }) => {
const order = await request.post('/test-support/orders', {
data: { owner: `e2e-${test.info().testId}` },
});
const { id } = await order.json();
await page.goto(`/orders/${id}`);
await page.getByRole('button', { name: 'Cancel order' }).click();
await expect(page.getByRole('status')).toHaveText('Order cancelled');
});
Wrong "The login test can run first, then the checkout tests can reuse its browser and cart."
Right Order dependence creates cascading failures and prevents safe parallel execution. Each test establishes the state it needs.
Network stubbing changes the guarantee
Playwright can intercept a matching request with page.route() and fulfill it without calling the API. Cypress can use cy.intercept() to supply a response body, status, headers, or delay. This makes rare and awkward states deterministic.
await page.route('**/api/checkout', async (route) => {
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ code: 'PAYMENTS_UNAVAILABLE' }),
});
});
await page.goto('/checkout');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('alert')).toHaveText(
'Payments are temporarily unavailable',
);
The test above verifies how the browser application handles the chosen response. It does not verify that the deployed checkout service is reachable or returns that contract. Keep at least one path through the real test backend when browser-to-backend integration is the behavior under test.
test('checkout reaches the test backend', async ({ page }) => {
await page.goto('/checkout');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('heading', { name: 'Receipt' })).toBeVisible();
});
cy.intercept('POST', '/api/checkout', {
statusCode: 503,
body: { code: 'PAYMENTS_UNAVAILABLE' },
}).as('checkout');
cy.visit('/checkout');
cy.get('[data-cy="place-order"]').click();
cy.wait('@checkout');
cy.get('[role="alert"]').should(
'have.text',
'Payments are temporarily unavailable',
);
The alias waits for the matching request and response cycle. It replaces a time guess with an observable event.
Wrong "A fully stubbed E2E test proves the whole stack works."
Right Stubbing removes the real dependency from that path. Use it for controlled frontend scenarios and retain separate coverage for the integration you removed.
Page objects should expose behavior
Playwright documents page object models as one way to structure a large suite. A page object can centralize locators and expose a higher-level API. The useful boundary is a meaningful part of the application, not necessarily one class for every URL.
import { expect, type Locator, type Page } from '@playwright/test';
export class CheckoutPage {
readonly placeOrder: Locator;
readonly status: Locator;
constructor(private readonly page: Page) {
this.placeOrder = page.getByRole('button', { name: 'Place order' });
this.status = page.getByRole('status');
}
async submit() {
await this.placeOrder.click();
await expect(this.status).toHaveText('Order accepted');
}
}
A page object does not repair a poor selector. It can spread one poor selector across the entire suite while hiding what the test interacts with. Keep assertions that define reusable workflow completion with the workflow; keep assertions unique to a scenario visible in that scenario.
Wrong "Every E2E suite needs page objects, and abstraction automatically makes selectors stable."
Right Page objects are an organization technique. Stability still comes from isolation, meaningful locator contracts, and observable completion conditions.
Auto-retry and test retry solve different problems
Playwright's actionability checks and web-first assertions wait within one test attempt. Cypress retries linked queries and assertions, while non-queries execute once. Neither behavior means the complete test starts over.
Whole-test retries run another attempt after a failure. Playwright and Cypress both default to zero test retries. A retry can expose intermittent behavior and provide another diagnostic attempt. A pass on retry remains evidence of flakiness.
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: process.env.CI ? 2 : 0,
trace: 'on-first-retry',
});
Use bounded retries with artifacts. A large retry count can make a red pipeline appear green while preserving the defect that caused the first attempt to fail.
Treat flakiness as a failed assumption
E2E tests coordinate a browser, application code, server processes, test data, and CI resources. An intermittent failure means at least one assumption about those parts is unstable. Classify the failed assumption before changing timeouts.
- The test observed state before the application reached the required condition.
- The locator depended on DOM structure or matched more than one target.
- Tests shared browser or server-side state.
- A real dependency or the CI machine was unavailable or resource constrained.
Each class has a different repair. Replace time guesses with observable UI or network conditions. Replace structural selectors with user-facing or explicit contracts. Allocate unique data. Preserve evidence for infrastructure failures.
// A time guess hides which condition is late.
await page.waitForTimeout(5000);
// The assertion names the condition the workflow requires.
await expect(page.getByRole('heading', { name: 'Receipt' })).toBeVisible();
Wrong "If retry number two passes, retry number one was harmless noise."
Right Playwright classifies a test that fails first and passes on retry as flaky. Keep that classification visible and inspect the failed attempt. Retries reduce disruption while the cause is being diagnosed; they do not change the first attempt's result.
Preserve the right integration boundaries
A test portfolio needs different boundaries because each boundary answers a different question. Cypress's testing-type comparison describes E2E as comprehensive but slower and more susceptible to flake, while component tests are quick and reliable but do not prove all layers work together.
For a checkout feature, a practical split can look like this:
- Small tests cover price calculations, validation combinations, and component states.
- A stubbed browser test forces a declined-payment response and checks the rendered recovery path.
- A small number of real-backend E2E tests cover the assembled purchase workflow.
Neither Playwright nor Cypress prescribes a fixed ratio of E2E tests to smaller tests. Choose counts from risk and feedback cost. Critical cross-layer workflows justify the expensive boundary; input combinations usually do not.
test('declined payment remains editable', async ({ page }) => {
await page.route('**/api/payments', (route) =>
route.fulfill({
status: 402,
json: { code: 'CARD_DECLINED' },
}),
);
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByRole('alert')).toHaveText('Card declined');
await expect(page.getByLabel('Card number')).toBeEditable();
});
The companion path leaves the route untouched and checks the assembled integration:
test('payment completes through the test backend', async ({ page }) => {
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByRole('heading', { name: 'Receipt' })).toBeVisible();
});
Wrong "Network stubs always reduce flakiness without reducing coverage."
Right A stub controls the response and removes the real service from that execution path. The test gains determinism for the client behavior and gives up evidence about the removed integration.
CI must wait for readiness
Installing dependencies and launching a server does not mean the application is ready to accept browser traffic. Cypress's CI guide calls out the race in starting a server and immediately running tests. It recommends waiting for a URL to respond instead of adding an arbitrary sleep. Playwright provides a webServer configuration that can launch a command and wait for a URL.
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'npm run start',
url: 'http://127.0.0.1:3000/health',
reuseExistingServer: !process.env.CI,
},
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
});
Playwright's CI documentation recommends one worker in CI for stability and reproducibility, then suggests sharding across jobs for wider parallelism. A capable self-hosted runner can use more workers. The constraint is resources and isolation, not a universal worker count.
A minimal Playwright job has three framework-specific operations after the repository and Node runtime are available:
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
Cypress needs the same readiness principle. Its official GitHub Action can start and wait for the server, or the pipeline can gate cypress run on a responsive URL.
- run: npm ci
- run: npx start-server-and-test start http://127.0.0.1:3000 cy:run
Parallelism is an isolation test
Playwright can shard a suite across machines. Cypress can parallelize specs across multiple machines when recording to Cypress Cloud. More workers shorten elapsed time only when tests and infrastructure tolerate concurrent load.
npx playwright test --shard=1/3
npx playwright test --shard=2/3
npx playwright test --shard=3/3
Before adding workers, remove shared accounts, fixed record names, reused carts, and global cleanup that can delete another worker's data. Generate a stable identity from the worker or test and clean only the records that identity owns.
const customerEmail = `e2e-${test.info().parallelIndex}-${test.info().testId}@example.test`;
Failure artifacts complete the CI contract. Keep the report plus the framework's trace, screenshot, or video for failed and retried attempts. A failed test without the page, network, and timing evidence often costs more engineer time than the test saved.
dig deeper
Primary sources behind these notes - the specs and official docs worth reading in full.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.