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.
It comes with everything you need out of the box - code coverage, snapshot testing, mocking, and a slick UI for debugging. Fast feedback, zero configuration.
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 tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
// Check if we should run database tests (need Docker)
const runDbTests = process.env.RUN_DB_TESTS === 'true';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
coverage: {
provider: 'v8'
},
server: {
deps: {
// tRPC imports next/navigation without an extension
inline: ['@trpc/server']
}
},
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
},
// Only use globalSetup for database tests
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
vite-tsconfig-pathsto resolve TypeScript path aliases - Coverage: Uses v8 provider for code coverage
- Database tests: Optional database tests using Testcontainers (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 runs tests that require a real database connection. It uses Testcontainers to spin up a PostgreSQL container for testing.
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.