General
Testing

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 Drizzle 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 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:

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-paths to 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 and lib/**/*.test.ts files

Running tests

There are several ways to run unit tests:

Run all tests

Terminal
npm run test:unit

This runs all unit tests once and exits. Perfect for CI/CD pipelines.

Watch mode

Terminal
npm run test:watch

This 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:

Terminal
npm run test:coverage

This 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):

Terminal
npm run test:db

This 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:

tests/lib/utils.test.ts
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 together
  • it or test: Defines an individual test case
  • expect: Makes assertions about the code being tested

Common assertions

tests/example.test.ts
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.