End-to-end testing fundamentals — test structure, page interaction, assertions, auto-waiting, and fixtures
Unit tests cover individual bricks. Integration tests check that bricks fit together. E2E tests verify that the whole building stands and the doors open.
test.describe() — grouping testsGroups related tests under a label, like a test suite. The label appears as a section header in the test report.
test() — a single test case
The page parameter is a fixture — Playwright automatically creates a fresh browser tab for each test and destroys it after. You never manage browsers yourself.
Something Playwright prepares before your test runs and cleans up after it finishes. You declare what you need, Playwright handles the lifecycle. Similar in spirit to JUnit's @BeforeEach / @AfterEach, but with cleaner syntax.
Like fixtures in a physical workshop that hold a piece steady while you work on it — they're "fixed in place" before the real work begins.
| Fixture | What it provides |
|---|---|
| page | A fresh browser tab — the most common fixture. Isolated per test. |
| context | A full browser context — useful when you need multiple tabs. |
| browser | The browser instance itself — rarely needed directly. |
| request | An API request context — useful for setting up preconditions via API calls. |
You can create your own fixtures for reusable preconditions — e.g. a loggedInPage that handles login before each test. This keeps tests clean and avoids repetitive setup.
page.goto() — navigation
Navigates the browser. Uses the baseURL from playwright.config.ts, so '/login' becomes http://localhost:3000/login.
page.getByRole() — finding elements
Playwright's recommended approach — it mirrors how a real user perceives the page. They see "a text field labeled email", not <input id="email-field">. The regex /email/i is a case-insensitive match on the accessible name.
Unlike Jest's instant assertions, Playwright assertions poll repeatedly until the condition is met or the timeout expires (default: 5 seconds). No need for setTimeout or manual waitFor calls.
| Assertion | What it checks |
|---|---|
| expect(page).toHaveURL('/') | Browser URL matches. Auto-waits for redirects. |
| expect(locator).toBeVisible() | Element is visible on the page. |
| expect(locator).toHaveText('...') | Element's text content matches. |
| expect(locator).not.toBeVisible() | Negated — element should NOT be on screen. |
When the outcome depends on the timing of two things happening, and that timing isn't guaranteed. In E2E testing: your test clicks a button that triggers an API call, and the next line checks for a result that isn't there yet. Sometimes the API is fast enough and the test passes. Sometimes it isn't, and the test fails — not because your app is broken, but because the test and the app were "racing" each other.
Playwright auto-waits for actionability checks before every action and polls assertions until they pass. No sleep(2000) hacks needed.
fill() — waits for the input to be visible and editableclick() — waits for the button to be visible and not covered by another elementtoHaveURL() — polls the URL until it matches or times outtoBeVisible() — polls until the element appears or times out
await is a JavaScript language requirement for Promises — you always need it. Auto-waiting is Playwright's internal smart behaviour that happens inside those awaited methods. Forgetting await causes exactly the race conditions Playwright is designed to prevent.
Testing "create a manual step" requires the user to be logged in, have picked a project, and have a manual already. Clicking through all those UI flows in every test is slow, fragile, and means a broken login page fails every test in your suite.
Only test the thing you're actually testing through the UI. Everything else (login, project creation, manual creation) should be set up via shortcuts — API calls or database seeding.
Playwright's main job is "does it work?", not "how fast is it?" — but you can do simple timing checks. For serious performance testing, use dedicated tools like Lighthouse, k6, or WebPageTest.
Only one test should go through the login UI (your login test). Only one test should create a project through the UI (your project creation test). Every other test that needs those as preconditions should set them up via API calls — keeping each test fast, focused, and independent.
@BeforeEach ≈ Playwright fixtures — both handle setup/teardown, but fixtures use a cleaner "ask for what you need" syntax
expect(x).toBe(y) ≈ Playwright expect(locator).toBeVisible() — same idea, but Playwright assertions auto-retry
await for Promises is separate from Playwright auto-waiting — you always need await, auto-wait happens inside the methods