Most bugs that reach real users are not the kind a unit test catches. They are the ones where a button stops working after a CSS change, a login form breaks on a redirect, or a page silently fails to load because an API call timed out. Unit tests check small pieces in isolation, but they never open a real browser and click around like a human would. That gap is exactly what end-to-end testing fills.
In this tutorial you will set up Playwright, a modern browser automation and testing framework built by Microsoft. By the end you will have Playwright installed on Ubuntu, a working test suite that drives a real browser, and the skills to write tests that are stable instead of flaky. You will also learn how to run those tests on a headless server, which is what you need for continuous integration.
This guide is for developers, QA engineers, and DevOps people who want a practical, hands-on introduction. You only need basic command-line knowledge and a little familiarity with JavaScript. You do not need any prior testing experience.
What End-to-End Testing Actually Means
Let us clear up the vocabulary first, because testing has a lot of overlapping terms.
A unit test checks one function or component in isolation. It is fast and narrow.
An integration test checks that a few parts work together, for example that your code talks to a database correctly.
An end-to-end test (often shortened to E2E) checks the whole thing from the user’s point of view. It launches a real browser, navigates to your app, types into fields, clicks buttons, and verifies that the right things appear on screen. If a user can do it, an E2E test can do it too.
Playwright is the tool that drives the browser for you. It controls Chromium, Firefox, and WebKit (the engine behind Safari) through a single API. That means you can write one test and run it against three different browser engines, which is a huge help when you need to catch browser-specific bugs.
Two ideas make Playwright stand out from older tools like Selenium:
Auto-waiting. Older frameworks forced you to sprinkle sleep calls everywhere to wait for elements to appear, which made tests slow and unreliable. Playwright automatically waits for an element to be visible, attached, and ready before it acts on it. You almost never write manual waits.
Locators. Instead of grabbing an element once and hoping it still exists later, Playwright uses locators, which are lazy descriptions of how to find an element. They are re-evaluated every time you use them, so they stay in sync with a page that keeps changing.
If you have read my earlier guide on load testing with k6, you can think of Playwright as the functional counterpart to that performance work: k6 asks “can the server handle the traffic?” while Playwright asks “does the app actually work for a person?”
Prerequisites
Before you start, make sure you have the following:
- An Ubuntu machine (this guide was written against Ubuntu 22.04 and 24.04, but anything reasonably recent works)
- Node.js 18 or newer and npm installed
- Basic command-line skills and a little JavaScript knowledge
- Around 2 GB of free disk space, because browser binaries are large
Check whether Node.js is already installed and recent enough:
node --version
npm --version
If you see a version older than 18, or the command is not found, install a current release. The official NodeSource repository is the cleanest way to get an up-to-date version on Ubuntu:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
Confirm the install worked:
node --version
You should see something like v20.x.x.
Step 1: Create a Project and Install Playwright
Create a fresh directory for your tests and move into it:
mkdir ~/playwright-demo
cd ~/playwright-demo
Now run the official initializer. This single command scaffolds a project, installs the test runner, and downloads the browser binaries:
npm init playwright@latest
The installer asks a few questions. For this tutorial, answer like this:
- Choose TypeScript or JavaScript. We will use JavaScript here to keep things simple, so pick JavaScript.
- Name the tests folder
tests(the default). - Add a GitHub Actions workflow: choose No for now.
- Install Playwright browsers: choose Yes.
The download step pulls in Chromium, Firefox, and WebKit, so give it a minute. When it finishes, your folder contains a few new things:
ls
You will see a package.json, a playwright.config.js, a tests folder with an example test, and a tests-examples folder with more samples.
Installing System Dependencies
Browsers need a number of shared libraries to run, and a clean Ubuntu server usually does not have them all. Playwright ships a helper that installs exactly what is missing:
sudo npx playwright install-deps
This pulls in libraries for graphics, fonts, and audio that Chromium and friends expect. If you skip this step on a minimal server, your tests will fail with cryptic errors about missing .so files. Running it now saves a lot of confusion later.
Step 2: Read and Run the Example Test
Open the generated example test so you can see the shape of a Playwright test:
cat tests/example.spec.js
It looks roughly like this:
const { test, expect } = require('@playwright/test');
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
await page.getByRole('link', { name: 'Get started' }).click();
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
A few things to notice. Each test gets a fresh page object, which represents a single browser tab. The async and await keywords are everywhere because every browser action takes time and returns a promise. The expect calls are your assertions, the points where the test decides whether things are correct.
Run the suite:
npx playwright test
By default Playwright runs headless, meaning no visible browser window opens. This is what you want on a server. After a few seconds you will see output like this:
Running 6 tests using 3 workers
6 passed (4.2s)
To open last HTML report run:
npx playwright show-report
Notice it ran the tests across multiple browsers in parallel using several workers. That is happening automatically based on the config file.
Step 3: Write Your First Real Test
The example is fine, but let us write something from scratch so the pieces click. We will test a public demo site, the Playwright “TodoMVC” style app is a common target, but to keep it dependency-free we will use a simple, stable public page.
Create a new file:
nano tests/search.spec.js
Paste in the following:
const { test, expect } = require('@playwright/test');
test('homepage has a working docs link', async ({ page }) => {
// Go to the site
await page.goto('https://playwright.dev/');
// The page title should mention Playwright
await expect(page).toHaveTitle(/Playwright/);
// Click the "Docs" navigation link
await page.getByRole('link', { name: 'Docs' }).click();
// We should land on a page whose URL contains /docs/
await expect(page).toHaveURL(/.*docs/);
// And the Installation heading should be visible somewhere
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
Save and exit (Ctrl+O, Enter, Ctrl+X in nano).
Run only this file:
npx playwright test tests/search.spec.js
Walk through what happened. The test opened the homepage, confirmed the title, clicked a real link, waited for the navigation to finish on its own, then checked both the URL and a heading. At no point did you write a manual wait. Playwright handled the timing for you.
Why getByRole Instead of CSS Selectors
You might wonder why we wrote getByRole('link', { name: 'Docs' }) instead of something like page.locator('.nav-link'). The reason is stability. CSS classes change constantly as designers tweak styling, and a test tied to .nav-link-v2-blue breaks the moment someone renames it. Roles and visible text, on the other hand, reflect what the user actually sees and interact with. Tests built on them survive redesigns far better.
Playwright recommends these user-facing locators in this rough order of preference: getByRole, getByLabel, getByPlaceholder, getByText, and getByTestId. Reach for a raw CSS or XPath selector only when nothing else fits.
Step 4: Debug Visually with Headed and UI Modes
When a test fails, you want to see what the browser saw. Playwright gives you several ways to do that.
Run with a visible browser window by adding the --headed flag. Note that this needs a graphical display, so run it on your desktop rather than a headless server:
npx playwright test tests/search.spec.js --headed
Even better is UI mode, an interactive interface that lets you step through tests, see each action, and time-travel through snapshots of the page:
npx playwright test --ui
For figuring out the right locator to use, the codegen tool is a gift. It opens a browser, records your clicks and typing, and writes the test code for you:
npx playwright codegen https://playwright.dev/
Click around the page, and watch the code appear in real time. You will not use the generated code verbatim, but it is the fastest way to discover the correct locator for a tricky element.
Finally, after any run you can open a rich HTML report that includes traces, screenshots, and error details:
npx playwright show-report
Step 5: Configure Playwright for a Server
Open playwright.config.js to see how the suite is wired up:
cat playwright.config.js
A few settings are worth knowing about. Here is a trimmed version with the lines that matter most:
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
The retries line is a quiet hero. On a CI server it retries a failing test up to two times, which absorbs the occasional network blip without hiding real failures. The trace: 'on-first-retry' setting records a full trace the moment a test needs retrying, so you have evidence to debug with. And screenshot: 'only-on-failure' captures an image at the exact moment something went wrong.
If you only want to run one browser to speed things up during development, target a project by name:
npx playwright test --project=chromium
Common Mistakes and Troubleshooting
“Host system is missing dependencies” error. This is the most common first-run problem on Ubuntu servers. The fix is the command from Step 1:
sudo npx playwright install-deps
If a specific browser is missing entirely, reinstall the binaries with npx playwright install.
Adding manual sleeps. New users often write something like await page.waitForTimeout(3000) to “make tests pass.” This is an anti-pattern. A fixed sleep is either too short (flaky) or too long (slow). Trust auto-waiting, and when you genuinely need to wait for a condition, wait for that condition explicitly, for example await expect(locator).toBeVisible().
Locators that match more than one element. If a locator finds two buttons with the same text, Playwright throws a “strict mode violation” rather than guessing. This is a feature, not a bug. Narrow the locator, for instance by scoping it inside a container with page.getByRole('navigation').getByRole('link', { name: 'Docs' }).
Tests pass locally but fail in CI. This almost always comes down to timing or screen size. Headless servers can be slower, so lean on retries and traces. Also set a consistent viewport in the config so your layout assumptions hold.
Running headed mode over SSH and getting a display error. A headless server has no screen. Either run headless (the default) or use xvfb-run to provide a virtual display:
sudo apt install -y xvfb
xvfb-run npx playwright test --headed
Best Practices
Prefer user-facing locators. Build tests around roles, labels, and text. They mirror how a person uses the page and survive cosmetic changes. Add a data-testid attribute only when an element has no accessible name to target.
Keep tests independent. Each test should set up its own state and not rely on another test running first. Playwright gives every test a fresh browser context, so use that isolation rather than fighting it. Independent tests can run in parallel and in any order.
Do not test third-party sites in your real suite. We used the Playwright homepage here for convenience, but in production you test your own application. Point Playwright at a local instance using the webServer option in the config so the app starts automatically before the tests run.
Use assertions, not console logs. A test that prints values but never asserts proves nothing. Every test should end in one or more expect calls that would fail loudly if the app misbehaved.
Store traces and reports as CI artifacts. When a test fails in a pipeline, the HTML report and trace are the difference between a five-minute fix and an hour of guessing. Configure your CI to upload the playwright-report folder.
Run a single browser during development, all three before merging. Iterating against Chromium alone is fast. Running the full matrix catches WebKit and Firefox quirks before they reach users.
Conclusion
You now have Playwright installed on Ubuntu, complete with the system libraries a server needs, and you have written tests that drive a real browser the way a user would. Along the way you learned why locators and auto-waiting make Playwright tests far less flaky than older approaches, how to debug visually with UI mode and codegen, and how to configure retries and traces so failures are easy to diagnose.
From here, the natural next steps are to point Playwright at your own application using the webServer config option, add it to a CI pipeline so tests run on every push, and explore more advanced features like network mocking, authentication state reuse, and visual comparison snapshots. Once your suite is wired into CI, pair it with performance checks like the ones in my k6 load testing guide to cover both correctness and speed. A good E2E suite is one of the highest-leverage things you can add to a project: it catches the breakages that matter most while you sleep.