Home / Blog / Playwright vs @playwright/test

Playwright vs @playwright/test: which package do you actually install?

Quick answer

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:

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:

  1. Add @playwright/test and 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.
  2. Write playwright.config.ts, the one file the library never gave you.
  3. Wrap each thing the script checks in a test(), take page from the fixture, and delete the launch and the close.
  4. Replace ad-hoc if checks with web-first assertions, which auto-wait until the condition holds or the timeout expires.
  5. 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.