JavaScript Testing Patterns
Comprehensive guide for implementing robust testing strategies in JavaScript/TypeScript applications using modern testing frameworks and best practices.
When to Use This Skill
-
Setting up test infrastructure for new projects
-
Writing unit tests for functions and classes
-
Creating integration tests for APIs and services
-
Implementing end-to-end tests for user flows
-
Mocking external dependencies and APIs
-
Testing React, Vue, or other frontend components
-
Implementing test-driven development (TDD)
-
Setting up continuous testing in CI/CD pipelines
Testing Frameworks
Jest - Full-Featured Testing Framework
Setup:
// jest.config.ts import type { Config } from "jest";
const config: Config = { preset: "ts-jest", testEnvironment: "node", roots: ["<rootDir>/src"], testMatch: ["/tests//.ts", "**/?(.)+(spec|test).ts"], collectCoverageFrom: [ "src//*.ts", "!src//.d.ts", "!src/**/.interface.ts", ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80, }, }, setupFilesAfterEnv: ["<rootDir>/src/test/setup.ts"], };
export default config;
Vitest - Fast, Vite-Native Testing
Setup:
// vitest.config.ts import { defineConfig } from "vitest/config";
export default defineConfig({ test: { globals: true, environment: "node", coverage: { provider: "v8", reporter: ["text", "json", "html"], exclude: ["/*.d.ts", "/*.config.ts", "/dist/"], }, setupFiles: ["./src/test/setup.ts"], }, });
Unit Testing Patterns
Pattern 1: Testing Pure Functions
// utils/calculator.ts export function add(a: number, b: number): number { return a + b; }
export function divide(a: number, b: number): number { if (b === 0) { throw new Error("Division by zero"); } return a / b; }
// utils/calculator.test.ts import { describe, it, expect } from "vitest"; import { add, divide } from "./calculator";
describe("Calculator", () => { describe("add", () => { it("should add two positive numbers", () => { expect(add(2, 3)).toBe(5); });
it("should add negative numbers", () => {
expect(add(-2, -3)).toBe(-5);
});
it("should handle zero", () => {
expect(add(0, 5)).toBe(5);
expect(add(5, 0)).toBe(5);
});
});
describe("divide", () => { it("should divide two numbers", () => { expect(divide(10, 2)).toBe(5); });
it("should handle decimal results", () => {
expect(divide(5, 2)).toBe(2.5);
});
it("should throw error when dividing by zero", () => {
expect(() => divide(10, 0)).toThrow("Division by zero");
});
}); });
Pattern 2: Testing Classes
// services/user.service.ts export class UserService { private users: Map<string, User> = new Map();
create(user: User): User { if (this.users.has(user.id)) { throw new Error("User already exists"); } this.users.set(user.id, user); return user; }
findById(id: string): User | undefined { return this.users.get(id); }
update(id: string, updates: Partial<User>): User { const user = this.users.get(id); if (!user) { throw new Error("User not found"); } const updated = { ...user, ...updates }; this.users.set(id, updated); return updated; }
delete(id: string): boolean { return this.users.delete(id); } }
// services/user.service.test.ts import { describe, it, expect, beforeEach } from "vitest"; import { UserService } from "./user.service";
describe("UserService", () => { let service: UserService;
beforeEach(() => { service = new UserService(); });
describe("create", () => { it("should create a new user", () => { const user = { id: "1", name: "John", email: "john@example.com" }; const created = service.create(user);
expect(created).toEqual(user);
expect(service.findById("1")).toEqual(user);
});
it("should throw error if user already exists", () => {
const user = { id: "1", name: "John", email: "john@example.com" };
service.create(user);
expect(() => service.create(user)).toThrow("User already exists");
});
});
describe("update", () => { it("should update existing user", () => { const user = { id: "1", name: "John", email: "john@example.com" }; service.create(user);
const updated = service.update("1", { name: "Jane" });
expect(updated.name).toBe("Jane");
expect(updated.email).toBe("john@example.com");
});
it("should throw error if user not found", () => {
expect(() => service.update("999", { name: "Jane" })).toThrow(
"User not found",
);
});
}); });
Pattern 3: Testing Async Functions
// services/api.service.ts
export class ApiService {
async fetchUser(id: string): Promise<User> {
const response = await fetch(https://api.example.com/users/${id});
if (!response.ok) {
throw new Error("User not found");
}
return response.json();
}
async createUser(user: CreateUserDTO): Promise<User> { const response = await fetch("https://api.example.com/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(user), }); return response.json(); } }
// services/api.service.test.ts import { describe, it, expect, vi, beforeEach } from "vitest"; import { ApiService } from "./api.service";
// Mock fetch globally global.fetch = vi.fn();
describe("ApiService", () => { let service: ApiService;
beforeEach(() => { service = new ApiService(); vi.clearAllMocks(); });
describe("fetchUser", () => { it("should fetch user successfully", async () => { const mockUser = { id: "1", name: "John", email: "john@example.com" };
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const user = await service.fetchUser("1");
expect(user).toEqual(mockUser);
expect(fetch).toHaveBeenCalledWith("https://api.example.com/users/1");
});
it("should throw error if user not found", async () => {
(fetch as any).mockResolvedValueOnce({
ok: false,
});
await expect(service.fetchUser("999")).rejects.toThrow("User not found");
});
});
describe("createUser", () => { it("should create user successfully", async () => { const newUser = { name: "John", email: "john@example.com" }; const createdUser = { id: "1", ...newUser };
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => createdUser,
});
const user = await service.createUser(newUser);
expect(user).toEqual(createdUser);
expect(fetch).toHaveBeenCalledWith(
"https://api.example.com/users",
expect.objectContaining({
method: "POST",
body: JSON.stringify(newUser),
}),
);
});
}); });
Mocking Patterns
Pattern 1: Mocking Modules
// services/email.service.ts import nodemailer from "nodemailer";
export class EmailService { private transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: 587, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, });
async sendEmail(to: string, subject: string, html: string) { await this.transporter.sendMail({ from: process.env.EMAIL_FROM, to, subject, html, }); } }
// services/email.service.test.ts import { describe, it, expect, vi, beforeEach } from "vitest"; import { EmailService } from "./email.service";
vi.mock("nodemailer", () => ({ default: { createTransport: vi.fn(() => ({ sendMail: vi.fn().mockResolvedValue({ messageId: "123" }), })), }, }));
describe("EmailService", () => { let service: EmailService;
beforeEach(() => { service = new EmailService(); });
it("should send email successfully", async () => { await service.sendEmail( "test@example.com", "Test Subject", "<p>Test Body</p>", );
expect(service["transporter"].sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: "test@example.com",
subject: "Test Subject",
}),
);
}); });
Pattern 2: Dependency Injection for Testing
// services/user.service.ts export interface IUserRepository { findById(id: string): Promise<User | null>; create(user: User): Promise<User>; }
export class UserService { constructor(private userRepository: IUserRepository) {}
async getUser(id: string): Promise<User> { const user = await this.userRepository.findById(id); if (!user) { throw new Error("User not found"); } return user; }
async createUser(userData: CreateUserDTO): Promise<User> { // Business logic here const user = { id: generateId(), ...userData }; return this.userRepository.create(user); } }
// services/user.service.test.ts import { describe, it, expect, vi, beforeEach } from "vitest"; import { UserService, IUserRepository } from "./user.service";
describe("UserService", () => { let service: UserService; let mockRepository: IUserRepository;
beforeEach(() => { mockRepository = { findById: vi.fn(), create: vi.fn(), }; service = new UserService(mockRepository); });
describe("getUser", () => { it("should return user if found", async () => { const mockUser = { id: "1", name: "John", email: "john@example.com" }; vi.mocked(mockRepository.findById).mockResolvedValue(mockUser);
const user = await service.getUser("1");
expect(user).toEqual(mockUser);
expect(mockRepository.findById).toHaveBeenCalledWith("1");
});
it("should throw error if user not found", async () => {
vi.mocked(mockRepository.findById).mockResolvedValue(null);
await expect(service.getUser("999")).rejects.toThrow("User not found");
});
});
describe("createUser", () => { it("should create user successfully", async () => { const userData = { name: "John", email: "john@example.com" }; const createdUser = { id: "1", ...userData };
vi.mocked(mockRepository.create).mockResolvedValue(createdUser);
const user = await service.createUser(userData);
expect(user).toEqual(createdUser);
expect(mockRepository.create).toHaveBeenCalled();
});
}); });
Pattern 3: Spying on Functions
// utils/logger.ts
export const logger = {
info: (message: string) => console.log(INFO: ${message}),
error: (message: string) => console.error(ERROR: ${message}),
};
// services/order.service.ts import { logger } from "../utils/logger";
export class OrderService {
async processOrder(orderId: string): Promise<void> {
logger.info(Processing order ${orderId});
// Process order logic
logger.info(Order ${orderId} processed successfully);
}
}
// services/order.service.test.ts import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { OrderService } from "./order.service"; import { logger } from "../utils/logger";
describe("OrderService", () => { let service: OrderService; let loggerSpy: any;
beforeEach(() => { service = new OrderService(); loggerSpy = vi.spyOn(logger, "info"); });
afterEach(() => { loggerSpy.mockRestore(); });
it("should log order processing", async () => { await service.processOrder("123");
expect(loggerSpy).toHaveBeenCalledWith("Processing order 123");
expect(loggerSpy).toHaveBeenCalledWith("Order 123 processed successfully");
expect(loggerSpy).toHaveBeenCalledTimes(2);
}); });
Integration Testing
Integration tests verify real database operations and HTTP endpoints using supertest and a test database instance. Always truncate tables in beforeEach and tear down in afterAll .
For full API integration test examples (supertest + PostgreSQL) and database repository integration tests, see references/advanced-testing-patterns.md.
Frontend Testing with Testing Library
Test React components by rendering them and querying by role, placeholder, or test ID. Test hooks with renderHook
- act . Prefer semantic queries (getByRole , getByPlaceholderText ) over data-testid .
For complete React component test examples (UserForm, hooks with renderHook /act ), see references/advanced-testing-patterns.md.
Test Fixtures and Factories
Use @faker-js/faker to generate realistic test data factories. Factories accept optional overrides so tests can set only the fields they care about:
// tests/fixtures/user.fixture.ts import { faker } from "@faker-js/faker";
export function createUserFixture(overrides?: Partial<User>): User { return { id: faker.string.uuid(), name: faker.person.fullName(), email: faker.internet.email(), createdAt: faker.date.past(), ...overrides, }; }
For snapshot testing, coverage configuration, test organization patterns, promise testing, and timer mocking, see references/advanced-testing-patterns.md.
Best Practices
-
Follow AAA Pattern: Arrange, Act, Assert
-
One assertion per test: Or logically related assertions
-
Descriptive test names: Should describe what is being tested
-
Use beforeEach/afterEach: For setup and teardown
-
Mock external dependencies: Keep tests isolated
-
Test edge cases: Not just happy paths
-
Avoid implementation details: Test behavior, not implementation
-
Use test factories: For consistent test data
-
Keep tests fast: Mock slow operations
-
Write tests first (TDD): When possible
-
Maintain test coverage: Aim for 80%+ coverage
-
Use TypeScript: For type-safe tests
-
Test error handling: Not just success cases
-
Use data-testid sparingly: Prefer semantic queries
-
Clean up after tests: Prevent test pollution