Testing with Next.js 15, Playwright, MSW, and Supabase

I was previously using Storybook as part of my testing suite, but got tired of how fragile it was when it came to package updates leading to more time fixing it than creating things. You certainly can't "Develop durable user interfaces", as they tout, if the framework that is meant to facilitate that isn't itself durable, and while it's nice to be able to see my components, I really just need reliable interaction and e2e testing.

Things that prevented me from setting up tools like Playwright or Cypress on my stack sooner were authenticated sessions, mocking of both libraries and APIs,1 and more recently, server-side actions. Playwright doesn't support mocking of libraries, but now Next.js has an experimental feature supporting Playwright and MSW and that gets me closer to testing the way I need.

You can read more about it here: Experimental test mode for Playwright

The documentation does precisely what it advertises, but in case that page disappears or is relocated, this guide is what worked for me.

Playwright and MSW support

Step 1. Install Playwright and MSW

I followed the installation guides for both Playwright and MSW because I installed them before I found the Next.js test mode feature. This works fine and isn't much more involved.

Playwright: Installation
MSW: Getting started

Otherwise, this is effectively what the Next.js documentation suggests:

npm install -D @playwright/test msw

Step 2. Update next.config.js

module.exports = {
  experimental: {
    testProxy: true,
  },
};

Step 3. Update playwright.config.ts

import { defineConfig } from 'next/experimental/testmode/playwright';

export default defineConfig({
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
  },
});

If you installed Playwright the Playwright way, you will need to import defineConfig from test mode.

Step 4. Write your tests

Option 1. Client-side support only

⚠︎
Update: I was previously recommending the msw integration but didn't realise it wasn't intercepting API calls from server actions. I have updated the docs to reflect this and include the method that does work for server actions below.
If you only need client-side intercepts, you can use the MSW integration. This example is copied directly from the doc:

import {
  test,
  expect,
  http,
  HttpResponse,
  passthrough,
} from 'next/experimental/testmode/playwright/msw'

test.use({
  mswHandlers: [
    [
      http.get('http://my-db/product/shoe', () => {
        return HttpResponse.json({
          title: 'A shoe',
        })
      }),
      // allow all non-mocked routes to pass through
      http.all('*', () => {
        return passthrough()
      }),
    ],
    { scope: 'test' },
  ],
})

test('/product/shoe', async ({ page, msw }) => {
  msw.use(
    http.get('http://my-db/product/boot', () => {
      return HttpResponse.json({
        title: 'A boot',
      })
    })
  )

  await page.goto('/product/boot')

  await expect(page.locator('body')).toHaveText(/Boot/)
})

Note: Outside of test() use test.use({ mswHandlers: [[], {scope: 'test'}] }), and inside use msw.use. Anything defined in msw.use is added to the handlers defined by test.use but I haven't tested overrides.

For reusability, each of my msw handlers are defined in another file, some as functions to allow for response customisation.

Option 2. Client and server action support

If you have API calls in server actions, the msw instrumentation will not work, but the next instrumentation does. This example is copied directly from the doc:

import { test, expect } from 'next/experimental/testmode/playwright'

test('/product/shoe', async ({ page, next }) => {
  // NOTE: `next.onFetch` only intercepts external `fetch` requests (for both client and server).
  // For example, if you `fetch` a relative URL (e.g. `/api/hello`) from the client
  // that's handled by a Next.js route handler (e.g. `app/api/hello/route.ts`),
  // it won't be intercepted.
  next.onFetch((request) => {
    if (request.url === 'http://my-db/product/shoe') {
      return new Response(
        JSON.stringify({
          title: 'A shoe',
        }),
        {
          headers: {
            'Content-Type': 'application/json',
          },
        }
      )
    }
    return 'abort'
  })

  await page.goto('/product/shoe')

  await expect(page.locator('body')).toHaveText(/Shoe/)
})

One disadvantage of this method is that unlike msw, there isn't an equivalent of mswHandlers for common handlers in a test group. Another is that if you already have msw implemented, like me, you have to rewrite your handlers.

To keep things DRY-ish, I create a wrapper for the next.onFetch so I can more conveniently reuse and pass multiple handlers:

The handler structure looks like this:

And are consts or functions that return an object of the structure:

{
  method: string;
  route: RegExp;
  response: {data: any; options?: any}
}

The benefit of this structure is that if I need to use msw anywhere else, I just need to create a wrapper for msw and as well and these handlers can be reused with the same test data and fixtures.

An example of how I'd use these together looks like:

test.describe('test group', () => {  
  const commonHandlers = [userRemote(user)];
  
  test('fetch todos', async ({page, next}) => {  
    mockFetch(next, [  
      todoRemote(user),
      ...commonHandlers  
    ]);  
  
    // Assertions
  });

  test('fetch todos failure', async ({page, next}) => {  
    mockFetch(next, [  
      todoRemoteFailure,
      ...commonHandlers  
    ]);  
  
    // Assertions
  });
});

Supabase auth support

Playwright has documentation for handling Authentication. It's straight forward and does exactly what it's meant to, but requires you to authenticate using an account in your backend to get the session. If this is what you want, then you're sorted.

But in keeping with being able to intercept requests — you can mock many of Supabase's auth-related calls — I've modified those directions to support mocking sessions as well.

Step 1. Generate your own Supabase access_token and Session

⚠︎
These generated tokens and sessions will not allow you to perform Supabase operations. They are for testing purposes only. For un-mocked operations, use the form auth method described in the Playwright Authentication documentation instead.
To set up, we need convenience functions to generate our own Supabase sessions for testing. There's a very handy article on Generating Supabase JWT/access token manually by Joonas Mertanan that I've based these on.

For reference, the encoded object in createSupabaseCookie is roughly a sample Supabase Session object, and the user inside that is roughly a sample Supabase User object. Fixtures could work here.

Step 2. Create an auth setup action in Playwright

Playwright uses BrowserContext storageState for handling authentication. Their recommended method manually logs in with a form, then writes the state to a file that can be loaded later.

As per the Playwright Authentication documentation, create a playwright/.auth directory and add it to .gitignore.

mkdir -p playwright/.auth  
echo $'\nplaywright/.auth' >> .gitignore

The playwright directory should be in your Next.js source root or at the same level as your src/.

Using the convenience functions in Step 1, we can generate our own mocked state without a manual login process.

Create playwright/auth.setup.ts to set up the authenticated state with our convenience functions and write that state to playwright/.auth/user.json. You can set up multiple session types (including manual login) by writing each to a different .auth/ file.

Step 3. Update playwright.config.ts

Again from the Playwright Authentication documentation, add a setup project that looks for any setup actions including the auth.setup.ts that we just created.

export default defineConfig({
  projects: [
    // Setup project
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        // Use prepared auth state.
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

If you want your other projects to be set up in the same way, add dependencies: ['setup'], to each of their definitions and the auth setup action will be run every time you run tests for that project.

If you want your projects to have a default authentication state, set the use.storageState property, otherwise, omit this and load them as needed in the tests.

Note: If you're using Playwright UI, you will need to manually invoke auth.setup.ts in the interface periodically to set up the authentication state.

Once you run the setup action the first time, your directory structure will look like this:

playwright/
├─ .auth/
│  ├─ user.json
├─ auth.setup.ts

Step 4. Write your tests

As an example of how I've used this setup, when I want to test an authenticated state, I test.use the storageState created by auth.setup.ts which allows Supabase to recognise an authenticated session.

test.describe('Authenticated', () => {  
  test.use({  
    storageState: 'playwright/.auth/user.json',  
    mswHandlers: [[
      http.get(/\/auth\/v1\/user/, () => HttpResponse.json(myUser))
    ], {scope: 'test'}],  
  });  
  
  test('my test', async ({page, msw}) => {
    msw.use(...)
  });  
});

If I also fetch the session user in my page, ie. supabase.auth.getUser() in the server or the client, this calls the Supabase endpoint /auth/v1/user/ which I can mock with MSW in mswHandlers to return a test Supabase User object.

Any other handlers needed in individual tests can be applied with msw.use.


Visual testing support

While not the same as what Storybook offers visually, Playwright offers aria snapshots for accessible structure as well as image screenshots for full pages or elements.

For more on snapshot testing, check out this Advanced Snapshot Testing in Playwright article by Mike Stop Continues.


Footnotes

  1. I know there are those against doing this, but that discussion is not the purpose of this guide.

Published February 14, 2025