
Using Playwright Without a Test Runner — Alternatives to @playwright/test and Their Trade-offs
This page has been translated by machine translation. View original
Introduction
When it comes to Playwright, the common approach is to use @playwright/test and write tests with test() and expect(). However, Playwright has a core library called playwright that can also be used as a browser automation script without a test runner.
This article introduces a real-world case where we adopted the plain playwright library instead of @playwright/test. We'll break down why we made that choice, what we gave up, and what we gained.
Prerequisites & Environment
playwright(core library)- TypeScript
- Path alias configuration with
module-alias
Differences Between @playwright/test and playwright
playwright → Browser automation library (core)
@playwright/test → Test runner + playwright core
@playwright/test is playwright with a test runner added on top. It includes the following features:
| Feature | @playwright/test | playwright (core) |
|---|---|---|
| Browser automation | ○ | ○ |
| test() / expect() | ○ | × |
| Fixtures | ○ | × |
| Auto retry | ○ | × |
| Parallel execution | ○ | × |
| Reporter | ○ | × |
| Trace / Screenshot | ○ (automatic) | ○ (manual) |
| playwright.config.ts | ○ | × |
Writing Test Scripts with Plain playwright
import "module-alias/register";
import { chromium } from "playwright";
import CheckoutFlow from "@class/screen/CheckoutFlow";
(async () => {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext({ locale: "en-US" });
const page = await context.newPage();
await CheckoutFlow.new(page, "STAGING")
.then((flow) => flow.addItemToCart({ productId: "A-001", quantity: 2 }))
.then((flow) => flow.enterShippingAddress("東京都渋谷区1-2-3"))
.then((flow) => flow.selectPaymentMethod("クレジットカード"));
process.exit(0);
})();
(async () => { ... })() — wrapped in an immediately invoked async function expression (IIFE). In @playwright/test, test() plays this role, but with the plain library you need to write it yourself. Using module-alias, project classes are referenced with path aliases like @class/....
Why We Didn't Use @playwright/test
1. RPA-style scripts, not tests
The purpose of this project was not "pass/fail judgment of test results," but "automatically executing business flows." There were almost no situations requiring assertions with expect(), making the test() framework overkill.
2. Building a custom orchestration layer
import Browser from "@class/wrapper/Browser";
import Slack from "@class/wrapper/Slack";
import Spreadsheet from "@class/wrapper/Spreadsheet";
At the top of the script, we import custom wrapper classes. Integration with external services (such as Slack notifications and spreadsheet output) was required beyond just browser automation, and we didn't want to be constrained by @playwright/test's test lifecycle.
3. Complete control over execution flow
With @playwright/test:
- The framework controls test execution order
- Each test is assumed to be independent for parallel execution
- State is managed through fixtures
With plain playwright:
- Scripts execute sequentially from top to bottom
- Results from previous steps can be used in subsequent steps
- External API calls or file I/O can be inserted at any point
The Intent Behind headless: false
const browser = await chromium.launch({ headless: false });
headless: false displays the browser UI. During development, you can visually verify the operations, making debugging significantly easier.
In CI environments, switch to headless: true:
const browser = await chromium.launch({
headless: process.env.CI === "true",
});
The Importance of locale Configuration
const context = await browser.newContext({ locale: "en-US" });
We explicitly set the locale. This affects:
- Date picker display format (MM/DD/YYYY vs DD/MM/YYYY)
- Numeric thousand separators (1,000 vs 1.000)
- The browser's
Accept-Languageheader
If the application under test references navigator.language to switch its display, not specifying a locale can cause results to vary between test environments.
A Note on process.exit(0)
process.exit(0);
We explicitly terminate the process at the end of the script. Without this:
- The Node.js process won't exit while the browser's WebSocket connection remains open
- Especially with
headless: false, the browser window stays open
A cleaner cleanup approach:
try {
// Test flow
await CheckoutFlow.new(page, "STAGING")
.then((flow) => flow.addItemToCart({ productId: "A-001", quantity: 2 }));
} finally {
await browser.close();
}
Calling browser.close() closes the browser and the process exits naturally. With this approach, process.exit(0) is unnecessary. Since process.exit(0) skips finally blocks, it can cause resource leaks.
When to Consider Migrating to @playwright/test
Consider migrating to @playwright/test when the following requirements arise:
- Test result reporting becomes necessary (HTML Reporter, JUnit Reporter)
- You want to reduce test time with parallel execution in CI/CD
- Automatic retry on failure is needed (in unstable network environments)
- You want to manage common test setup with fixtures
- The team grows and standardization of test structure becomes necessary
Conversely, if you have a small number of scripts and RPA-style usage is the primary purpose, plain playwright is sufficient.
Summary
playwright(core) can be used as a browser automation library without a test runner- By not using
@playwright/test, you gain complete control over execution flow and freedom in external service integration - In return, retry, parallel execution, and reporting must either be implemented yourself or foregone
- Develop with
headless: false, lock display formats withlocale, and clean up properly withbrowser.close() - Consider migrating to
@playwright/testas the team size and number of tests grow
It's important not to be bound by the "correct way" to use a tool, but to choose the approach that fits your project's purpose.

