Using Playwright Test to run Unit Tests
I have used Playwright for a couple of my projects so far, and I had such a good experience that it got my E2E test automation tool of choice.
Recently, I started using Playwright Test, the test runner of Playwright (@playwright/test
) for unit tests also.
This means the test subject is executed directly in Node.js; this differs from the "usual" way of using Playwright to open a browser, navigate to a web page, and test that web page in the browser.
In this blog post, I want to
- describe what I like about Playwright and why I think it can be a good choice for unit testing
- outline things missing from Playwright compared to other test frameworks (and how to fill some of those gaps)
- suggest some minor tweaks to make Playwright more suitable for unit tests
Why to use Playwright Test for unit tests #
I had used "traditional" test runners before, mainly Jest, but also Mocha a few years ago.
I think there are some advantages of using Playwright for unit tests:
-
Same tool for E2E tests and unit tests: One tool less in your setup means one tool less to learn, configure, update, etc.
-
Out-of-the-box support for TypeScript: Playwright Test supports TypeScript out-of-the-box.
For all other test runners I am aware of you have to configure some transform (Babel,ts-jest
, etc.), or run the test runner withts-node
, or compile the test cases before running them (see e.g. Typescript" of the Jest docs). -
Access to features unique to Playwright: Playwright has some very useful features, e.g.:
-
allow to prepare resources per test case on an "opt-in" basis.
This is in my opinion much better than thebeforeEach
/afterEach
fiddling you have to do with other test runners.// base.ts import { test as base } from '@playwright/test'; import * as sinon from 'sinon'; export const test = base.extend<{ fakeClock: sinon.SinonFakeTimers }>({ fakeClock: [ async ({}, use) => { const clock = sinon.useFakeTimers(); await use(clock); clock.restore(); }, { scope: 'test' }, ], }); // FILE: bar.spec.ts import { test } from './base'; test('with real clock', ({}) => { /* * this test case does not use the "fakeClock" fixture, * thus the fixture does not get set up */ }); test('with fake clock', ({ fakeClock }) => { /* * fake timers will get set up for this test case * because "fakeClock" fixture is used */ });
-
allow to run "the same or different tests in multiple configurations".
For example a while ago I had to make sure that some client/server interaction based on works with both WebSockets and HTTP long-polling.
Setting up two Playwright projects with different project parameters allowed me to run the same tests with two configurations, once using WebSockets and once using HTTP long-polling:// playwright.config.ts import { PlaywrightTestConfig } from '@playwright/test'; interface TestOptions { transport: 'websocket' | 'polling'; } const config: PlaywrightTestConfig<TestOptions> = { projects: [ { name: 'using transport: websocket', use: { transport: 'websocket' }, }, { name: 'using transport: polling', use: { transport: 'polling' }, }, ], }; export default config; // FILE: bar.spec.ts import { test } from '@playwright/test'; test('socket.io connection', ({ transport }) => { /* * this test case will get run twice, * with "transport" set once to "websocket" and once to "polling" */ initializeSocket(transport); // ... });
-
-
Development funded by a big company: Playwright is a product of Microsoft and has been maintained very actively over the last couple of years.
-
No use of globals: Playwright does not use any globals for its test runner. You import from
@playwright/test
just what you need in your test files:// FILE bar.spec.ts import { expect, test } from '@playwright/test'; test.describe('describe title', () => { test('test title', ({}) => { expect(1 + 1).toEqual(2); }); });
Compare that to existing test runners like Jest and Mocha (and Cypress, using Mocha under-the-hood). Those test runners set up their test functions as globals (
it
,expect
,describe
,beforeEach
etc.).
In TypeScript projects, as soon as you install any two of these test libraries you end up with TypeScript compilation errors, since the types of those functions are usually not compatible with each other. There are a couple of workarounds to avoid those compilation errors, for example fiddling with (multiple)tsconfig.json
files such that types of these libraries are excluded.
Cypress even has a guide dedicated to this problem ().Having the same import statements from
@playwright/test
in every test file might be some boilerplate, but I think the reduced friction (by not having any globals) justifies that boilerplate.
Newer test runners likevitest
also do not set globals anymore.
Things missing from Playwright (and how to fill the gaps) #
Compared to Jest, there are some things missing from Playwright Test which are often needed when running unit tests.
Most of those things can be substituted by using libraries of the Node.js ecosystem.
Take a look at example repository I made to see how to set up those things!
-
For Mocks & Spies use
sinon
orjest-mock
(the mocking solution shipped with Jest). -
For Fake timers use
sinon
(code changes). -
Code coverage just needs a simple combination of the command-line interface
nyc
of Istanbul (a code coverage tool for JavaScript) andsource-map-support
(code changes). -
There is experimental support for Watch mode at the time of writing, see .
Set environment variablePWTEST_WATCH=1
when running Playwright. -
Some test frameworks provide a way to mock CommonJS/ECMAScript modules.
Module mocking allows to replace the "thing" returned byrequire
/import
statements by some fake instance.Unfortunately, this is not possible in Playwright. Although there are some Node.js libraries that allow module mocking (like
testdouble
), they don't work because Playwright does some of the module loading itself ().
If you want to get module mocking support in Playwright, you can upvote this feature request: .This is the biggest downside of using Playwright for unit tests in my opinion.
For now, if you want to test modules isolated from others you probably have to adapt the codebase to use some kind of dependency injection. Be it that dependencies are just passed as arguments when calling functions, or using a dependency injection solution like IoC containers (which I have used very successfully in the past).
One last thing #
Playwright Test has a method expect(...).toMatchSnapshot(...)
which can perform snapshot-comparisons on screenshots but also textual and binary data.
Playwright will append a platform-specific suffix (like linux
) to the name of the file the snapshot is stored in.
This makes sense for screenshots (since screenshots taken on different platforms will deviate slightly, for example because of text rendering), but it rarely makes sense in case of textual/binary data.
The platform-specific suffix can be removed by setting the configuration parameter:
import { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
snapshotPathTemplate: '{testDir}/{testFilePath}-snapshots/{arg}{ext}',
};
export default config;