Unit Tests
Write and run fast unit tests for individual functions and components with instant feedback.
Unit tests are a type of automated test where individual units or components are tested. The "unit" in "unit test" refers to the smallest testable parts of an application. These tests are designed to verify that each unit of code performs as expected.
The Pro Next.js Prisma starter kit uses Vitest as the unit testing framework. It's a blazing-fast test runner built on top of Vite, designed for modern JavaScript and TypeScript projects.
Why Vitest?
If you've used Jest before, you already know Vitest - it shares the same API. But Vitest is built for speed: native TypeScript support without transpilation, parallel test execution, and a smart watch mode that only re-runs tests affected by your changes.
The repository configures coverage, environment stubs, database isolation and
TypeScript path aliases for you. You can extend those defaults in
vitest.config.mts as the application grows.
Why write unit tests?
Unit tests give you fast, focused feedback on small pieces of your code - individual functions, hooks, or components. Instead of debugging an entire page or flow, you can verify just the logic you care about in isolation.
They also act as living documentation: a good test tells you how a function is supposed to behave, which edge cases are important, and what assumptions the code makes. This makes it much easier to safely refactor or extend features later.
In the starter kit, unit tests are designed to be cheap and quick to run, so you can keep Vitest running in watch mode while you code. Every change you make is immediately checked, helping you catch regressions before they ever reach integration or end‑to‑end tests.
Configuration
The Vitest configuration is in vitest.config.mts:
import { defineConfig } from 'vitest/config';
const runDbTests = process.env.RUN_DB_TESTS === 'true';
export default defineConfig({
resolve: { tsconfigPaths: true },
test: {
coverage: { provider: 'v8' },
passWithNoTests: true,
watch: false,
testTimeout: 10_000,
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/.next/**',
'**/e2e/**',
// Exclude database tests unless RUN_DB_TESTS is true
...(runDbTests
? []
: [
'**/organizations.test.ts',
'**/tests/trpc/routers/**',
'**/*db*.test.ts'
])
],
include: ['tests/**/*.{test,spec}.?(c|m)[jt]s?(x)', 'lib/**/*.test.ts'],
environment: 'node',
pool: runDbTests ? 'forks' : 'threads',
fileParallelism: !runDbTests,
sequence: { concurrent: !runDbTests },
globalSetup: runDbTests ? './tests/support/setup-global.ts' : undefined,
setupFiles: runDbTests
? ['./tests/support/setup-shared-db.ts']
: ['./tests/support/setup-env.ts']
}
});Key features:
- Path resolution: Uses Vitest's native
tsconfigPathssupport for aliases - Coverage: Uses v8 provider for code coverage
- Database tests: Optional Testcontainers suite using a shared PostgreSQL container and an isolated schema (requires Docker)
- Test locations: Includes tests from
tests/directory andlib/**/*.test.tsfiles
Running tests
There are several ways to run unit tests:
Run all tests
npm run test:unitThis runs all unit tests once and exits. Perfect for CI/CD pipelines.
Watch mode
npm run test:watchThis starts Vitest in watch mode. As you edit files, only the affected tests are re-run, giving you fast feedback while you work.
Code coverage
Generate a code coverage report:
npm run test:coverageThis runs all tests and generates a coverage report showing which lines, branches, and functions are covered by tests.
Database tests
Run database integration tests (requires Docker):
npm run test:dbThis starts one temporary PostgreSQL container through Testcontainers. Database tests receive an isolated schema and truncate its tables before each test. The global teardown stops the container after the run.
Docker must be reachable
Start Docker Desktop or your Docker daemon before running this command. The
suite creates and destroys its own PostgreSQL container; it does not use the
database from your normal DATABASE_URL.
Writing unit tests
Example: Testing utility functions
Here's an example of testing a utility function:
import { describe, expect, it } from 'vitest';
import { capitalize, getInitials } from '@/lib/utils';
describe('capitalize', () => {
it('capitalizes the first letter of a word', () => {
expect(capitalize('hello')).toBe('Hello');
});
it('returns empty string if input is empty', () => {
expect(capitalize('')).toBe('');
});
it('capitalizes a single character', () => {
expect(capitalize('a')).toBe('A');
});
});
describe('getInitials', () => {
it('returns initials for a two-word name', () => {
expect(getInitials('John Doe')).toBe('JD');
});
it('handles single name', () => {
expect(getInitials('John')).toBe('J');
});
it('handles empty string', () => {
expect(getInitials('')).toBe('');
});
});Test structure
describe: Groups related tests togetheritortest: Defines an individual test caseexpect: Makes assertions about the code being tested
Common assertions
import { describe, expect, it } from 'vitest';
describe('Common assertions', () => {
it('checks equality', () => {
expect(1 + 1).toBe(2);
});
it('checks object equality', () => {
expect({ name: 'John' }).toEqual({ name: 'John' });
});
it('checks truthiness', () => {
expect(true).toBeTruthy();
expect(false).toBeFalsy();
});
it('checks for null/undefined', () => {
expect(null).toBeNull();
expect(undefined).toBeUndefined();
});
it('checks strings', () => {
expect('hello').toContain('ell');
expect('hello').toMatch(/^h/);
});
it('checks arrays', () => {
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
});
it('checks errors', () => {
expect(() => {
throw new Error('test');
}).toThrow('test');
});
});Best practices
Unit tests should work for you, not the other way around. Focus on writing tests that make it easier to change code with confidence, not on satisfying arbitrary rules or reaching a magic number in a dashboard.
Test behavior, not implementation
Focus on what the function does, not how it does it. This makes tests more resilient to refactoring.
// ✅ Good - tests behavior
expect(capitalize('hello')).toBe('Hello');
// ❌ Not so good - tests implementation details
expect(capitalize.toString()).toContain('charAt');Keep tests focused
Each test should verify one specific behavior. If a test is checking multiple things, split it into multiple tests.
// ✅ Good - focused test
it('capitalizes the first letter', () => {
expect(capitalize('hello')).toBe('Hello');
});
// ❌ Not so good - testing multiple things
it('handles various inputs', () => {
expect(capitalize('hello')).toBe('Hello');
expect(capitalize('')).toBe('');
expect(capitalize('a')).toBe('A');
});Use descriptive test names
Test names should clearly describe what is being tested.
// ✅ Good - descriptive
it('returns empty string if input is empty', () => {
expect(capitalize('')).toBe('');
});
// ❌ Not so good - unclear
it('handles edge case', () => {
expect(capitalize('')).toBe('');
});Test edge cases
Don't just test the happy path. Test edge cases like empty strings, null values, and boundary conditions.
describe('capitalize', () => {
it('handles normal input', () => {
expect(capitalize('hello')).toBe('Hello');
});
it('handles empty string', () => {
expect(capitalize('')).toBe('');
});
it('handles single character', () => {
expect(capitalize('a')).toBe('A');
});
});Code coverage is a guide, not a goal
Code coverage helps you find untested code, but it shouldn't be the primary goal. Focus on testing critical paths and edge cases, not achieving 100% coverage.
Next steps
With unit tests set up, you can now:
- Test utility functions to ensure they work correctly
- Test business logic in isolation
- Catch regressions before they reach production
- Refactor with confidence knowing tests will catch breaking changes
Ready to test complete user flows? Check out the E2E Tests guide.