Модуль 12.2
Тестирование: Jest + TypeScript
Гарантия качества кода через автоматизированные тесты
Введение: зачем нужно тестирование
Тестирование — это не_optional_ часть современной разработки. Каждый professional developer понимает, что код без тестов — это код, который может сломаться в любой момент. TypeScript обеспечивает проверку типов на этапе компиляции, но тесты проверяют поведение — то, как ваш код работает в реальных условиях.
Jest — самый популярный тестовый фреймворк в экосистеме JavaScript/TypeScript. Он создан Facebook (Meta) и используется в тысячах проектов по всему миру. Jest обеспечивает:
- Zero-config — работает "из коробки" для большинства проектов
- Изоляция тестов — каждый тест запускается в изолированном окружении
- Параллельное выполнение — тесты запускаются параллельно для скорости
- Встроенные матчеры — богатый набор assertions
- Mocking — мощная система мокирования
- Coverage — встроенное покрытие кода
- Snapshot testing — тестирование вывода
1. Установка Jest с TypeScript
1.1 Варианты установки
Есть несколько способов настроить Jest для работы с TypeScript:
# Вариант 1: ts-jest (рекомендуемый) # ts-jest трансформирует TypeScript файлы для Jest npm install --save-dev jest ts-jest @types/jest typescript # Вариант 2: @swc/jest (более быстрый) # Использует SWC компилятор вместо TypeScript npm install --save-dev jest @swc/jest @types/jest typescript # Вариант 3: Babel # Через Babel плагин npm install --save-dev jest @babel/core @babel/preset-env @babel/preset-typescript @types/jest typescript # Инициализация конфигурации npx ts-jest config:install
1.2 Выбор подхода
ts-jest
Полная поддержка TypeScript, включая type checking. Медленнее, но надёжнее.
Для: Проекты, где важна точность типов
@swc/jest
В 20-50 раз быстрее ts-jest. Не проверяет типы при трансформации.
Для: Большие проекты, где важна скорость
Babel
Гибкий, поддерживает множество плагинов. Средняя скорость.
Для: Проекты с нестандартными потребностями
2. Конфигурация jest.config.ts
// jest.config.ts — Полная конфигурация для TypeScript
import type { Config } from 'jest';
const config: Config = {
// Окружение тестов
testEnvironment: 'node',
// Корневая директория
rootDir: '.',
// Директории с тестами
roots: ['<rootDir>/src'],
// Шаблоны файлов тестов
testMatch: [
'**/__tests__/**/*.{ts,tsx}',
'**/?(*.)+(spec|test).{ts,tsx}',
],
// Трансформация файлов
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
// Модули (alias)
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@components/(.*)$': '<rootDir>/src/components/$1',
'^@utils/(.*)$': '<rootDir>/src/utils/$1',
'^@services/(.*)$': '<rootDir>/src/services/$1',
'\\.(css|less|scss)$': 'identity-obj-proxy',
},
// Настройки ts-jest
transformIgnorePatterns: [
'/node_modules/',
],
globals: {
'ts-jest': {
tsconfig: 'tsconfig.test.json',
diagnostics: true,
},
},
// Покрытие кода
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'text-summary', 'lcov', 'html'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/index.ts',
'!src/types/**',
'!src/**/*.stories.{ts,tsx}',
'!src/**/*.test.{ts,tsx}',
'!src/**/*.spec.{ts,tsx}',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
// Ожидание завершения
teardownTimeout: 10000,
// Файлы setup
setupFilesAfterSetup: ['<rootDir>/src/setupTests.ts'],
// Очищать mocks между тестами
clearMocks: true,
// Восстанавливать mocks между тестами
restoreMocks: true,
// Таймаут теста
testTimeout: 10000,
// Verbose output
verbose: true,
// Ошибки
errorOnDeprecated: true,
};
export default config;
2.1 Конфигурация tsconfig для тестов
// tsconfig.test.json — Конфигурация TypeScript для тестов
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"],
"jsx": "react-jsx",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"declaration": false,
"sourceMap": true,
"strict": true
},
"include": [
"src/**/*",
"tests/**/*",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.spec.tsx"
],
"exclude": [
"node_modules",
"dist",
"build"
]
}
2.2 Package.json scripts
// package.json — Scripts для тестирования
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --reporters=default --reporters=jest-junit",
"test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand",
"test:unit": "jest --testPathPattern=unit",
"test:integration": "jest --testPathPattern=integration",
"test:e2e": "jest --testPathPattern=e2e --testTimeout=30000",
"test:changed": "jest --onlyChanged",
"test:related": "jest --findRelatedTests"
}
}
3. Написание первых тестов
3.1 Структура теста: describe, it, expect
// src/utils/math.ts — Функция для тестирования
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(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;
}
export function isEven(n: number): boolean {
return n % 2 === 0;
}
// src/utils/math.test.ts — Тесты
import { add, multiply, divide, isEven } from './math';
// describe — группировка тестов
describe('Math utils', () => {
// describe — вложенная группировка
describe('add function', () => {
// it (или test) — отдельный тестовый случай
it('should add two positive numbers', () => {
// expect — утверждение (assertion)
expect(add(2, 3)).toBe(5);
});
it('should add negative numbers', () => {
expect(add(-1, -2)).toBe(-3);
});
it('should add zero', () => {
expect(add(5, 0)).toBe(5);
});
it('should handle decimal numbers', () => {
expect(add(0.1, 0.2)).toBeCloseTo(0.3);
});
});
describe('multiply function', () => {
it('should multiply two numbers', () => {
expect(multiply(3, 4)).toBe(12);
});
it('should return zero when multiplied by zero', () => {
expect(multiply(5, 0)).toBe(0);
});
it('should handle negative numbers', () => {
expect(multiply(-2, 3)).toBe(-6);
});
});
describe('divide function', () => {
it('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('should throw error when dividing by zero', () => {
// toThrow — проверка выброса исключения
expect(() => divide(10, 0)).toThrow('Division by zero');
});
it('should handle decimal results', () => {
expect(divide(1, 3)).toBeCloseTo(0.333, 2);
});
});
describe('isEven function', () => {
it('should return true for even numbers', () => {
expect(isEven(2)).toBe(true);
expect(isEven(4)).toBe(true);
expect(isEven(0)).toBe(true);
});
it('should return false for odd numbers', () => {
expect(isEven(1)).toBe(false);
expect(isEven(3)).toBe(false);
expect(isEven(-1)).toBe(false);
});
});
});
3.2 Запуск тестов
# Запуск всех тестов npm test # Запуск в watch mode (перезапуск при изменении файлов) npm run test:watch # Запуск конкретного файла npm test -- math.test.ts # Запуск тестов, соответствующих паттерну npm test -- -t "add function" # Запуск с покрытием кода npm run test:coverage # Запуск в отладочном режиме npm run test:debug # Запуск с увеличенным таймаутом npm test -- --testTimeout=30000
4. Матчеры Jest (Matchers)
Jest предоставляет богатый набор матчеров для проверки различных условий. Вот основные:
describe('Jest Matchers', () => {
// 1. Точное сравнение
describe('Точное сравнение', () => {
it('toBe — строгое равенство (===)', () => {
expect(2 + 2).toBe(4);
expect('hello').toBe('hello');
expect(true).toBe(true);
});
it('toEqual — глубокое сравнение объектов', () => {
expect({ name: 'Alice', age: 30 }).toEqual({ name: 'Alice', age: 30 });
expect([1, 2, 3]).toEqual([1, 2, 3]);
});
it('toStrictEqual — строгое глубокое сравнение', () => {
const obj1 = { a: 1, b: { c: 2 } };
const obj2 = { a: 1, b: { c: 2 } };
expect(obj1).toStrictEqual(obj2);
});
});
// 2. Логические проверки
describe('Логические проверки', () => {
it('toBeTruthy — truthy значение', () => {
expect(1).toBeTruthy();
expect('hello').toBeTruthy();
expect(true).toBeTruthy();
});
it('toBeFalsy — falsy значение', () => {
expect(0).toBeFalsy();
expect('').toBeFalsy();
expect(false).toBeFalsy();
expect(null).toBeFalsy();
expect(undefined).toBeFalsy();
});
it('toBeNull — проверка на null', () => {
expect(null).toBeNull();
expect(undefined).not.toBeNull();
});
it('toBeUndefined — проверка на undefined', () => {
expect(undefined).toBeUndefined();
expect(null).not.toBeUndefined();
});
it('toBeDefined — проверка на определённость', () => {
expect(0).toBeDefined();
expect('').toBeDefined();
expect(false).toBeDefined();
});
});
// 3. Числовые проверки
describe('Числовые проверки', () => {
it('toBeGreaterThan — больше', () => {
expect(10).toBeGreaterThan(5);
});
it('toBeGreaterThanOrEqual — больше или равно', () => {
expect(10).toBeGreaterThanOrEqual(10);
});
it('toBeLessThan — меньше', () => {
expect(5).toBeLessThan(10);
});
it('toBeLessThanOrEqual — меньше или равно', () => {
expect(5).toBeLessThanOrEqual(5);
});
it('toBeCloseTo — приближённое сравнение (для float)', () => {
expect(0.1 + 0.2).toBeCloseTo(0.3);
expect(0.1 + 0.2).toBeCloseTo(0.3, 5);
});
});
// 4. Строковые проверки
describe('Строковые проверки', () => {
it('toMatch — регулярное выражение', () => {
expect('hello world').toMatch(/world/);
expect('hello world').toMatch('hello');
});
it('toContain — содержание подстроки', () => {
expect('hello world').toContain('world');
expect([1, 2, 3]).toContain(2);
});
it('toContainEqual — содержание объекта в массиве', () => {
const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
expect(users).toContainEqual({ id: 1, name: 'Alice' });
});
});
// 5. Проверка исключений
describe('Проверка исключений', () => {
it('toThrow — проверка выброса исключения', () => {
expect(() => {
throw new Error('Error message');
}).toThrow('Error message');
});
it('toThrowError — проверка типа ошибки', () => {
expect(() => {
throw new TypeError('Type error');
}).toThrow(TypeError);
});
it('rejects — проверка отклонённого промиса', async () => {
const asyncFn = () => Promise.reject(new Error('Async error'));
await expect(asyncFn()).rejects.toThrow('Async error');
});
});
// 6. Проверка массивов и объектов
describe('Проверка массивов и объектов', () => {
it('toHaveProperty — наличие свойства', () => {
expect({ name: 'Alice', age: 30 }).toHaveProperty('name');
expect({ name: 'Alice', age: 30 }).toHaveProperty('name', 'Alice');
});
it('toHaveLength — длина', () => {
expect([1, 2, 3]).toHaveLength(3);
expect('hello').toHaveLength(5);
});
it('toHaveProperty — вложенное свойство', () => {
const user = { profile: { address: { city: 'Moscow' } } };
expect(user).toHaveProperty('profile.address.city', 'Moscow');
});
it('toContainEntries — содержание записей', () => {
const obj = { a: 1, b: 2 };
expect(obj).toContainEntries([['a', 1], ['b', 2]]);
});
});
// 7. Проверка вызовов функций
describe('Проверка вызовов функций', () => {
it('toHaveBeenCalled — функция была вызвана', () => {
const fn = jest.fn();
fn();
expect(fn).toHaveBeenCalled();
});
it('toHaveBeenCalledTimes — количество вызовов', () => {
const fn = jest.fn();
fn();
fn();
fn();
expect(fn).toHaveBeenCalledTimes(3);
});
it('toHaveBeenCalledWith — вызов с аргументами', () => {
const fn = jest.fn();
fn('hello', 42);
expect(fn).toHaveBeenCalledWith('hello', 42);
});
it('toHaveBeenLastCalledWith — последний вызов', () => {
const fn = jest.fn();
fn('first');
fn('last');
expect(fn).toHaveBeenLastCalledWith('last');
});
it('toHaveBeenNthCalledWith — N-й вызов', () => {
const fn = jest.fn();
fn('first');
fn('second');
fn('third');
expect(fn).toHaveBeenNthCalledWith(2, 'second');
});
});
// 8. Сnapshots
describe('Snapshots', () => {
it('toMatchSnapshot — сравнение со снимком', () => {
const user = { id: 1, name: 'Alice', role: 'admin' };
expect(user).toMatchSnapshot();
});
it('toMatchInlineSnapshot — inline снимок', () => {
expect({ x: 1, y: 2 }).toMatchInlineSnapshot(`
Object {
"x": 1,
"y": 2,
}
`);
});
});
// 9. Негативные проверки
describe('Негативные проверки', () => {
it('not — отрицание', () => {
expect(2 + 2).not.toBe(5);
expect('hello').not.toBe('world');
});
});
});
5. Мокирование (Mocking)
5.1 jest.fn() — Создание моков
// 1. Простой mock функции
const mockFn = jest.fn();
mockFn('hello');
expect(mockFn).toHaveBeenCalledWith('hello');
// 2. Mock с возвращаемым значением
const mockFnWithReturn = jest.fn()
.mockReturnValue(42)
.mockReturnValueOnce(1)
.mockReturnValueOnce(2);
expect(mockFnWithReturn()).toBe(1); // Первый вызов
expect(mockFnWithReturn()).toBe(2); // Второй вызов
expect(mockFnWithReturn()).toBe(42); // Все последующие
// 3. Mock с implementation
const mockFnWithImpl = jest.fn((x: number, y: number) => x + y);
expect(mockFnWithImpl(2, 3)).toBe(5);
// 4. Mock с resolved/rejected value (для async)
const asyncMock = jest.fn()
.mockResolvedValue({ id: 1, name: 'Alice' })
.mockRejectedValueOnce(new Error('Network error'));
// Первый вызов — успех
const result1 = await asyncMock();
expect(result1).toEqual({ id: 1, name: 'Alice' });
// Второй вызов — ошибка
await expect(asyncMock()).rejects.toThrow('Network error');
// 5. Проверка количества вызовов
const fn = jest.fn();
fn(); fn(); fn();
expect(fn).toHaveBeenCalledTimes(3);
// 6. Проверка аргументов каждого вызова
expect(fn).toHaveBeenNthCalledWith(1);
expect(fn).toHaveBeenNthCalledWith(2);
expect(fn).toHaveBeenNthCalledWith(3);
5.2 jest.mock() — Мокирование модулей
// services/api.ts — Модуль для мокирования
export async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
export async function createUser(data: CreateUserDto): Promise<User> {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data),
});
return response.json();
}
// services/api.test.ts — Тесты с мокированием
import { fetchUser, createUser } from './api';
// Автоматическое мокирование всего модуля
jest.mock('./api');
// Типизированный мок
const mockedFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>;
const mockedCreateUser = createUser as jest.MockedFunction<typeof createUser>;
describe('API Service', () => {
beforeEach(() => {
// Очистка всех моков перед каждым тестом
jest.clearAllMocks();
});
describe('fetchUser', () => {
it('should fetch user successfully', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
mockedFetchUser.mockResolvedValue(mockUser);
const result = await fetchUser(1);
expect(result).toEqual(mockUser);
expect(mockedFetchUser).toHaveBeenCalledWith(1);
expect(mockedFetchUser).toHaveBeenCalledTimes(1);
});
it('should throw error when fetch fails', async () => {
mockedFetchUser.mockRejectedValue(new Error('Network error'));
await expect(fetchUser(1)).rejects.toThrow('Network error');
});
});
describe('createUser', () => {
it('should create user with correct data', async () => {
const newUserData = { name: 'Bob', email: 'bob@example.com' };
const createdUser = { id: 2, ...newUserData };
mockedCreateUser.mockResolvedValue(createdUser);
const result = await createUser(newUserData);
expect(result).toEqual(createdUser);
expect(mockedCreateUser).toHaveBeenCalledWith(newUserData);
});
});
});
5.3 jest.spyOn() — Шпионаж за методами
// Spy на методы объекта
const calculator = {
add: (a: number, b: number) => a + b,
subtract: (a: number, b: number) => a - b,
};
const addSpy = jest.spyOn(calculator, 'add');
const subtractSpy = jest.spyOn(calculator, 'subtract');
// Вызов метода
const result = calculator.add(2, 3);
// Проверка вызова
expect(addSpy).toHaveBeenCalledWith(2, 3);
expect(result).toBe(5);
// Spy на методы класса
class UserService {
private users: User[] = [];
addUser(user: User): void {
this.users.push(user);
}
getUser(id: number): User | undefined {
return this.users.find(u => u.id === id);
}
}
describe('UserService', () => {
let service: UserService;
let addUserSpy: jest.SpyInstance;
beforeEach(() => {
service = new UserService();
addUserSpy = jest.spyOn(service, 'addUser');
});
afterEach(() => {
addUserSpy.mockRestore();
});
it('should call addUser when adding a user', () => {
const user = { id: 1, name: 'Alice' };
service.addUser(user);
expect(addUserSpy).toHaveBeenCalledWith(user);
});
});
6. Тестирование асинхронного кода
// services/userService.ts
export class UserService {
async getUser(id: number): Promise<User | null> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) return null;
return response.json();
} catch {
return null;
}
}
async getUsers(): Promise<User[]> {
const response = await fetch('/api/users');
return response.json();
}
async updateUser(id: number, data: Partial<User>): Promise<User> {
const response = await fetch(`/api/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
return response.json();
}
}
// services/userService.test.ts
import { UserService } from './userService';
// Мокирование глобального fetch
const mockFetch = jest.fn();
global.fetch = mockFetch;
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
service = new UserService();
mockFetch.mockClear();
});
describe('getUser', () => {
it('should return user when API responds successfully', async () => {
const mockUser = { id: 1, name: 'Alice' };
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUser),
});
const result = await service.getUser(1);
expect(result).toEqual(mockUser);
expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
});
it('should return null when user not found', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
});
const result = await service.getUser(999);
expect(result).toBeNull();
});
it('should return null when network error occurs', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
const result = await service.getUser(1);
expect(result).toBeNull();
});
});
describe('getUsers', () => {
it('should return list of users', async () => {
const mockUsers = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockUsers),
});
const result = await service.getUsers();
expect(result).toEqual(mockUsers);
expect(result).toHaveLength(2);
});
});
describe('updateUser', () => {
it('should update user with correct data', async () => {
const updatedUser = { id: 1, name: 'Alice Updated' };
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(updatedUser),
});
const result = await service.updateUser(1, { name: 'Alice Updated' });
expect(result).toEqual(updatedUser);
expect(mockFetch).toHaveBeenCalledWith(
'/api/users/1',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ name: 'Alice Updated' }),
}),
);
});
});
});
7. Хуки жизненного цикла
describe('Lifecycle Hooks', () => {
// beforeAll — выполняется один раз перед всеми тестами в блоке
beforeAll(() => {
console.log('Setting up test suite...');
// Например: подключение к БД, создание fixtures
});
// afterAll — выполняется один раз после всех тестов
afterAll(() => {
console.log('Cleaning up test suite...');
// Например: отключение от БД, очистка fixtures
});
// beforeEach — выполняется перед КАЖДЫМ тестом
beforeEach(() => {
console.log('Setting up individual test...');
// Например: сброс state, очистка mocks
jest.clearAllMocks();
});
// afterEach — выполняется после КАЖДОГО теста
afterEach(() => {
console.log('Cleaning up individual test...');
// Например: восстановление original implementations
jest.restoreAllMocks();
});
it('test 1', () => {
expect(true).toBe(true);
});
it('test 2', () => {
expect(true).toBe(true);
});
});
// Вложенные хуки
describe('Outer describe', () => {
beforeAll(() => console.log('Outer beforeAll'));
afterAll(() => console.log('Outer afterAll'));
describe('Inner describe 1', () => {
beforeAll(() => console.log('Inner 1 beforeAll'));
afterAll(() => console.log('Inner 1 afterAll'));
it('test 1', () => {});
});
describe('Inner describe 2', () => {
beforeAll(() => console.log('Inner 2 beforeAll'));
afterAll(() => console.log('Inner 2 afterAll'));
it('test 2', () => {});
});
});
// Порядок выполнения:
// Outer beforeAll
// Inner 1 beforeAll
// Inner 1 afterAll
// Inner 2 beforeAll
// Inner 2 afterAll
// Outer afterAll
8. Параметризованные тесты
// 1. Тесты с each
describe('validateEmail', () => {
const testCases = [
{ email: 'user@example.com', expected: true },
{ email: 'invalid-email', expected: false },
{ email: 'user@', expected: false },
{ email: '@example.com', expected: false },
{ email: 'user.name@example.com', expected: true },
{ email: 'user+tag@example.com', expected: true },
{ email: '', expected: false },
];
it.each(testCases)(
'should validate %s correctly (expected: %j)',
({ email, expected }) => {
expect(validateEmail(email)).toBe(expected);
},
);
});
// 2. Тесты с each и описанием
describe('calculateDiscount', () => {
const scenarios = [
{ description: 'VIP customer', discount: 0.2, price: 100, expected: 80 },
{ description: 'Regular customer', discount: 0.1, price: 100, expected: 90 },
{ description: 'No discount', discount: 0, price: 100, expected: 100 },
{ description: 'Full discount', discount: 1, price: 100, expected: 0 },
];
it.each(scenarios)(
'$description: discount=$discount, price=$price → expected=$expected',
({ discount, price, expected }) => {
expect(calculateDiscount(discount, price)).toBe(expected);
},
);
});
// 3. Тесты с table форматом
describe('Math operations', () => {
it.each([
[1, 2, 3], // a + b = expected
[0, 0, 0],
[-1, 1, 0],
[100, 200, 300],
])('add(%d, %d) = %d', (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});
});
// 4. Асинхронные параметризованные тесты
describe('fetchUserData', () => {
const userIds = [1, 2, 3, 4, 5];
it.each(userIds)('should fetch user with id %d', async (userId) => {
const user = await fetchUserData(userId);
expect(user).toBeDefined();
expect(user.id).toBe(userId);
});
});
9. Покрытие кода (Coverage)
9.1 Типы покрытия
Какой процент операторов был выполнен
Какой процент условий (if/else, switch) был проверен
Какой процент функций был вызван
Какой процент строк был выполнен
9.2 Настройка покрытия
// jest.config.ts — Настройка покрытия
const config: Config = {
// Включить покрытие
collectCoverage: true,
// Директория для отчётов
coverageDirectory: 'coverage',
// Формат отчётов
coverageReporters: ['text', 'text-summary', 'lcov', 'html', 'json'],
// Файлы для анализа
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/index.ts',
'!src/types/**',
'!src/**/*.stories.{ts,tsx}',
'!src/**/*.test.{ts,tsx}',
'!src/**/*.spec.{ts,tsx}',
'!src/config/**',
],
// Минимальные пороги
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
// Пороги для конкретных файлов
'src/services/api.ts': {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
},
// Исключения из покрытия
coveragePathIgnorePatterns: [
'/node_modules/',
'/dist/',
'/coverage/',
],
};
// Запуск с покрытием
// npm run test:coverage
// Просмотр HTML отчёта
// Откройте coverage/lcov-report/index.html
10. Snapshot тестирование
// components/UserCard.tsx
interface Props {
user: {
id: number;
name: string;
email: string;
avatar?: string;
};
}
export function UserCard({ user }: Props) {
return (
<div className="user-card">
<img src={user.avatar || '/default-avatar.png'} alt={user.name} />
<h3>{user.name}</h3>
<p>{user.email}</p>
<span>ID: {user.id}</span>
</div>
);
}
// components/UserCard.test.tsx — Snapshot тесты
import { render } from '@testing-library/react';
import { UserCard } from './UserCard';
describe('UserCard', () => {
const mockUser = {
id: 1,
name: 'Alice',
email: 'alice@example.com',
};
it('should render correctly', () => {
const { container } = render(<UserCard user={mockUser} />);
expect(container).toMatchSnapshot();
});
it('should render with avatar', () => {
const userWithAvatar = {
...mockUser,
avatar: 'https://example.com/avatar.jpg',
};
const { container } = render(<UserCard user={userWithAvatar} />);
expect(container).toMatchSnapshot();
});
it('should render inline snapshot', () => {
const { getByText } = render(<UserCard user={mockUser} />);
expect(getByText('Alice')).toMatchInlineSnapshot();
});
});
// Обновление снимков:
// npm test -- -u
11. Мокирование модулей и API
// 1. Мокирование модулей
jest.mock('./services/database', () => ({
connect: jest.fn(),
disconnect: jest.fn(),
query: jest.fn(),
}));
import * as db from './services/database';
const mockDb = db as jest.Mocked<typeof db>;
describe('Database Service', () => {
beforeEach(() => {
mockDb.connect.mockResolvedValue(undefined);
mockDb.query.mockResolvedValue([{ id: 1 }]);
});
it('should connect to database', async () => {
await db.connect();
expect(mockDb.connect).toHaveBeenCalled();
});
});
// 2. Мокирование API с MSW (Mock Service Worker)
// src/mocks/handlers.ts
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const handlers = [
rest.get('/api/users/:id', (req, res, ctx) => {
return res(
ctx.json({ id: 1, name: 'Alice' }),
);
}),
rest.post('/api/users', (req, res, ctx) => {
return res(ctx.json({ id: 2, name: 'Bob' }));
}),
];
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('API calls with MSW', () => {
it('should fetch user', async () => {
const user = await fetchUser(1);
expect(user).toEqual({ id: 1, name: 'Alice' });
});
it('should handle API error', async () => {
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.status(404));
}),
);
const user = await fetchUser(1);
expect(user).toBeNull();
});
});
12. Best Practices
Архитектура тестов
- Следуйте паттерну AAA (Arrange, Act, Assert)
- Один тест проверяет одну вещь
- Используйте describe для группировки
- Именуйте тесты описательно
- Избегайте зависимостей между тестами
Мокирование
- Мокируйте на границах модулей
- Используйте jest.mock() для модулей
- Используйте jest.fn() для функций
- Очищайте моки в beforeEach
- Не мокируйте реализацию, мокируйте интерфейс
Паттерн AAA (Arrange, Act, Assert)
it('should calculate total price with discount', () => {
// Arrange — подготовка данных
const items = [
{ price: 100, quantity: 2 },
{ price: 50, quantity: 1 },
];
const discount = 0.1;
// Act — действие
const total = calculateTotal(items, discount);
// Assert — проверка результата
expect(total).toBe(225); // (200 + 50) * 0.9
});
13. Интеграция с CI/CD
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run TypeScript check
run: npm run typecheck
- name: Run tests
run: npm run test:ci
- name: Upload coverage
uses: codecov/codecov-action@v3
if: matrix.node-version == 20
with:
files: ./coverage/lcov.info
flags: unittests
name: codecov-umbrella
Итоги урока
В этом уроке мы изучили основы тестирования TypeScript кода с помощью Jest. Ключевые моменты:
- Jest — самый популярный тестовый фреймворк для JavaScript/TypeScript
- ts-jest — трансформер для работы Jest с TypeScript
- describe/it/expect — основные строительные блоки тестов
- Матчеры —богатый набор assertions для проверки результатов
- Мокирование — jest.fn(), jest.mock(), jest.spyOn()
- Асинхронность — async/await и промисы в тестах
- Coverage — измерение покрытия кода
- Snapshots — тестирование вывода
- CI/CD — автоматизация тестов в пайплайне
Тесты — это инвестиция в качество вашего кода. Хорошо протестированный код легче поддерживать, рефакторить и расширять. В следующем уроке мы изучим продвинутые паттерны типизации в TypeScript.
Тестирование: Jest + TypeScript
10 вопросов