Skip to main content

Testing Setup: Vitest & Playwright in Boilerplates 2026

·StarterPick Team
Share:

TL;DR

  • Vitest is the 2026 standard for unit and component tests: Jest-compatible API, native ESM, fast watch mode, and Vite-native.
  • Playwright is the standard for E2E tests: cross-browser, reliable, first-class TypeScript support, and excellent Next.js integration.
  • React Testing Library for component interaction tests—test behavior, not implementation.
  • Most boilerplates skip testing setup entirely—adding it takes 2-4 hours and is worth every minute.
  • The critical path for SaaS: test auth flows, billing webhooks, and critical user paths in E2E. Unit test business logic.
  • CI: run unit tests on every push, E2E tests on PRs and before deploy.

Key Takeaways

  • Vitest and Jest share the same API—migration is straightforward if you're coming from Jest.
  • Playwright is more reliable than Cypress for modern Next.js apps: better async handling, no iframe restrictions, native ESM.
  • Testing Library's philosophy: query the DOM the way users would (by role, label text, display value)—not by CSS classes or implementation details.
  • MSW (Mock Service Worker) for mocking fetch requests in tests—works in both browser and Node.js environments.
  • Testing Server Actions and Route Handlers requires specific setup—Playwright's API testing feature handles these well.
  • Don't test implementation details. A test that breaks when you rename a variable is worthless.

Why Testing Setup Belongs in Your Boilerplate

There is a widely-held belief that testing infrastructure is something you add once the project is underway — once the core features are built, once the team is settled, once there is enough code to make the configuration worthwhile. This belief is wrong, and the cost of acting on it is steep.

The most immediate cost is technical. Retrofitting a test suite into an untested codebase is far harder than configuring testing from day one. Mocking patterns differ significantly between frameworks — if you wrote your data-fetching code without considering testability, you will discover that Vitest and Jest mock modules differently, that your dependency injection story is nonexistent, and that half your components reach directly into global state in ways that make isolation difficult. Snapshot files accumulate rapidly once you start writing component tests after the fact, and they reflect the current (potentially incorrect) state of the UI rather than the intended behavior. CI timing changes dramatically when you bolt on E2E tests late: your 3-minute build pipeline becomes a 15-minute gauntlet because the test suite was never designed to run efficiently in parallel.

The psychological cost is equally real and less discussed. Developers write tests when writing tests is easy. When the setup is zero friction — when npm test just works, when sample tests already exist that you can copy and adapt, when the CI pipeline already runs tests and makes failures visible — developers will write tests as a natural part of their workflow. When writing a test requires a 45-minute detour into configuration files and Stack Overflow, developers will not write tests. The boilerplate is not just shipping configuration files; it is shipping a culture of testing by default.

This is why boilerplate-included testing with sample test patterns is more valuable than just the configuration. A blank vitest.config.ts tells a developer that tests are supported. A vitest.config.ts paired with working examples of a unit test for business logic, a component test for a UI element, and an E2E test for the signup flow tells a developer exactly how to write the next test without having to figure out the conventions from scratch. That difference in friction determines whether testing actually happens.

The Testing Pyramid for Next.js

The testing pyramid is a well-established concept, but it has specific implications for Next.js applications that are worth spelling out concretely.

At the base of the pyramid are unit tests, handled by Vitest in this stack. Unit tests exercise individual functions in isolation — your subscription calculation logic, your date formatting utilities, your input validation functions, your price formatting helpers. These tests run in milliseconds, require no browser, no database, and no network. They are the most valuable tests per unit of effort because they run fast enough to serve as a tight feedback loop during development. The rule: anything that is pure logic with inputs and outputs should have unit tests.

The middle layer is integration tests, which in Next.js means React Testing Library combined with Vitest. Integration tests render React components in a simulated DOM (jsdom) and verify that the component tree behaves correctly when users interact with it. These tests exercise the interface between your component code and its dependencies — does the pricing card show the right button for each subscription state? Does the form show validation errors correctly? Does the modal dismiss when the user presses Escape? Integration tests catch a category of bugs that unit tests miss: the bugs that live in the wiring between your logic and your UI.

