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
# 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:
// 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:
// 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:
// 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:
// 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:
// 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
# 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:
// 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:
// 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
describeblocks - Follow the AAA pattern: Arrange, Act, Assert
Test data management
- Use test fixtures and factories for consistent test data
- Clean up test data in
beforeEachorafterEachhooks - 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:
// 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:
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:
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/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
