For a long time I skipped testing on freelance projects entirely. Tight deadlines, one developer, seemed hard to justify. Then a client project broke in production because of a change that looked completely unrelated to the part that actually failed, and it cost more time to debug after the fact than writing tests would have taken up front.
Here is the testing setup I use now, kept minimal enough that it does not slow projects down but catches the failures that actually matter.
1. The Setup
Vitest over Jest, mainly for speed and simpler configuration with Next.js and TypeScript out of the box.
npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom jsdom
Enter fullscreen mode Exit fullscreen mode
// 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',
setupFiles: ['./vitest.setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
Enter fullscreen mode Exit fullscreen mode
// vitest.setup.ts
import '@testing-library/jest-dom';
Enter fullscreen mode Exit fullscreen mode
Add a script and you are ready to write tests:
// package.json
{
"scripts": {
"test": "vitest"
}
}
Enter fullscreen mode Exit fullscreen mode
2. Testing a Client Component
Client Components test the same way any React component does, since they render normally in the browser.
// components/AddToCartButton.tsx
'use client';
import { useState } from 'react';
export function AddToCartButton({ productId }: { productId: string }) {
const [inCart, setInCart] = useState(false);
return (
<button onClick={() => setInCart(true)}>
{inCart ? 'Added' : 'Add to cart'}
</button>
);
}
Enter fullscreen mode Exit fullscreen mode
// components/AddToCartButton.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { AddToCartButton } from './AddToCartButton';
describe('AddToCartButton', () => {
it('shows "Add to cart" initially', () => {
render(<AddToCartButton productId="123" />);
expect(screen.getByText('Add to cart')).toBeInTheDocument();
});
it('changes to "Added" after clicking', () => {
render(<AddToCartButton productId="123" />);
fireEvent.click(screen.getByText('Add to cart'));
expect(screen.getByText('Added')).toBeInTheDocument();
});
});
Enter fullscreen mode Exit fullscreen mode
Nothing Next.js specific here. This is standard React Testing Library, testing what the user sees and does, not internal implementation details.
3. Testing Server Components
This is where it gets less standard. Server Components are async functions that never run in the browser, so React Testing Library cannot render them the normal way. The practical approach is to call the function directly and check what it returns, or extract the data logic out and test that separately.
// app/blog/page.tsx
import { getPosts } from '@/lib/queries/posts';
export default async function BlogPage() {
const posts = await getPosts();
return (
<div>
{posts.map((post) => (
<article key={post.id}>{post.title}</article>
))}
</div>
);
}
Enter fullscreen mode Exit fullscreen mode
Rather than testing the page component itself, I test getPosts directly, since that is where the actual logic lives:
// lib/queries/posts.test.ts
import { describe, it, expect, vi } from 'vitest';
import { getPosts } from './posts';
import Post from '@/models/Post';
vi.mock('@/lib/db', () => ({
connectDB: vi.fn(),
}));
vi.mock('@/models/Post', () => ({
default: {
find: vi.fn(() => ({
sort: vi.fn(() => ({
lean: vi.fn(() =>
Promise.resolve([{ _id: '1', title: 'Test Post' }])
),
})),
})),
},
}));
describe('getPosts', () => {
it('returns posts sorted from the database', async () => {
const posts = await getPosts();
expect(posts).toHaveLength(1);
expect(posts[0].title).toBe('Test Post');
});
});
Enter fullscreen mode Exit fullscreen mode
This tests the actual query logic, mocking the database layer instead of the page markup, which is where bugs in a Server Component usually live anyway.
4. Testing Server Actions
Server Actions are just async functions, so they test the same way as any other function, no special Next.js testing tools required.
// actions/contact.test.ts
import { describe, it, expect, vi } from 'vitest';
import { submitContact } from './contact';
vi.mock('@/lib/db', () => ({
connectDB: vi.fn(),
}));
const mockCreate = vi.fn();
vi.mock('@/models/Message', () => ({
default: { create: mockCreate },
}));
describe('submitContact', () => {
it('rejects invalid email', async () => {
const result = await submitContact({
name: 'Test',
email: 'not-an-email',
message: 'Hello there this is long enough',
});
expect(result.success).toBe(false);
});
it('saves valid input', async () => {
const result = await submitContact({
name: 'Test User',
email: '[email protected]',
message: 'Hello there this is long enough',
});
expect(result.success).toBe(true);
expect(mockCreate).toHaveBeenCalledOnce();
});
});
Enter fullscreen mode Exit fullscreen mode
This is exactly why keeping Zod validation inside the action itself, from the earlier form handling pattern, pays off here. Testing the action directly tests the real validation logic, not a mock of it.
5. Testing API Route Handlers
Route handlers export functions per HTTP method, so testing them means calling that function with a mock Request object.
// app/api/users/route.ts
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const users = await getUsersFromDb();
return Response.json({ success: true, data: users });
}
Enter fullscreen mode Exit fullscreen mode
// app/api/users/route.test.ts
import { describe, it, expect, vi } from 'vitest';
import { GET } from './route';
import { NextRequest } from 'next/server';
vi.mock('@/lib/db', () => ({
getUsersFromDb: vi.fn(() => Promise.resolve([{ id: '1', name: 'Test' }])),
}));
describe('GET /api/users', () => {
it('returns a list of users', async () => {
const request = new NextRequest('http://localhost/api/users');
const response = await GET(request);
const body = await response.json();
expect(body.success).toBe(true);
expect(body.data).toHaveLength(1);
});
});
Enter fullscreen mode Exit fullscreen mode
Constructing a real NextRequest and calling the exported function directly avoids needing a running server just to test the route logic.
6. What I Actually Test, and What I Skip
Not everything needs a test, and testing everything on a solo or small-team freelance project usually costs more time than it saves. What I actually cover:
Validation logic. Zod schemas and anything that decides whether input is accepted or rejected, since a silent validation bug is hard to notice until a bad record is already in the database.
Server Actions and query functions with real logic. Anything beyond a simple pass-through to the database, filtering, calculations, conditional logic.
Components with meaningful interaction. A form, a cart, anything where a broken click handler actually breaks the user's ability to do something.
What I usually skip: pure presentational components with no logic, and simple pass-through queries with nothing beyond a single find() call. The cost of writing and maintaining tests for those rarely pays off compared to just checking them manually during development.
Summary
| What | How to test it |
|---|---|
| Client Components | Standard React Testing Library, render and interact |
| Server Components | Extract and test the data logic, not the component itself |
| Server Actions | Call directly as an async function, mock the database layer |
| API route handlers | Construct a NextRequest, call the exported method directly |
| Validation schemas | Test valid and invalid input directly against the Zod schema |
The mindset shift that made this click for me: stop trying to render Server Components the way you would a normal React component, and start testing the actual logic underneath them directly. Most of what breaks in production was never really about rendering, it was a query, a validation rule, or a Server Action doing the wrong thing.
I run this exact setup, Vitest plus React Testing Library, covering validation and action logic, across client projects that need real reliability, not just fast delivery.
Get the templates: https://pixelanas.gumroad.com
Do you test Next.js projects, or skip it on tighter freelance timelines? Drop it below 👇
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.