At the top of the pyramid are E2E tests, handled by Playwright. E2E tests run a real browser against a real running instance of your application. They are the slowest and most expensive tests to write and maintain, but they are the only tests that catch the full class of integration bugs — the ones where every individual piece works correctly but the system fails because two pieces don't fit together as expected. For a SaaS application, E2E tests should cover the "money paths": signup, login, subscription upgrade, and core product functionality. A user who cannot sign up or pay is a user you've lost.

Each layer of the pyramid catches different bugs. A unit test verifies that your proration calculation produces the correct number. An integration test verifies that the pricing card calls the calculation correctly and displays the result in the UI. An E2E test verifies that clicking "Upgrade" in the browser takes the user through the complete checkout flow. All three layers are necessary; none substitutes for the others.


React Testing Library: The Right Abstraction for Component Tests

React Testing Library is built around a single guiding principle: your tests should resemble how users use your software. This sounds obvious until you consider what it rules out.

The old approach to component testing — the approach that Testing Library was explicitly designed to replace — was to test components by inspecting their internal state and structure. You would mount a component, find elements by CSS selector or component type, and then assert on things like wrapper.state('isOpen') or wrapper.find(Button).props().onClick. This style of testing was popular with Enzyme, and it was deeply problematic. Tests written this way were tightly coupled to implementation details: if you renamed a variable, extracted a subcomponent, or changed a CSS class name, your tests would break even though the component's behavior was unchanged. The tests became a maintenance burden rather than a safety net.

Testing Library's approach is to query the DOM the same way a user would perceive it. You find elements by their accessible role (getByRole('button', { name: /upgrade/i })), by their label text (getByLabelText('Email')), by their display value (getByDisplayValue('user@example.com')), or by visible text (getByText(/current plan/i)). This approach has a crucial property: it remains valid as long as the component's behavior is correct. You can refactor the internals freely as long as the user-facing behavior is preserved, and your tests will continue to pass.

The most common beginner mistake with Testing Library is testing implementation details despite having adopted the library. This usually takes the form of testing state variables directly, asserting on component props, or using getByTestId as a crutch for elements that should be findable by accessible role. The data-testid attribute should be a last resort, used only for elements that genuinely have no accessible role or visible text to query by. If you find yourself adding data-testid attributes to every element, you have recreated the coupling problem you were trying to escape.

The user-event Library

The userEvent library from the Testing Library project is essential for component tests that exercise interactivity. The older fireEvent API dispatches individual DOM events — a click, a change, a keydown — but it does not simulate the complete sequence of events that a real user interaction produces. A user clicking a button actually triggers pointerover, pointerenter, mouseover, mouseenter, pointermove, mousemove, pointerdown, mousedown, focus, pointerup, mouseup, click. fireEvent.click() only dispatches the click event.

This matters for testing components that respond to the full interaction sequence — drag-and-drop handlers, hover states, focus management, and any component that uses onPointerDown or onMouseDown. Use userEvent.click() rather than fireEvent.click() for more realistic simulations.

import userEvent from "@testing-library/user-event";

// userEvent requires setup for v14+ to configure options
const user = userEvent.setup();

test("opens dropdown on click", async () => {
  render(<Dropdown options={["Option A", "Option B"]} />);
  await user.click(screen.getByRole("button", { name: /select option/i }));
  expect(screen.getByRole("listbox")).toBeVisible();
});

Testing Async Patterns and Server Components

Next.js App Router introduces a distinction that matters for testing: Server Components and Client Components have different testing strategies. Server Components are async React components that fetch data and render on the server — they cannot use hooks, event handlers, or browser APIs. You cannot render Server Components in jsdom with Testing Library the same way you render Client Components.

The practical approach for testing Server Component logic is to extract it. The data-fetching and business logic that lives in a Server Component should be in a separate function that you can unit test directly. The Server Component itself becomes a thin wrapper that calls that function and renders the result. You test the function with Vitest unit tests; you test the rendered output end-to-end with Playwright.

Client Components — anything with "use client" — render normally with Testing Library and jsdom.

Testing Server Actions requires mocking the database layer. The pattern is to mock your ORM module (Drizzle, Prisma) with Vitest's vi.mock() and call the action directly with a FormData object. Next.js navigation hooks (useRouter, usePathname, redirect) need to be mocked in your Vitest setup file, which the configuration in this article handles.


Playwright for SaaS: Beyond Basic E2E Tests

The introductory Playwright examples in most tutorials show simple navigation tests: go to a page, check a heading, done. SaaS applications have more demanding testing requirements, and Playwright has specific features designed to address them.

Authenticated Test Setup with storageState

The most important Playwright optimization for a SaaS application is eliminating redundant authentication in every test. If every test starts by filling in a login form, navigating to a dashboard, and waiting for the authenticated state to load, your test suite will be dramatically slower than it needs to be and fragile in a specific way: any bug in the login flow will cause every test to fail, making it hard to identify which tests are actually testing the right things.

Playwright's storageState feature solves this. You write a one-time setup script that authenticates a test user and saves the browser storage state (cookies, localStorage) to a file. All subsequent tests that need an authenticated browser load that saved state instead of re-authenticating. Authentication happens once per test run, not once per test.

// e2e/auth.setup.ts
import { test as setup } from "@playwright/test";
import path from "path";

const authFile = path.join(__dirname, "../.playwright/.auth/user.json");

setup("authenticate", async ({ page }) => {
  await page.goto("/auth/login");
  await page.getByLabel("Email").fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.waitForURL(/dashboard/);
  await page.context().storageState({ path: authFile });
});
// playwright.config.ts (add to projects)
projects: [
  {
    name: "setup",
    testMatch: /.*\.setup\.ts/,
  },
  {
    name: "authenticated",
    testMatch: /.*\.spec\.ts/,
    dependencies: ["setup"],
    use: {
      storageState: ".playwright/.auth/user.json",
    },
  },
],

Multi-Role Testing

SaaS applications typically have multiple user roles — admin users who can manage team members and billing, regular users who can use the product, and sometimes unauthenticated users who can only see the marketing site. Testing across these roles requires separate auth state files for each role and a deliberate test organization strategy.

The convention that works well is to create separate setup files for each role (admin.setup.ts, user.setup.ts) that authenticate as users with the appropriate permissions, then create corresponding Playwright project configurations that use each auth state. Tests for admin-only features use the admin project; tests for regular user flows use the user project.

Testing Stripe Checkout in Test Mode

Stripe's test mode provides a complete simulation of the payment flow, including test card numbers that trigger specific behaviors (successful payment, declined card, card requiring authentication). Testing the checkout flow with Playwright against Stripe's test environment is the only reliable way to verify that your billing integration works correctly before users encounter it.

The key setup detail: your test environment must use Stripe test mode keys (sk_test_...), and your Playwright tests must handle the redirect to checkout.stripe.com. Playwright can follow redirects to external domains by default. For testing the post-payment redirect back to your application, ensure your Stripe checkout session's success_url uses a predictable URL that Playwright can assert on.

The Page Object Model

As your E2E test suite grows beyond a dozen tests, you will accumulate duplicated page interaction code. The Page Object Model (POM) pattern addresses this by encapsulating the interactions with each page or major UI section into a dedicated class. Instead of each test knowing how to fill in the login form, every test uses loginPage.login(email, password). When the login form changes, you update one class rather than every test that logs in.

// e2e/pages/billing.page.ts
import { Page, expect } from "@playwright/test";

export class BillingPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto("/billing");
    await expect(this.page.getByRole("heading", { name: /billing/i })).toBeVisible();
  }

  async clickUpgradeToPro() {
    await this.page.getByRole("button", { name: /upgrade to pro/i }).click();
  }

  async getCurrentPlan() {
    return this.page.getByTestId("current-plan-badge").textContent();
  }
}

Playwright in CI: Docker and Parallelization

Playwright requires browser binaries that are not available in most default GitHub Actions environments. The recommended approach is to cache the browser installation between runs using the cache action with a key based on the Playwright version.

- name: Cache Playwright browsers
  uses: actions/cache@v4
  id: playwright-cache
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ hashFiles('package-lock.json') }}

- name: Install Playwright browsers
  if: steps.playwright-cache.outputs.cache-hit != 'true'
  run: npx playwright install --with-deps chromium

Playwright supports running tests in parallel across multiple workers. In CI, the default is one worker to avoid resource contention, but you can increase this based on your runner's CPU count. GitHub Actions' ubuntu-latest runners have 2 vCPUs, so workers: 2 is safe.

When Playwright tests fail in CI, the trace viewer is the fastest debugging tool available. Configure trace: "on-first-retry" in your Playwright config to record a trace for any test that fails and gets retried. Upload the trace as a CI artifact, download it locally, and open it with npx playwright show-trace trace.zip to see a step-by-step replay of exactly what happened.


Measuring Coverage and Setting Sensible Thresholds

Test coverage is the most misunderstood metric in software testing. It measures which lines, branches, functions, and statements were executed during your test run — not whether your tests are actually good. A test that calls every function but never asserts on the output will produce high coverage numbers while providing almost no protection against regressions.

This is why coverage targets should be treated as a floor, not a goal. The question is not "how do we get to 80%?" but rather "what code is most important to have covered, and do we have tests that would catch a regression there?"

What Coverage Measures

Vitest's built-in coverage, powered by c8 (V8's native coverage) or istanbul, tracks four metrics:

Line coverage counts whether each line of code was executed. This is the broadest metric and the easiest to game: a single test that calls a function exercises all of its lines even if it never checks what the function returns.

Branch coverage tracks whether both sides of every conditional (if, ternary, switch case) were evaluated. This is more meaningful than line coverage because it catches cases like: "the happy path always works, but the error path has never been tested."

Function coverage tracks whether every function was called at all. Low function coverage is a clear signal that large sections of your codebase have no tests.

Statement coverage is similar to line coverage but counts individual statements rather than lines (a line with three chained method calls counts as three statements).

Practical Thresholds for SaaS Boilerplates

A pragmatic coverage strategy for a SaaS application distinguishes between code by type rather than applying a single threshold to everything.

Utility functions and business logic — the subscription calculation functions, the pricing tier logic, the email validation utilities, the date formatting helpers — should have high coverage, targeting 80% or above. These functions are the most testable code in your application and the most dangerous to break silently. A bug in subscription proration logic has direct financial consequences.

UI components are harder to test exhaustively because they have many visual states, and some of those states are difficult to trigger programmatically. A 60% branch coverage threshold for components is a reasonable baseline. Focus your component tests on behavior rather than appearance: does the component show the right elements in each state, does it call the right handlers when the user interacts with it.

E2E coverage should focus on the "money paths" — the flows that represent core value delivery and revenue. For a SaaS application, this means: user can sign up, user can log in, user can initiate a subscription upgrade, user can use the core product feature that they are paying for, and user can access their account settings. Every one of these flows should have an E2E test that runs on every PR.

Coverage Numbers vs Regression Prevention

The failure mode of coverage-driven testing is optimizing for the metric. A developer who needs to increase coverage from 72% to 80% will write the tests that are easiest to write, not the tests that are most valuable to write. Simple functions get redundant tests; complex edge cases get ignored.

The better framing: use coverage as a detection tool, not a target. Run coverage reports to find which code has no tests at all — areas of zero coverage are almost always a gap worth addressing. Use branch coverage to find conditional logic that has only been tested in one direction. But do not make coverage threshold compliance the goal; make regression prevention the goal.


Setting Up Vitest

Installation

npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom

Configuration

// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "path";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: "./src/test/setup.ts",
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      exclude: [
        "node_modules/",
        "src/test/",
        "**/*.d.ts",
        "**/*.config.*",
        "**/coverage/**",
      ],
    },
  },
  resolve: {
    alias: {
      "@": resolve(__dirname, "./src"),
    },
  },
});

Setup File

// src/test/setup.ts
import "@testing-library/jest-dom";
import { vi } from "vitest";

// Mock Next.js navigation
vi.mock("next/navigation", () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    back: vi.fn(),
    prefetch: vi.fn(),
  }),
  usePathname: () => "/",
  useSearchParams: () => new URLSearchParams(),
  redirect: vi.fn(),
}));

// Mock Next.js headers
vi.mock("next/headers", () => ({
  cookies: () => ({
    get: vi.fn(),
    set: vi.fn(),
    delete: vi.fn(),
  }),
  headers: () => new Headers(),
}));

Writing Tests

Unit test (business logic):

// src/lib/__tests__/subscription.test.ts
import { describe, it, expect } from "vitest";
import { calculateProration, isSubscriptionActive } from "../subscription";

describe("subscription utils", () => {
  it("calculates prorated amount correctly", () => {
    const result = calculateProration({
      currentPlan: "basic",
      newPlan: "pro",
      daysRemaining: 15,
    });
    expect(result).toBe(12.50); // half-month upgrade
  });

  it("returns false for cancelled subscription", () => {
    expect(isSubscriptionActive("canceled")).toBe(false);
    expect(isSubscriptionActive("active")).toBe(true);
    expect(isSubscriptionActive("trialing")).toBe(true);
  });
});

Component test:

// src/components/__tests__/pricing-card.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PricingCard } from "../pricing-card";

describe("PricingCard", () => {
  it("shows upgrade button for free plan users", () => {
    render(<PricingCard plan="pro" currentPlan="free" />);
    expect(screen.getByRole("button", { name: /upgrade/i })).toBeInTheDocument();
  });

  it("shows current plan badge for active plan", () => {
    render(<PricingCard plan="pro" currentPlan="pro" />);
    expect(screen.getByText(/current plan/i)).toBeInTheDocument();
    expect(screen.queryByRole("button", { name: /upgrade/i })).not.toBeInTheDocument();
  });

  it("calls onUpgrade when upgrade button clicked", async () => {
    const onUpgrade = vi.fn();
    render(<PricingCard plan="pro" currentPlan="free" onUpgrade={onUpgrade} />);

    await userEvent.click(screen.getByRole("button", { name: /upgrade/i }));
    expect(onUpgrade).toHaveBeenCalledWith("pro");
  });
});

Server Action test:

// src/actions/__tests__/create-project.test.ts
import { describe, it, expect, vi } from "vitest";
import { createProject } from "../create-project";

vi.mock("@/db", () => ({
  db: {
    insert: vi.fn().mockReturnValue({
      values: vi.fn().mockReturnValue({
        returning: vi.fn().mockResolvedValue([{ id: "project-1", name: "Test" }]),
      }),
    }),
  },
}));

describe("createProject", () => {
  it("creates a project with valid input", async () => {
    const formData = new FormData();
    formData.set("name", "Test Project");

    const result = await createProject(formData);
    expect(result.success).toBe(true);
    expect(result.project?.name).toBe("Test Project");
  });

  it("returns error for empty name", async () => {
    const formData = new FormData();
    formData.set("name", "");

    const result = await createProject(formData);
    expect(result.success).toBe(false);
    expect(result.error).toContain("name");
  });
});

MSW for API Mocking

npm install -D msw
// src/test/mocks/handlers.ts
import { http, HttpResponse } from "msw";

export const handlers = [
  http.post("/api/stripe/create-checkout", () => {
    return HttpResponse.json({ url: "https://checkout.stripe.com/test" });
  }),

  http.get("/api/user/subscription", () => {
    return HttpResponse.json({
      plan: "pro",
      status: "active",
      currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
    });
  }),
];

// src/test/setup.ts (add to existing setup)
import { setupServer } from "msw/node";
import { handlers } from "./mocks/handlers";

const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Setting Up Playwright

Installation

npm install -D @playwright/test
npx playwright install --with-deps chromium

Configuration

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: "html",

  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
  },

  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    // Add for cross-browser: firefox, webkit
  ],

  webServer: {
    command: "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
  },
});

Writing E2E Tests

Authentication flow:

// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Authentication", () => {
  test("user can sign up with email and password", async ({ page }) => {
    await page.goto("/auth/signup");

    await page.getByLabel("Email").fill("test@example.com");
    await page.getByLabel("Password").fill("SecurePass123!");
    await page.getByRole("button", { name: "Create account" }).click();

    // Should redirect to onboarding or dashboard
    await expect(page).toHaveURL(/\/(onboarding|dashboard)/);
    await expect(page.getByText("Welcome")).toBeVisible();
  });

  test("shows error for invalid credentials", async ({ page }) => {
    await page.goto("/auth/login");

    await page.getByLabel("Email").fill("wrong@example.com");
    await page.getByLabel("Password").fill("wrongpassword");
    await page.getByRole("button", { name: "Sign in" }).click();

    await expect(page.getByText(/invalid credentials/i)).toBeVisible();
  });
});

Billing flow:

// e2e/billing.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Billing", () => {
  test.beforeEach(async ({ page }) => {
    // Use Playwright's auth state persistence
    await page.goto("/auth/login");
    await page.getByLabel("Email").fill(process.env.TEST_USER_EMAIL!);
    await page.getByLabel("Password").fill(process.env.TEST_USER_PASSWORD!);
    await page.getByRole("button", { name: "Sign in" }).click();
    await page.waitForURL(/dashboard/);
  });

  test("can initiate subscription upgrade", async ({ page }) => {
    await page.goto("/billing");

    await page.getByRole("button", { name: /upgrade to pro/i }).click();

    // Should redirect to Stripe checkout
    await page.waitForURL(/stripe\.com|checkout/);
  });
});

Page Object Model for reuse:

// e2e/fixtures/auth.fixture.ts
import { test as base } from "@playwright/test";
import { LoginPage } from "./pages/login.page";
import { DashboardPage } from "./pages/dashboard.page";

export const test = base.extend<{
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  authenticatedPage: void;
}>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await use(loginPage);
  },
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
  authenticatedPage: async ({ page }, use) => {
    // Reuse stored auth state (set up once, used in all tests)
    await use();
  },
});

CI Configuration

GitHub Actions

# .github/workflows/test.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm test -- --coverage
      - uses: codecov/codecov-action@v4

  e2e-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npm run build
      - run: npx playwright test
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
          NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Package.json Scripts

{
  "scripts": {
    "test": "vitest",
    "test:watch": "vitest --watch",
    "test:coverage": "vitest --coverage",
    "test:ui": "vitest --ui",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:report": "playwright show-report"
  }
}

For the CI/CD pipeline that runs these tests automatically, see CI/CD pipeline for SaaS boilerplates. For the TypeScript configuration that ensures test files are properly typed, see TypeScript config: boilerplate best practices. For the boilerplates that ship with testing pre-configured, see best Next.js boilerplates 2026.


Methodology

Testing patterns based on official Vitest and Playwright documentation, analysis of testing configurations in top Next.js boilerplates on GitHub, and React Testing Library best practices guide. CI integration examples from GitHub Actions documentation and observed patterns in production Next.js repositories.

The SaaS Boilerplate Matrix (Free PDF)

20+ SaaS starters compared: pricing, tech stack, auth, payments, and what you actually ship with. Updated monthly. Used by 150+ founders.

Join 150+ SaaS founders. Unsubscribe in one click.