TypeScript Testing — Vitest, Jest, and Cypress Guide
In this tutorial, you will learn about TypeScript Testing. We cover key concepts, practical examples, and best practices to help you master this topic.
TypeScript testing with Vitest or Jest catches logic errors and type mismatches simultaneously — your tests verify runtime behavior while TypeScript ensures mock data matches real interfaces.
What You'll Learn
- Setting up Vitest with TypeScript
- Writing unit tests for typed functions
- Mocking typed dependencies
- Integration testing with databases
- E2E testing with Cypress
- Test-Driven Development (TDD)
Why It Matters
Untyped tests let you mock incorrect shapes — a mock that returns { name: "Alice" } when the real function expects { fullName: "Alice" } passes tests but fails in production. TypeScript makes tests as type-safe as production code.
Real-World Use
The Doda Browser sync service has 2,000+ TypeScript tests. Every API endpoint, database query, and sync algorithm is tested with typed mocks that mirror the production interfaces. TypeScript catches mock mismatches before tests even run.
Learning Path
flowchart LR A[Database Access] --> B[Testing] B --> C[Advanced Patterns] B --> D[You Are Here] C --> E[SOLID Principles] D --> F[Error Handling]
Setting Up Vitest
Vitest is the modern TypeScript testing framework with native ESM and TypeScript support:
npm install --save-dev vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
});
Add to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
Writing Type-Safe Unit Tests
// src/utils/format.ts
export function formatUserName(firstName: string, lastName: string): string {
return `${firstName} ${lastName}`.trim();
}
export function calculateDiscount(price: number, discountPercent: number): number {
if (discountPercent < 0 || discountPercent > 100) {
throw new Error('Discount must be between 0 and 100');
}
return price * (1 - discountPercent / 100);
}
// src/utils/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatUserName, calculateDiscount } from './format';
describe('formatUserName', () => {
it('combines first and last name with a space', () => {
const result = formatUserName('Alice', 'Johnson');
expect(result).toBe('Alice Johnson');
});
it('handles empty strings', () => {
const result = formatUserName('', 'Smith');
expect(result).toBe('Smith');
});
it('trims whitespace', () => {
const result = formatUserName(' Bob ', ' Jones ');
expect(result).toBe('Bob Jones');
});
});
describe('calculateDiscount', () => {
it('applies percentage discount correctly', () => {
expect(calculateDiscount(100, 20)).toBe(80);
});
it('throws for invalid discount percentage', () => {
expect(() => calculateDiscount(100, -1)).toThrow();
expect(() => calculateDiscount(100, 101)).toThrow();
});
it('returns 0 for 100% discount', () => {
expect(calculateDiscount(100, 100)).toBe(0);
});
});
Expected output:
✓ formatUserName
✓ combines first and last name with a space
✓ handles empty strings
✓ trims whitespace
✓ calculateDiscount
✓ applies percentage discount correctly
✓ throws for invalid discount percentage
✓ returns 0 for 100% discount
Mocking Typed Dependencies
Vitest's vi.mock works seamlessly with TypeScript:
// src/services/user-service.ts
import { prisma } from '../lib/db';
export async function getUserEmail(userId: string): Promise<string | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
return user?.email ?? null;
}
// src/services/user-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { getUserEmail } from './user-service';
// Mock the entire db module
vi.mock('../lib/db', () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
// Import the mocked module (now with typed mocks)
import { prisma } from '../lib/db';
const mockFindUnique = vi.mocked(prisma.user.findUnique);
describe('getUserEmail', () => {
it('returns the email when user exists', async () => {
// Mock the return value with the correct type
mockFindUnique.mockResolvedValue({ email: 'alice@example.com' });
const email = await getUserEmail('user-1');
expect(email).toBe('alice@example.com');
expect(mockFindUnique).toHaveBeenCalledWith({
where: { id: 'user-1' },
select: { email: true },
});
});
it('returns null when user does not exist', async () => {
mockFindUnique.mockResolvedValue(null);
const email = await getUserEmail('non-existent');
expect(email).toBeNull();
});
});
The vi.mocked() utility wraps the mock function with correct TypeScript types. Without it, mockFindUnique.mockResolvedValue would accept any value.
Integration Testing with Supertest
Test Express routes with typed requests:
npm install --save-dev supertest @types/supertest
// src/app.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import app from './app'; // Express app
describe('GET /health', () => {
it('returns status ok', async () => {
const response = await request(app)
.get('/health')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body).toEqual({
status: 'ok',
timestamp: expect.any(String),
});
});
});
describe('POST /users', () => {
it('creates a user with valid data', async () => {
const response = await request(app)
.post('/users')
.send({ name: 'Alice', email: 'alice@example.com' })
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe('Alice');
});
it('returns 400 for invalid email', async () => {
await request(app)
.post('/users')
.send({ name: 'Alice', email: 'not-an-email' })
.expect(400);
});
});
Testing Asynchronous Code
TypeScript's async/await is a natural fit for test assertions:
// src/services/payment-service.ts
export interface PaymentResult {
success: boolean;
transactionId?: string;
error?: string;
}
export async function processPayment(amount: number, cardNumber: string): Promise<PaymentResult> {
if (amount <= 0) {
return { success: false, error: 'Amount must be positive' };
}
if (cardNumber.length < 13) {
return { success: false, error: 'Invalid card number' };
}
// Simulate payment processing
await new Promise((r) => setTimeout(r, 10));
return { success: true, transactionId: `txn_${Date.now()}` };
}
// src/services/payment-service.test.ts
describe('processPayment', () => {
it('succeeds with valid amount and card', async () => {
const result = await processPayment(50, '4111111111111111');
expect(result.success).toBe(true);
expect(result.transactionId).toMatch(/^txn_/);
});
it('fails with negative amount', async () => {
const result = await processPayment(-10, '4111111111111111');
expect(result.success).toBe(false);
expect(result.error).toBe('Amount must be positive');
});
it('fails with short card number', async () => {
const result = await processPayment(50, '123');
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid card number');
});
});
Test Coverage Configuration
npx vitest run --coverage
Configure coverage thresholds in vitest.config.ts:
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
}
E2E Testing with Cypress
Cypress supports TypeScript natively:
// cypress/e2e/login.cy.ts
describe('Login Flow', () => {
beforeEach(() => {
cy.visit('/login');
});
it('displays validation errors for empty fields', () => {
cy.get('[data-testid="login-button"]').click();
cy.contains('Email is required').should('be.visible');
cy.contains('Password is required').should('be.visible');
});
it('logs in with valid credentials', () => {
cy.get('[data-testid="email-input"]').type('user@example.com');
cy.get('[data-testid="password-input"]').type('validPassword123');
cy.get('[data-testid="login-button"]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome back').should('be.visible');
});
});
Common Mistakes
1. Testing implementation details instead of behavior
Test what the function does, not how it does it. Avoid asserting on internal state or private methods.
2. Mocking too many things
Over-mocking makes tests brittle. Mock only external boundaries (database, network, file system).
3. Not testing error paths
Happy-path tests miss 50% of the code. Always test error conditions, edge cases, and boundary values.
4. Using real databases in unit tests
Integration tests can use test databases, but unit tests should mock database access. It's slower and unreliable otherwise.
5. Forgetting to clear mocks between tests
Mock state carries over. Use vi.clearAllMocks() in afterEach or beforeEach.
6. Not using vi.mocked() for better type inference
Without vi.mocked(), mock function types default to any, losing type safety.
7. Writing tests that depend on execution order
Tests should be isolated and runnable in any order. Use beforeEach for setup, never rely on previous test state.
Practice Questions
What's the difference between Vitest and Jest? Vitest is faster (uses Vite/Esbuild), has native TypeScript support, and matches Jest's API. Jest requires additional configuration for TypeScript.
How do you mock a named export in Vitest? Use
vi.mock('./module', () => ({ namedExport: vi.fn() }))orvi.spyOn(module, 'namedExport').What's the purpose of
vi.mocked()? It wraps a mocked function with correct TypeScript types so TypeScript knows the return type and parameter types of the mock.How do you test functions that throw errors? Wrap in
expect(() => fn()).toThrow()for synchronous, orawait expect(fn()).rejects.toThrow()for async.What's the difference between unit and integration tests? Unit tests test single functions/classes in isolation (mocked dependencies). Integration tests test multiple components together (real database, real API calls).
Challenge
Write a test suite for a shopping cart service: test adding items, removing items, applying discounts, checking out, and error cases. Use typed mocks for the payment service and database.
FAQ
Mini Project
Write a comprehensive test suite for a task management API:
- Unit tests: Task service (create, update, delete, complete)
- Integration tests: Express routes with supertest
- Mocked database: Mock Prisma client with typed mocks
- Error tests: Validation failures, not found, unauthorized
- Coverage: Configure 80% thresholds
What's Next
Your TypeScript testing skills ensure production reliability. Now explore advanced patterns with {{< ref "49-advanced-patterns" >}}, or learn SOLID principles with {{< ref "50-solid-principles" >}}.
For error handling patterns, see {{< ref "51-error-handling" >}}.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro