Back to Blog

Testing strategies for Next.js SaaS applications: E2E, unit, and integration testing

Testing
Next.js
E2E Testing
Unit Testing
Integration Testing
Quality Assurance

Building a robust testing strategy is essential for maintaining quality and confidence in your Next.js SaaS application. This guide covers unit, integration, and E2E testing approaches, testing patterns for Next.js-specific features, and best practices for production-ready SaaS applications.

Testing pyramid for Next.js SaaS

A well-structured testing strategy follows the testing pyramid principle:

  • Unit tests - Fast, isolated tests for individual functions and components (70% of tests)
  • Integration tests - Test component interactions and API routes (20% of tests)
  • E2E tests - Full user flows in a real browser environment (10% of tests)

This distribution ensures fast feedback loops while maintaining confidence in critical user flows. Fastack's architecture supports all three testing levels with clear separation of concerns.

Unit testing with Vitest

Vitest is a fast, Vite-native test runner that works excellently with Next.js and TypeScript:

Setting up Vitest

Installation and configuration
# Install Vitest and testing utilities
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

Vitest provides Jest-compatible APIs with faster execution and better TypeScript support, making it ideal for Next.js projects.

Testing utility functions

Start with testing pure functions and utilities:

Example: Testing a utility function
// utils/formatCurrency.ts
export function formatCurrency(amount: number, currency = 'USD'): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(amount);
}

// utils/formatCurrency.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency } from './formatCurrency';

describe('formatCurrency', () => {
  it('formats USD currency correctly', () => {
    expect(formatCurrency(1000)).toBe('$1,000.00');
    expect(formatCurrency(99.99)).toBe('$99.99');
  });

  it('handles different currencies', () => {
    expect(formatCurrency(1000, 'EUR')).toBe('€1,000.00');
    expect(formatCurrency(1000, 'GBP')).toBe('£1,000.00');
  });

  it('handles zero and negative values', () => {
    expect(formatCurrency(0)).toBe('$0.00');
    expect(formatCurrency(-100)).toBe('-$100.00');
  });
});

Unit tests for utilities provide fast feedback and catch edge cases early. Fastack's shared packages in a monorepo structure make it easy to test utilities in isolation.

Testing React components

Test React components in isolation with React Testing Library:

Example: Testing a component
// components/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';

describe('Button', () => {
  it('renders with correct text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('calls onClick handler when clicked', async () => {
    const handleClick = vi.fn();
    const user = userEvent.setup();
    
    render(<Button onClick={handleClick}>Click me</Button>);
    await user.click(screen.getByRole('button'));
    
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

React Testing Library encourages testing user behavior rather than implementation details, leading to more maintainable tests.

Integration testing

Integration tests verify that multiple parts of your application work together correctly.

Testing API routes

Test Next.js API routes with proper request/response handling:

Example: Testing an API route
// app/api/users/route.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GET } from './route';
import { createMocks } from 'node-mocks-http';

describe('GET /api/users', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('returns user data for authenticated requests', async () => {
    const { req, res } = createMocks({
      method: 'GET',
      headers: {
        cookie: 'next-auth.session-token=valid-token',
      },
    });

    const response = await GET(req as any);
    const data = await response.json();

    expect(response.status).toBe(200);
    expect(data).toHaveProperty('users');
    expect(Array.isArray(data.users)).toBe(true);
  });

  it('returns 401 for unauthenticated requests', async () => {
    const { req } = createMocks({
      method: 'GET',
    });

    const response = await GET(req as any);
    expect(response.status).toBe(401);
  });
});

Integration tests for API routes verify authentication, authorization, and data flow end-to-end within the server context.

Testing with Prisma

Test database operations using a test database:

Example: Testing database operations
// lib/db.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient({
  datasources: {
    db: {
      url: process.env.TEST_DATABASE_URL,
    },
  },
});

describe('User operations', () => {
  beforeEach(async () => {
    // Clean up test data
    await prisma.user.deleteMany();
  });

  afterEach(async () => {
    await prisma.user.deleteMany();
  });

  it('creates a new user', async () => {
    const user = await prisma.user.create({
      data: {
        email: '[email protected]',
        name: 'Test User',
      },
    });

    expect(user).toHaveProperty('id');
    expect(user.email).toBe('[email protected]');
  });

  it('finds user by email', async () => {
    await prisma.user.create({
      data: {
        email: '[email protected]',
        name: 'Test User',
      },
    });

    const user = await prisma.user.findUnique({
      where: { email: '[email protected]' },
    });

    expect(user).not.toBeNull();
    expect(user?.email).toBe('[email protected]');
  });
});

Use a separate test database to avoid affecting development data. Fastack's Prisma setup in a shared package makes it easy to test database operations across the monorepo.

Testing server components

Test Next.js server components that fetch data:

Example: Testing a server component
// app/users/page.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import UsersPage from './page';
import { prisma } from '@/lib/prisma';

// Mock Prisma
vi.mock('@/lib/prisma', () => ({
  prisma: {
    user: {
      findMany: vi.fn(),
    },
  },
}));

describe('UsersPage', () => {
  it('renders user list', async () => {
    const mockUsers = [
      { id: '1', email: '[email protected]', name: 'User 1' },
      { id: '2', email: '[email protected]', name: 'User 2' },
    ];

    vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers);

    const component = await UsersPage();
    const { container } = render(component);

    expect(container).toHaveTextContent('User 1');
    expect(container).toHaveTextContent('User 2');
  });
});

Server components can be tested by mocking data sources and verifying the rendered output.

E2E testing with Playwright

End-to-end tests verify complete user flows in a real browser environment:

Setting up Playwright

Installation and configuration
# Install Playwright
npm install -D @playwright/test
npx playwright install

# 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'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Playwright provides excellent browser automation with support for multiple browsers and reliable waiting mechanisms.

Testing authentication flows

Test complete authentication workflows:

Example: E2E test for sign up flow
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test('user can sign up and access dashboard', async ({ page }) => {
    // Navigate to sign up page
    await page.goto('/sign-up');
    
    // Fill sign up form
    await page.fill('input[name="email"]', '[email protected]');
    await page.fill('input[name="password"]', 'SecurePassword123!');
    await page.fill('input[name="name"]', 'Test User');
    
    // Submit form
    await page.click('button[type="submit"]');
    
    // Wait for redirect to dashboard
    await page.waitForURL('/dashboard');
    
    // Verify user is logged in
    await expect(page.locator('text=Welcome, Test User')).toBeVisible();
  });

  test('user can sign in with existing credentials', async ({ page }) => {
    await page.goto('/sign-in');
    
    await page.fill('input[name="email"]', '[email protected]');
    await page.fill('input[name="password"]', 'Password123!');
    await page.click('button[type="submit"]');
    
    await page.waitForURL('/dashboard');
    await expect(page).toHaveURL('/dashboard');
  });
});

E2E tests for authentication verify the complete flow from UI interaction to database persistence, ensuring NextAuth.js integration works correctly.

Testing protected routes

Verify that route protection works correctly:

Example: Testing route protection
// e2e/protected-routes.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Protected Routes', () => {
  test('redirects unauthenticated users to sign in', async ({ page }) => {
    await page.goto('/dashboard');
    
    // Should redirect to sign in
    await expect(page).toHaveURL(//sign-in/);
  });

  test('allows authenticated users to access dashboard', async ({ page }) => {
    // Sign in first
    await page.goto('/sign-in');
    await page.fill('input[name="email"]', '[email protected]');
    await page.fill('input[name="password"]', 'Password123!');
    await page.click('button[type="submit"]');
    await page.waitForURL('/dashboard');
    
    // Now access protected route
    await page.goto('/dashboard');
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('h1')).toContainText('Dashboard');
  });
});

Testing protected routes ensures Next.js middleware and NextAuth.js session handling work correctly together.

Testing best practices

Follow these practices for maintainable and effective tests:

Test organization

  • Keep tests close to the code they test (co-location)
  • Use descriptive test names that explain what is being tested
  • Group related tests with describe blocks
  • Follow the AAA pattern: Arrange, Act, Assert

Test data management

  • Use test fixtures and factories for consistent test data
  • Clean up test data in beforeEach or afterEach hooks
  • Use a separate test database to avoid conflicts
  • Mock external services (email, payments) in integration tests

Performance and reliability

  • Keep unit tests fast (under 100ms each)
  • Use proper waiting strategies in E2E tests (avoid fixed timeouts)
  • Run tests in parallel when possible
  • Use test retries for flaky E2E tests

Testing Next.js-specific features

Next.js introduces unique testing challenges:

Server components

Server components are async by default. Test them by awaiting the component and mocking data sources:

Testing async server components
// Server components are async
const component = await ServerComponent();
const { container } = render(component);

// Mock data fetching
vi.mock('@/lib/api', () => ({
  fetchUsers: vi.fn().mockResolvedValue([...]),
}));

Middleware

Test Next.js middleware by creating mock request objects:

Testing middleware
import { NextRequest } from 'next/server';
import { middleware } from './middleware';

test('middleware redirects unauthenticated users', async () => {
  const request = new NextRequest(new URL('http://localhost:3000/dashboard'));
  const response = await middleware(request);
  
  expect(response.status).toBe(307);
  expect(response.headers.get('location')).toContain('/sign-in');
});

Route handlers

Test API route handlers with proper request/response mocking:

Testing route handlers
import { NextRequest } from 'next/server';
import { GET } from './route';

test('GET handler returns data', async () => {
  const request = new NextRequest('http://localhost:3000/api/users');
  const response = await GET(request);
  const data = await response.json();
  
  expect(response.status).toBe(200);
  expect(data).toHaveProperty('users');
});

CI/CD integration

Integrate tests into your CI/CD pipeline for automated quality checks:

GitHub Actions example
# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: '22'
      
      - run: npm ci
      - run: npm run test:unit
      - run: npm run test:integration
      - run: npm run test:e2e
      
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/

Running tests in CI ensures code quality before merging and deploying. Fastack's monorepo structure allows running tests for specific packages or apps as needed.

Conclusion

A comprehensive testing strategy combining unit, integration, and E2E tests provides confidence in your Next.js SaaS application. Start with unit tests for utilities and components, add integration tests for API routes and database operations, and use E2E tests for critical user flows.

Fastack's modular architecture makes it easy to test individual packages in isolation while also testing the complete application flow. By following the testing pyramid and Next.js-specific testing patterns, you can build a robust test suite that catches bugs early and prevents regressions.

Ready to build your SaaS faster?

Get our scalable, production-ready boilerplate to save endless hours of development and setup

Testing Strategies for Next.js SaaS Applications: E2E, Unit, and Integration Testing - Fastack