$ sudo teach IT

Модуль 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 Типы покрытия

Statements (Операторы)

Какой процент операторов был выполнен

Branches (Ветки)

Какой процент условий (if/else, switch) был проверен

Functions (Функции)

Какой процент функций был вызван

Lines (Строки)

Какой процент строк был выполнен

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 вопросов

Jest-тест для TypeScript функции

Premium