Home / Blog / Playwright vs @playwright/test
Playwright vs @playwright/test: which package do you actually install?
Install @playwright/test. That is what almost everyone means by Playwright: the
browser API plus a test runner, with fixtures, projects, parallel workers, retries, reporters
and UI mode. The plain playwright package is the same browser API without the
harness. Reach for it when you are not writing tests: scraping, PDFs, scheduled screenshots,
a script inside another tool.
Both packages are current, both are Microsoft's, and on npm they move together: an unpinned install of each on 1 September 2026 returned 1.62.1. Every sample and every error message here came out of two empty directories that day, one package in each, on Node 20.19.6.
Which one do I install, playwright or @playwright/test?
@playwright/test, unless you are not writing tests. It gives you the browser API
and the runner in one dependency, and it is what every tutorial, conference talk and Stack
Overflow answer will assume you already have.
Playwright's own documentation makes the recommendation: "Under most circumstances, for
end-to-end testing, you'll want to use @playwright/test (Playwright Test), and not
playwright (Playwright Library) directly."
The two install paths leave different things on disk. npm init playwright@latest
scaffolds a project, and the documentation lists what lands: playwright.config.ts,
package.json, a lockfile and tests/example.spec.ts, with the browser
binaries downloaded on the way through. npm i -D playwright followed by
npx playwright install chromium firefox webkit leaves a dependency and browser
binaries. No config file, no test folder, nothing to type to run something.
If you already ran the wrong one, nothing is broken. Adding @playwright/test and
dropping playwright from your package.json is the entire fix.
What is the Playwright library on its own?
The playwright package is the browser automation API with nothing wrapped around
it: chromium, firefox and webkit to launch, contexts,
pages, locators, actions, network interception, screenshots, PDFs and tracing. It is the same
engine the runner drives, described from the top in
what Playwright actually is.
You supply the program around the API: the entry point, the close() calls, the
decision about what counts as a failure, and a loop if you want more than one of anything.
Here is a library script that screenshots a page.
import { chromium } from 'playwright';
const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
const page = await context.newPage();
await page.goto('https://playwright.dev/');
await page.screenshot({ path: 'home.png', fullPage: true });
await context.close();
await browser.close();
Playwright 1.62.1 · TypeScript · lib-only/screenshot.ts · npx tsx screenshot.ts · wrote a 515,961-byte home.png
Nothing about that file is second-class; the documentation's own first script is the same shape.
Two things it needed came from outside Playwright. The project's package.json
carries "type": "module", because the file uses top-level await. And
running a .ts file needs a transpiler you install yourself: node
screenshot.ts on Node 20.19.6 exits with ERR_UNKNOWN_FILE_EXTENSION, so the
command is npx tsx screenshot.ts. Playwright ships the type definitions; the
compiler is your problem. Its own Key Differences table says as much in the
Running row — with the library, "you run the code as a node script, possibly with
some compilation first."
When is the library alone the right choice?
Scraping and data extraction. This is the largest library-only audience, and
the scraping tutorials that rank for it open with npm install playwright without
mentioning that a second package exists. For their reader that is correct. A scraper wants one
browser, its own schedule and its own error handling, and it belongs to a data pipeline. Firm86
builds Playwright test suites and does not sell scraping work, so take this as a description of
what people do with the package.
Generating a PDF or an image from a page. An invoice, a monthly report, a
social card, rendered on demand by a service that has a request waiting on it.
page.pdf() and page.screenshot() are library calls and the runner adds
nothing to either.
Screenshots on a schedule. A cron job that captures a page every hour, a check that confirms the marketing site still renders after a deploy, a snapshot step inside a pipeline that belongs to something else. The scheduler exists already and you are borrowing a browser.
A script inside another tool's process. A CLI, a serverless function, a build step, a queue worker. Each already owns its process lifecycle, its retry policy and the place its errors go, and a test runner inside one leaves two systems arguing over when the process ends and who writes the report. The library exists so that you do not have to hold that argument.
The runner buys none of these anything. There are no tests to isolate, no report anybody will open, and no retry you would want silently applied to a job that writes data somewhere.
One edge, because it comes up: a library script in a loop is still one browser doing one thing, and a repeat count is not a load figure. Playwright is not k6, JMeter or Gatling, and we do not sell load testing built on it.
What does @playwright/test add that you would otherwise build?
Start with what the two packages export. This ran in the directory whose
package.json lists only @playwright/test:
const lib = await import('playwright');
const run = await import('@playwright/test');
const a = Object.keys(lib).sort();
const b = Object.keys(run).sort();
console.log('playwright ', a.length, 'names:', a.join(', '));
console.log('@playwright/test ', b.length, 'names:', b.join(', '));
console.log('only in the runner:', b.filter(n => !a.includes(n)).join(', '));
console.log('only in the library:', a.filter(n => !b.includes(n)).join(', ') || '(none)');
Playwright 1.62.1 · exports.mjs · node exports.mjs
$ node exports.mjs
playwright 10 names: _android, _electron, chromium, default, devices, errors, firefox, request, selectors, webkit
@playwright/test 17 names: _android, _baseTest, _electron, _utilityTest, chromium, default, defineConfig, devices, errors, expect, firefox, mergeExpects, mergeTests, request, selectors, test, webkit
only in the runner: _baseTest, _utilityTest, defineConfig, expect, mergeExpects, mergeTests, test
only in the library: (none)
output, 1 September 2026 · @playwright/test 1.62.1 · Node 20.19.6
Ten names against seventeen, and every library name appears in the runner's list. Playwright's
documentation states the same relationship in a sentence: "Playwright Library provides unified APIs for
launching and interacting with browsers, while Playwright Test provides all this plus a fully
managed end-to-end Test Runner and experience." Of the seven names the runner adds, two
carry a leading underscore and are internal, leaving test, expect,
defineConfig, mergeTests and mergeExpects.
The same run answers a packaging question. It imported playwright from a project
that never listed it, and the import resolved, because @playwright/test 1.62.1
declares "playwright": "1.62.1" as a dependency, pinned to that exact version.
Here is the screenshot script's job written as a test:
import { test, expect } from '@playwright/test';
test('the home page offers a way in', async ({ page }) => {
await page.goto('https://playwright.dev/');
await expect(page.getByRole('link', { name: 'Get started' })).toBeVisible();
});
Playwright 1.62.1 · TypeScript · runner/tests/home.spec.ts · npx playwright test · 1 passed (3.1s)
No launch, no context, no close, no browser variable. The page
arrives isolated and is closed for you. The runner also compiled that .ts file with
no tsx, no tsconfig.json and no "type": "module" anywhere
in the directory.
Behind those four missing lines:
- Fixtures hand each test a fresh
pageandcontext, plus whatever you define yourself for login state, seeded data and worker-scoped resources. Without them you write abeforeEachthat launches and afinallythat closes, in every file, forever. - Projects are named configuration groups, so one set of specs runs on Chromium, Firefox and WebKit, or against staging and local. Without them, an environment variable and a shell loop.
- Parallelism spreads the test files over worker processes. Without it, your own process pool.
- Retries re-run a failed test and classify the result, so a test that passes
on its second attempt is reported as flaky. Without it, a
forloop that swallows failures. - Reporters cover the HTML report, the line reporter and the blob reporter that merges shards into one result. Without them, you parse your own output.
- Trace configuration decides when a trace gets captured. Tracing itself is a library API.
- UI mode is
npx playwright test --ui, the watch-mode explorer with a timeline, DOM snapshots and pick-locator. It is what every Playwright demo is filmed in, and it belongs to the runner.
Tracing shows the difference cleanly. A library script records its own:
import { chromium } from 'playwright';
const browser = await chromium.launch();
const context = await browser.newContext();
await context.tracing.start({ screenshots: true, snapshots: true });
const page = await context.newPage();
await page.goto('https://playwright.dev/');
await page.getByRole('link', { name: 'Get started' }).click();
await context.tracing.stop({ path: 'trace.zip' });
await context.close();
await browser.close();
Playwright 1.62.1 · TypeScript · lib-only/trace.ts · npx tsx trace.ts · wrote a 1,046,743-byte trace.zip holding 21 entries
That zip opens with npx playwright show-trace trace.zip, in the same viewer, from
the CLI the library itself ships. The runner's version puts no tracing code in the test:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: 2,
reporter: 'html',
use: {
viewport: { width: 1280, height: 720 },
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
Playwright 1.62.1 · TypeScript · runner/playwright.config.ts
Under that config a deliberately failing spec ran three times (the first attempt plus the two
retries that retries: 2 allows) and a trace.zip appeared in exactly one
of the three result directories, the one whose name ends -retry1. The capability moved
out of the test and into a policy. That is
the same split across languages, where the
other bindings get the browser API and bring pytest, JUnit or NUnit for the rest.
How do I move a script into the test runner?
The signal is that you have started building the harness yourself. The script checks things and branches on the result, you want to know which check failed instead of only that the process exited non-zero, and there is a retry loop or a log format in there that somebody wrote by hand.
The move itself is short:
- Add
@playwright/testand leave the script where it is. The getting-started walkthrough builds a suite from nothing if you want the long form of every step below. - Write
playwright.config.ts, the one file the library never gave you. - Wrap each thing the script checks in a
test(), takepagefrom the fixture, and delete the launch and the close. - Replace ad-hoc
ifchecks with web-first assertions, which auto-wait until the condition holds or the timeout expires. - Delete your retry loop and your logging, and configure the runner's instead.
A script with three checks in it is an afternoon. A script that has grown a hundred, and stands between a deploy and a customer, is a project, and it is the one we take on: build the suite properly, once — the specs in your repository, and the CI job that runs them on every commit.
When this bites you
Both packages in one package.json, at different versions. npm
does this without a warning. Asking for playwright@1.61.1 beside
@playwright/test@1.62.1 exits clean, leaves two copies of the library, and lets the
CLI and the import disagree about which one you meant.
$ npm i -D playwright@1.61.1 @playwright/test@1.62.1
added 5 packages, and audited 6 packages in 4s
$ npm ls --all
mixed@1.0.0
+-- @playwright/test@1.62.1
| `-- playwright@1.62.1
| `-- playwright-core@1.62.1
`-- playwright@1.61.1
`-- playwright-core@1.61.1
$ npx playwright --version
Version 1.62.1
$ npx playwright install chromium
$ node shot.mjs
browserType.launch: Executable doesn't exist at C:\Users\khark\AppData\Local\ms-playwright\chromium_headless_shell-1228\chrome-headless-shell-win64\chrome-headless-shell.exe
1 September 2026 · trimmed: absolute paths and the optional-dependency lines removed
The 1.61.1 library wants Chromium build 1228 and the 1.62.1 CLI installs 1234, so
npx playwright install chromium succeeds and the script still cannot launch. The
error then tells you to run the command you have just run. Pin both to one version, or list only
@playwright/test.
A snippet from the docs pasted into a library script. Most examples on
playwright.dev are runner examples, starting from a page that a fixture supplied.
A script has no fixture to supply it.
$ npx tsx wrongimport.ts
SyntaxError: The requested module 'playwright' does not provide an export named 'expect'
$ npx playwright test
Error: Cannot find package '@playwright/test' imported from lib-only\tests\home.spec.ts
Error: No tests found
$ npx tsx pasted.ts
ReferenceError: page is not defined
1 September 2026 · Playwright 1.62.1 · Node 20.19.6 · paths shortened, output otherwise verbatim
The import error names expect and not the package, which sends people looking in
the wrong place. And npx playwright test starts at all from a library-only project
because the playwright package ships the test command in its own CLI:
the help output from the two installs is byte-identical at 1,921 bytes. The command existing is
no evidence the runner is there.
A script that has quietly become a suite. Forty checks in one file, no way to run one on its own, and a failure that reports only a non-zero exit. Nothing errors, which is why this one runs for a year before anybody moves it.
Questions
Do I need both playwright and @playwright/test?
No, and listing both is how projects get into trouble. At 1.62.1 the @playwright/test package declares playwright as a dependency at an exact version, so npm puts the library in node_modules whether or not you asked for it. A script in a project that lists only @playwright/test can import chromium from playwright and it resolves. If you are writing tests, @playwright/test on its own is the whole dependency.
Can I use Playwright without a test runner?
Yes. The playwright package launches browsers, drives pages, intercepts network traffic, takes screenshots, saves PDFs and records traces on its own, and the documentation's own first script is a library script. Use it when the program is not a test: a scraper, a PDF service, a screenshot on a schedule, or a step inside a tool that already has a runner of its own.
Is the plain playwright package enough for web scraping?
Yes, and it is what the scraping tutorials install. A scraper wants one browser, its own schedule and its own error handling, and the runner's fixtures, retries and reports have nothing to attach themselves to. Firm86 builds Playwright test suites and does not sell scraping work, so read that as a description of the package rather than an offer.
Can I record a trace without the test runner?
Yes. context.tracing.start and context.tracing.stop are library calls, and the zip they write opens in the same viewer through npx playwright show-trace, a command the library's own CLI carries. What the runner adds is the policy: set trace to on-first-retry in the config file and the trace is captured on the retry, with no tracing code anywhere in the test.
What does your script check right now?
If you have a Node script driving a browser and somebody has told you it ought to be a test suite, the useful first message is what it checks and how you find out when one of those checks fails. That is enough for us to say what the conversion involves and where it stops being worth doing. Firm86 builds Playwright suites: the specs in your repository, and the CI job that runs them on every commit. Engineers are billed hourly, from $50 an hour, minimum one full-time engineer for one month.