Writing Integration Tests with Playwright

All versions of Nextbase Starter Kit come with Playwright installed. Playwright is a powerful end-to-end testing framework that allows you to write integration tests for your application.


Playwright is configured in the playwright.config.js file.

import { PlaywrightTestConfig, devices } from '@playwright/test';
import path from 'path';
// Use process.env.PORT by default and fallback to port 3000
const PORT = process.env.PORT || 3000;
// Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port
const baseURL = `http://localhost:${PORT}`;
// Reference: https://playwright.dev/docs/nextjs/v2/test-configuration
const config: PlaywrightTestConfig = {
  // Timeout per test
  timeout: 120 * 1000,
  // Test directory
  testDir: path.join(__dirname, 'e2e'),
  // If a test fails, retry it additional 2 times
  retries: 2,
  // Artifacts folder where screenshots, videos, and traces are stored.
  outputDir: 'test-results/',
  // Run your local dev server before starting the tests:
  // https://playwright.dev/docs/nextjs/v2/test-advanced#launching-a-development-web-server-during-the-tests
  webServer: {
    command: 'cross-env NODE_ENV=test next dev',
    url: baseURL,
    timeout: 120 * 1000,
    reuseExistingServer: !process.env.CI,
  use: {
    // Use baseURL so to make navigations relative.
    // More information: https://playwright.dev/docs/nextjs/v2/api/class-testoptions#test-options-base-url
    // Retry a test if its failing with enabled tracing. This allows you to analyse the DOM, console logs, network traffic etc.
    // More information: https://playwright.dev/docs/nextjs/v2/trace-viewer
    trace: 'retry-with-trace',
    actionTimeout: 60 * 1000,
    // All available context options: https://playwright.dev/docs/nextjs/v2/api/class-browser#browser-new-context
    // contextOptions: {
    //   ignoreHTTPSErrors: true,
    // },
  projects: [
      name: 'with-auth',
      testMatch: 'auth/**/*.setup.ts',
      name: 'Logged In Users (Desktop Chrome)',
      testMatch: /.*user.spec.ts/,
      retries: 0,
      dependencies: ['with-auth'],
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      name: 'Logged Out Users (Desktop Chrome)',
      testIgnore: /.*user.spec.ts/,
      use: {
        ...devices['Desktop Chrome'],
  globalSetup: './playwright/global-setup.ts',
export default config;

As you can see, the configuration file is quite simple. The playwright projects are configured in this manner:

  1. Projects which setup authentication are configured in the with-auth project.
  2. Projects which test logged in users are configured in the Logged In Users (Desktop Chrome) project.
  3. Projects which test logged out users are configured in the Logged Out Users (Desktop Chrome) project.

The Logged In Users (Desktop Chrome) project is configured to use the with-auth project as a dependency. This means that the with-auth project will run first, and then the Logged In Users (Desktop Chrome) project will run. However, the tests themselves run in parallel in each project.

Running tests

Playwright runs in NODE_ENV=test mode. It also relies on .env.test for environment variables. Now, .env.test is configured to use local supabase which is setup by running pnpm supabase start command. The pnpm supabase start command sets up a local supabase instance using docker.

  1. The supabase api url is available at http://localhost:54321/ (This is the most important url)
  2. The supabase db url is at http://localhost:54322/
  3. The supabase studio is available at http://localhost:54323/ (You can use this to use the UI interface )
  4. The supabase inbucket instance is available at http://localhost:54324/ (We use this for testing emails)

Now that supabase is running locally. All we need to do is to run playwright.

Running playwright with UI

pnpm test:e2e --ui

Running playwright in headless mode

pnpm test:e2e

Since playwright has been configured to start the local dev server, you don't need to run pnpm dev separately.

Writing tests

All the test cases are written in the e2e folder. The e2e folder is divided into two folders:

  1. Tests which require a user logged in need to be named test-case.user.spec.ts
  2. Tests which don't require a user logged in need to be named test-case.spec.ts

Create Organization Test case example

This is an example of a test case which requires a user to be logged in.

test('create organization works correctly', async ({ page }) => {
  // Start from the index page (the baseURL is set via the webServer in the playwright.config.ts)
  await page.goto('/dashboard');
  // click button with role combobox and data-name "organization-switcher"
  await page.click(
  // click button with text New Organization
  await page.click('button:has-text("New Organization")');
  // wait for form within a div role dialog to show up with data-testid "create-organization-form"
  const form = await page.waitForSelector(
    'div[role="dialog"] form[data-testid="create-organization-form"]',
  // find input with name "name" and type "Lorem Ipsum"
  const input = await form.waitForSelector('input[name="name"]');
  if (!input) {
    throw new Error('input not found');
  await input.fill('Lorem Ipsum');
  // click on button with text "Create Organization"
  const submitButton = await form.waitForSelector(
    'button:has-text("Create Organization")',
  if (!submitButton) {
    throw new Error('submitButton not found');
  await submitButton.click();
  // wait for url to change to /organization/<organizationUUID>
  let organizationId;
  await page.waitForURL((url) => {
    const match = url
    if (match) {
      organizationId = match[1];
      return true;
    return false;
  if (!organizationId) {
    throw new Error('organizationId creation failed');
  // go to /organization/<organizationUUID>/settings
  const settingsPageURL = `/organization/${organizationId}/settings`;
  await page.goto(settingsPageURL);
  // wait for data-testid "edit-organization-title-form"
  const editOrganizationTitleForm = await page.waitForSelector(
  // fill input with name "organization-title"
  const titleInput = await editOrganizationTitleForm.waitForSelector(
  if (!titleInput) {
    throw new Error('titleInput not found');
  await titleInput.fill('Lorem Ipsum 2');
  // click on button with text "Update"
  const updateButton = await editOrganizationTitleForm.waitForSelector(
  if (!updateButton) {
    throw new Error('updateButton not found');
  await updateButton.click();
  // wait for text "Organization title updated!"
  await page.waitForSelector('text=Organization title updated!');