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; this differs from the "usual" way of using Playwright to open a browser, navigate to a web page, and test that web page.

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 with ts-node, or compile the test cases before running them (see e.g. "Using Typescript" of the Jest docs).

  • Access to features unique to Playwright: Playwright has some very useful features, e.g.:

    • "fixtures" allow to prepare resources per test case on an "opt-in" basis.
      This is in my opinion much better than the beforeEach / 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
         */
      });
      
    • "projects" 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 socket.io 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 (docs.cypress.io/guides/tooling/typescript-support#Clashing-types-with-Jest).

    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 like vitest 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 the example repository I made to see how to set up those things!

  • For Mocks & Spies use sinon or jest-mock (the mocking solution shipped with Jest).

  • For Fake timers use sinon (See code changes).

  • Code coverage just needs a simple combination of the command-line interface nyc of Istanbul (a code coverage tool for JavaScript) and source-map-support (See code changes).

  • Watch mode is not supported but "planned work" at the time of writing: playwright.dev/docs/test-components#planned-work.
    There are some workarounds in the related issue: github.com/microsoft/playwright/issues/7035.

  • Some test frameworks provide a way to mock CommonJS/ECMAScript modules.
    Module mocking allows to replace the "thing" returned by require/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 (github.com/microsoft/playwright/issues/14398).
    If you want to get module mocking support in Playwright, you can upvote this feature request: github.com/microsoft/playwright/issues/14572.

    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 NestJS 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.

At the time of writing, 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.

This is an open issue (github.com/microsoft/playwright/issues/11134). An easy workaround is to disable the suffix by setting up an "auto-fixture" and override testInfo.snapshotSuffix:

import { test as base } from '@playwright/test';

export const test = base.extend<{ _autoSnapshotSuffix: void }>({
  _autoSnapshotSuffix: [
    async ({}, use, testInfo) => {
      testInfo.snapshotSuffix = '';
      await use();
    },
    { auto: true },
  ],
});

Did you like this blog post?

Great, then let's keep in touch! Follow me on Twitter, I tweet about TypeScript, testing and web development in general - and of course about updates on my own blog posts.