For a new end-to-end suite, start with Playwright. It ships its own test runner, waits for elements automatically, runs tests in parallel, drives Chromium, Firefox and WebKit, and records traces you can replay. Puppeteer is a browser automation library, not a test framework. It still fits scraping, PDF generation, and Chrome-only scripts, and I’d keep it for those.
Both tools came out of the same lineage. Playwright was started by people who had worked on Puppeteer, so the APIs look alike: page.goto, page.click, page.locator. That similarity is why people ask which to pick, and why the answer depends on what sits around the browser calls.
Comparison table
| Puppeteer | Playwright (@playwright/test) | |
|---|---|---|
| What it is | Library that controls a browser | Library plus test runner |
| Browsers | Chrome and Chromium, Firefox through WebDriver BiDi | Chromium, Firefox, WebKit |
| Test runner | None. Bring Jest, Mocha or node:test | Built in |
| Waiting | Locators wait for the element. Plain page calls need you to wait | Actions wait for the element to be actionable. Assertions retry |
| Parallelism | You configure it in your runner | Workers per file, or per test with fullyParallel |
| Tracing | Chrome performance traces and screenshots | Trace viewer with DOM snapshots, network and console per step |
| Retries | Runner dependent | Config option |
| Languages | JavaScript and TypeScript | JavaScript, TypeScript, Python, Java, .NET |
| Codegen | Chrome DevTools Recorder can export Puppeteer scripts | npx playwright codegen |
Check the details against each project’s docs before you commit. Both move quickly, and a table like this ages.
The same login test in both
I wrote one test against a public practice site, the-internet.herokuapp.com. It logs in with the demo user and checks the success message.
Puppeteer, using node:assert because there is no runner:
import puppeteer from 'puppeteer';
import assert from 'node:assert/strict';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://the-internet.herokuapp.com/login');
await page.locator('#username').fill('tomsmith');
await page.locator('#password').fill('SuperSecretPassword!');
await page.locator('button[type="submit"]').click();
const flash = await page.locator('#flash').map((el) => el.textContent).wait();
assert.match(flash, /You logged into a secure area!/);
await browser.close();
Playwright Test:
import { test, expect } from '@playwright/test';
test('valid user can log in', async ({ page }) => {
await page.goto('https://the-internet.herokuapp.com/login');
await page.getByLabel('Username').fill('tomsmith');
await page.getByLabel('Password').fill('SuperSecretPassword!');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.locator('#flash')).toContainText('You logged into a secure area!');
});
I ran both and both pass. The differences are visible without reading closely.
- Playwright’s version has no browser setup or teardown. The
pagefixture opens a fresh browser context for each test and closes it afterwards. - Playwright finds fields by label and role, which is how a user finds them. In Puppeteer I used CSS selectors. You can do the same in Playwright, and Puppeteer has accessibility selectors too, but the defaults push you in different directions.
- The Puppeteer assertion reads the text once, after the locator resolves. The Playwright assertion keeps checking until the text appears or the timeout hits.
That last point is the one that matters in a real suite.
Waiting and flakiness
Most flaky E2E tests are timing problems: the test checks something before the page has caught up. Playwright attacks this in two places. Actions such as click wait until the element is visible, stable and enabled. Web-first assertions such as toContainText retry until they pass.
Puppeteer’s locator API waits for actions in a similar way. Assertions are your runner’s job, though, and Jest’s expect does not retry. If you write expect(await page.$eval(...)).toBe(...) you have a race, and it will pass on your laptop and fail on a slower CI box. You can wrap it in a polling helper, and then you are maintaining a small piece of a test framework.
For the other causes of flaky tests, shared data, order dependence and fragile selectors, see Flaky end-to-end tests: the five causes and how to fix each and How to make your UI automation tests resilient to changes.
Parallel runs and reports
With Puppeteer, parallelism comes from the runner. Jest runs test files in separate workers, which works, but each worker launches its own browser and you manage that lifecycle. Playwright Test runs files in parallel by default and gives each test an isolated context, which is cheaper than a new browser. Sharding across CI machines is a flag: npx playwright test --shard=1/4.
The report side is where I notice the gap most. When a Playwright test fails in CI, you open the trace and step through the test with a DOM snapshot at every action. With Puppeteer you get whatever you wired up: a screenshot on failure, maybe a log.
When Puppeteer still fits
- Chrome-only scripts that are not tests. Scraping, rendering a page to PDF, generating screenshots. There is no runner overhead to carry, and the library is small.
- An existing Puppeteer suite that works. A rewrite buys you nothing if the suite is stable and nobody is fighting it.
- Chrome DevTools Protocol access. Both tools can reach it, and Puppeteer was built around it. If your job is to inspect Chrome behavior closely, it is a natural fit.
- A team that already has a runner and reporting around Jest. Adding a browser library to that is a small step.
If you are writing user-facing E2E tests in a new project, none of these apply.
Migration notes
If you do move an existing Puppeteer suite, do it a file at a time.
- Install
@playwright/testnext to Puppeteer. They do not conflict. - Port one stable test. Replace
browser.newPage()with thepagefixture and swapawait page.waitForSelector(...)for an assertion on the thing you were waiting for. - Replace
page.$evalreads followed byexpectwithawait expect(locator).toHaveText(...). This is the change that removes the most flakiness. - Move shared login steps into a fixture or a saved
storageState, so tests start already authenticated. - Delete the Puppeteer file once the Playwright version has been green for a while.
Do not translate line by line. A mechanical port keeps the old waiting habits and loses the benefit.
FAQ
Is Playwright better than Puppeteer? For testing, mostly yes, because the runner, auto-waiting, tracing and cross-browser support come in one package. For Chrome-only scripts that are not tests, the two are close and Puppeteer is fine.
Can Puppeteer test Firefox? Yes, through WebDriver BiDi. Playwright covers Firefox and WebKit with its own patched builds, and that coverage is more established.
Can I use Jest with Playwright? Yes. The step-by-step version is in First steps with end-to-end testing using Jest and Playwright. If you have no existing Jest suite, @playwright/test is simpler.
Is it hard to switch later? The APIs are close enough that porting a test takes minutes. The effort is in the waiting and fixture habits, not the syntax.
If you are still choosing a language or framework before the browser tool, First steps with test automation covers that decision. Then pick one flow, a login, and write it in Playwright first.