Перейти к содержимому

50. Тестирование TypeScript

Йоу, кодеры! Сегодня мы поговорим о том, как поддерживать наш TypeScript-код в отличной форме. Нет, не с помощью спортзала, а с помощью тестирования! Вы уже знаете, что TypeScript помогает нам ловить ошибки на этапе компиляции, но давайте будем честны: даже самый строгий тип не спасет от некорректной бизнес-логики или проблем в рантайме. Вот тут-то и приходит на помощь тестирование – наш личный фитнес-тренер для кода.

Тестирование – это процесс проверки работоспособности нашего кода. Мы пишем специальные кусочки кода (тесты), которые вызывают функции, методы или компоненты нашей программы с определенными входными данными и затем проверяют, соответствуют ли полученные результаты нашим ожиданиям.

Почему это критично для TypeScript?

  1. Валидация логики: TypeScript проверяет типы, но не логику. Он не знает, должна ли функция sum(1, 2) вернуть 3 или 4.
  2. Рефакторинг с уверенностью: Когда у вас есть надежный набор тестов, вы можете смело переписывать и улучшать свой код, зная, что если что-то сломается, тесты немедленно об этом сообщат.
  3. Документация: Тесты часто служат живой документацией о том, как должны работать различные части вашей системы.
  4. Снижение ошибок в продакшене: Чем больше ошибок мы поймаем на этапе разработки, тем меньше их попадет к нашим пользователям.

В мире JavaScript/TypeScript чаще всего используются фреймворки типа Jest или Vitest. Мы будем использовать Jest в наших примерах, но концепции применимы и к Vitest (который, к слову, зачастую быстрее). Jest имеет отличную встроенную поддержку TypeScript через ts-jest или нативные возможности.

Начнем с простого: протестируем обычную функцию.

Представьте, у нас есть утилита для форматирования имени.

src/utils/formatters.ts
export function formatFullName(firstName: string, lastName: string, middleName?: string): string {
if (!firstName || !lastName) {
throw new Error('First name and last name are required.');
}
return [firstName, middleName, lastName].filter(Boolean).join(' ');
}
export function capitalizeFirstLetter(text: string): string {
if (!text) return '';
return text.charAt(0).toUpperCase() + text.slice(1);
}

А теперь напишем для этого тесты. Обычно тесты лежат рядом с тестируемым файлом или в отдельной папке __tests__.

src/utils/formatters.test.ts
import { formatFullName, capitalizeFirstLetter } from './formatters';
describe('formatFullName', () => { // describe группирует связанные тесты
it('should format a full name with first and last name', () => { // it описывает конкретный тестовый сценарий
const result = formatFullName('Иван', 'Петров');
expect(result).toBe('Иван Петров'); // expect - это утверждение, toBe - матчер для сравнения по значению
});
it('should format a full name with first, middle, and last name', () => {
const result = formatFullName('Анна', 'Смирнова', 'Ивановна');
expect(result).toBe('Анна Ивановна Смирнова');
});
it('should throw an error if first name is missing', () => {
// expect(() => ...) для тестирования функций, которые могут выбрасывать ошибки
expect(() => formatFullName('', 'Сидоров')).toThrow('First name and last name are required.');
});
});
describe('capitalizeFirstLetter', () => {
it('should capitalize the first letter of a string', () => {
expect(capitalizeFirstLetter('привет')).toBe('Привет');
});
it('should return an empty string for an empty input', () => {
expect(capitalizeFirstLetter('')).toBe('');
});
it('should handle strings that are already capitalized', () => {
expect(capitalizeFirstLetter('Hello')).toBe('Hello');
});
});

Большинство современных приложений работают с асинхронными операциями: запросы к API, работа с базой данных, чтение файлов. Jest отлично справляется и с этим.

Представим сервис, который загружает данные пользователя.

src/services/userService.ts
interface User {
id: number;
name: string;
email: string;
}
// Предполагаем, что это реальная функция, которая делает fetch
async function fetchUserFromApi(userId: number): Promise<User> {
// В реальном приложении здесь был бы fetch или axios
return new Promise((resolve) => {
setTimeout(() => {
if (userId === 1) {
resolve({ id: 1, name: 'Яша', email: '[email protected]' });
} else {
// Для простоты, другие ID вызовут ошибку
// В реальной жизни API может возвращать 404
throw new Error('User not found');
}
}, 100); // Имитируем задержку сети
});
}
export class UserService {
constructor(private apiFetcher: (userId: number) => Promise<User>) {}
async getUserById(id: number): Promise<User> {
try {
const user = await this.apiFetcher(id);
return user;
} catch (error) {
console.error(`Failed to fetch user ${id}:`, error);
throw new Error(`Could not retrieve user with ID: ${id}`);
}
}
}

Теперь протестируем UserService. Нам нужно будет “замокать” (mock) apiFetcher, чтобы не делать реальные сетевые запросы во время тестов.

src/services/userService.test.ts
import { UserService } from './userService';
describe('UserService', () => {
let mockApiFetcher: jest.Mock<Promise<any>, [number]>; // Типизируем мок
beforeEach(() => {
// Сбрасываем мок перед каждым тестом, чтобы они были независимы
mockApiFetcher = jest.fn();
});
it('should return a user when API call is successful', async () => {
const mockUser = { id: 1, name: 'Тестовый Пользователь', email: '[email protected]' };
// Указываем, что мок должен вернуть, когда его вызовут
mockApiFetcher.mockResolvedValueOnce(mockUser);
const service = new UserService(mockApiFetcher);
const result = await service.getUserById(1);
expect(mockApiFetcher).toHaveBeenCalledWith(1); // Проверяем, что мок был вызван с нужным ID
expect(result).toEqual(mockUser); // Проверяем, что результат соответствует моку
});
it('should throw an error when API call fails', async () => {
const errorMessage = 'Network Error';
// Указываем, что мок должен отклонить промис с ошибкой
mockApiFetcher.mockRejectedValueOnce(new Error(errorMessage));
const service = new UserService(mockApiFetcher);
// Используем expect(...).rejects для асинхронных ошибок
await expect(service.getUserById(999)).rejects.toThrow('Could not retrieve user with ID: 999');
expect(mockApiFetcher).toHaveBeenCalledWith(999);
});
});

В предыдущем примере мы уже начали мокировать. Мокирование — это замена реальной зависимости поддельной, которая контролируется тестом. Это позволяет изолировать тестируемый юнит и сфокусироваться только на его логике, не беспокоясь о поведении других систем.

Помимо jest.fn(), есть jest.spyOn() для мокирования методов уже существующих объектов/классов, и jest.mock() для мокирования целых модулей.

Представим систему уведомлений, которая использует внешний сервис логирования.

src/services/logger.ts
export const logger = {
info: (message: string, data?: any) => {
console.log(`[INFO] ${message}`, data || '');
},
error: (message: string, error?: Error) => {
console.error(`[ERROR] ${message}`, error || '');
}
};
// src/services/notificationService.ts
import { logger } from './logger';
export enum NotificationType {
Email = 'email',
SMS = 'sms',
}
export interface NotificationPayload {
recipient: string;
message: string;
type: NotificationType;
}
export class NotificationService {
async sendNotification(payload: NotificationPayload): Promise<boolean> {
logger.info(`Attempting to send ${payload.type} notification to ${payload.recipient}`);
try {
// Здесь была бы реальная логика отправки (email-клиент, SMS-шлюз и т.д.)
if (payload.message.includes('error')) {
throw new Error('Simulated sending error');
}
logger.info('Notification sent successfully.', payload);
return true;
} catch (error: any) {
logger.error('Failed to send notification.', error);
return false;
}
}
}

Тестируем NotificationService, мокируя logger.

src/services/notificationService.test.ts
import { NotificationService, NotificationType } from './notificationService';
import { logger } from './logger'; // Импортируем, чтобы можно было замокать
// Полностью мокаем модуль logger
// Jest будет искать файл '__mocks__/logger.ts' или автоматически генерировать мок
jest.mock('./logger', () => ({
logger: {
info: jest.fn(), // Заменяем реальные методы на моки
error: jest.fn(),
},
}));
// Приводим logger к типу JestMocked, чтобы получить доступ к его методам моков
const mockedLogger = logger as jest.Mocked<typeof logger>;
describe('NotificationService', () => {
let notificationService: NotificationService;
beforeEach(() => {
notificationService = new NotificationService();
// Сбрасываем все вызовы моков перед каждым тестом
mockedLogger.info.mockClear();
mockedLogger.error.mockClear();
});
it('should successfully send a notification and log info', async () => {
const payload = { recipient: '[email protected]', message: 'Hello', type: NotificationType.Email };
const result = await notificationService.sendNotification(payload);
expect(result).toBe(true);
expect(mockedLogger.info).toHaveBeenCalledTimes(2); // Начало и успешное завершение
expect(mockedLogger.info).toHaveBeenCalledWith(
`Attempting to send ${payload.type} notification to ${payload.recipient}`
);
expect(mockedLogger.info).toHaveBeenCalledWith('Notification sent successfully.', payload);
expect(mockedLogger.error).not.toHaveBeenCalled(); // Ошибок быть не должно
});
it('should handle sending errors and log an error', async () => {
const payload = { recipient: '[email protected]', message: 'Contains error', type: NotificationType.Email };
const result = await notificationService.sendNotification(payload);
expect(result).toBe(false);
expect(mockedLogger.info).toHaveBeenCalledTimes(1); // Только начальное сообщение
expect(mockedLogger.error).toHaveBeenCalledTimes(1); // Должна быть зафиксирована ошибка
expect(mockedLogger.error).toHaveBeenCalledWith(
'Failed to send notification.',
expect.any(Error) // Проверяем, что был передан объект Error
);
});
});

Когда мы работаем с дженериками, TypeScript проверяет типы на этапе компиляции. Наше тестирование сосредоточено на проверке того, что функция ведет себя корректно для различных типов данных, которые она может обрабатывать, а не на проверке самой типизации (для этого есть другие инструменты, типа tsd).

Рассмотрим универсальную функцию для фильтрации массива объектов.

src/utils/arrayUtils.ts
export function filterByProperty<T, K extends keyof T>(arr: T[], property: K, value: T[K]): T[] {
return arr.filter(item => item[property] === value);
}
export function findUniqueItems<T>(arr: T[]): T[] {
// Используем Set для уникальных значений
return Array.from(new Set(arr));
}

Теперь тесты для этих дженерик-функций:

src/utils/arrayUtils.test.ts
import { filterByProperty, findUniqueItems } from './arrayUtils';
describe('filterByProperty', () => {
const users = [
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
{ id: 3, name: 'Charlie', role: 'admin' },
{ id: 4, name: 'David', role: 'guest' },
];
it('should filter an array of objects by a string property', () => {
const admins = filterByProperty(users, 'role', 'admin');
expect(admins).toEqual([
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 3, name: 'Charlie', role: 'admin' },
]);
});
it('should filter an array of objects by a number property', () => {
const userById = filterByProperty(users, 'id', 2);
expect(userById).toEqual([
{ id: 2, name: 'Bob', role: 'user' },
]);
});
it('should return an empty array if no items match the criteria', () => {
const developers = filterByProperty(users, 'role', 'developer');
expect(developers).toEqual([]);
});
it('should handle empty input array', () => {
expect(filterByProperty([], 'id', 1)).toEqual([]);
});
});
describe('findUniqueItems', () => {
it('should return unique items from a number array', () => {
const numbers = [1, 2, 2, 3, 1, 4];
expect(findUniqueItems(numbers)).toEqual([1, 2, 3, 4]);
});
it('should return unique items from a string array', () => {
const strings = ['apple', 'banana', 'apple', 'orange'];
expect(findUniqueItems(strings)).toEqual(['apple', 'banana', 'orange']);
});
it('should handle an empty array', () => {
expect(findUniqueItems([])).toEqual([]);
});
it('should handle an array with all unique items', () => {
const unique = [1, 'a', true];
expect(findUniqueItems(unique)).toEqual([1, 'a', true]);
});
});
  1. Забыли await в асинхронных тестах: Если вы тестируете асинхронную функцию, но не используете await (или не возвращаете промис), тест может завершиться до того, как асинхронная операция будет выполнена, и дать ложноположительный результат.
    • Решение: Всегда используйте async/await с expect, который поддерживает промисы (например, expect(...).resolves.toBe(...) или expect(...).rejects.toThrow(...)).
  2. Неправильное мокирование: Мокирование — мощный инструмент, но его неправильное использование может привести к тому, что вы тестируете мок, а не ваш код. Или моки слишком сложные.
    • Решение: Мокируйте только необходимые зависимости. Используйте jest.mock, jest.fn, jest.spyOn для разных сценариев. Старайтесь, чтобы моки были максимально простыми.
  3. Тестирование реализации, а не поведения: Тесты должны проверять что делает функция, а не как она это делает. Если вы пишете тесты, которые тесно связаны с внутренней реализацией (например, проверяете приватные методы или слишком много деталей моков), то при рефакторинге вам придется переписывать кучу тестов.
    • Решение: Сосредоточьтесь на публичном API. Тестируйте вход/выход, а не промежуточные шаги, если это не критично для бизнес-логики.
  4. Игнорирование ошибок TypeScript в тестовых файлах: Убедитесь, что ваш tsconfig.json настроен так, чтобы включать тестовые файлы и проверять их типы. Иначе вы теряете одно из главных преимуществ TypeScript.
    • Решение: Включите тестовые папки в include вашего tsconfig.json или создайте отдельный tsconfig.test.json.
  5. Неполное тестирование граничных случаев: Пустые входные данные, null/undefined, ошибки API, большие числа, пустые массивы — это все граничные случаи, которые часто ломают код.
    • Решение: Всегда включайте тесты для граничных случаев.

Время применить полученные знания! Создайте следующие файлы и напишите для них тесты, используя Jest.

Задание 1: Тестирование класса с управлением состоянием

Создайте класс ShoppingCart с методами addItem, removeItem, getTotalPrice и clearCart.

src/shoppingCart.ts
interface Product {
id: string;
name: string;
price: number;
}
interface CartItem extends Product {
quantity: number;
}
export class ShoppingCart {
private items: Map<string, CartItem>; // Используем Map для удобства
constructor() {
this.items = new Map();
}
addItem(product: Product, quantity: number): void {
// Реализовать добавление товара. Если товар уже есть, увеличить количество.
}
removeItem(productId: string): void {
// Реализовать удаление товара по ID. Если количество становится 0, удалить полностью.
}
getTotalPrice(): number {
// Реализовать подсчет общей стоимости всех товаров в корзине.
return 0; // Заглушка
}
clearCart(): void {
// Реализовать очистку корзины.
}
getCartItems(): CartItem[] {
return Array.from(this.items.values());
}
}

Напишите shoppingCart.test.ts, который:

  • Добавляет новые товары.
  • Увеличивает количество существующих товаров.
  • Удаляет товары (как полностью, так и уменьшает количество).
  • Корректно подсчитывает общую стоимость.
  • Очищает корзину.

Задание 2: Тестирование асинхронной утилиты с возможными ошибками

Создайте асинхронную функцию retryOperation, которая пытается выполнить заданную асинхронную функцию до N раз, пока она не будет успешной или не истекут все попытки.

src/utils/retryUtils.ts
type AsyncOperation<T> = () => Promise<T>;
export async function retryOperation<T>(
operation: AsyncOperation<T>,
maxRetries: number = 3,
delayMs: number = 100
): Promise<T> {
// Реализовать логику повторных попыток.
// Используйте Promise.reject и throw для имитации ошибок.
// Возможно, вам понадобится `setTimeout` или `jest.advanceTimersByTime` для задержки в тестах.
return operation(); // Заглушка
}

Напишите retryUtils.test.ts, который:

  • Проверяет успешное выполнение с первой попытки.
  • Проверяет успешное выполнение после нескольких неудачных попыток.
  • Проверяет, что функция выбрасывает ошибку, если все попытки исчерпаны.
  • Использует Jest timers для контроля задержек (подсказка: jest.useFakeTimers(); и jest.runAllTimers();).

Задание 3: Мокирование и тестирование функции, использующей сторонний API

Создайте функцию fetchPopularPosts, которая делает запрос к “API” и возвращает список популярных постов. “API” должен быть замокан.

src/api/postApi.ts
export interface Post {
id: number;
title: string;
authorId: number;
views: number;
}
export const postApiService = {
async getPosts(): Promise<Post[]> {
// Здесь был бы реальный fetch.
// Для практики просто вернем заглушку, которую будем мокать.
return Promise.resolve([
{ id: 1, title: 'First Post', authorId: 101, views: 150 },
{ id: 2, title: 'Second Post', authorId: 102, views: 250 },
]);
}
};
export async function fetchPopularPosts(minViews: number): Promise<Post[]> {
// Реализовать: получить все посты через postApiService,
// отфильтровать по minViews и отсортировать по views (убыванию).
return []; // Заглушка
}

Напишите postApi.test.ts, который:

  • Мокирует postApiService.getPosts так, чтобы он возвращал предопределенный список постов.
  • Проверяет, что fetchPopularPosts корректно фильтрует посты по minViews.
  • Проверяет, что посты отсортированы по убыванию просмотров.
  • Проверяет, как функция обрабатывает ошибку, если postApiService.getPosts отклоняет промис.
  • Пишите тесты! Это не опционально, а часть процесса разработки качественного ПО.
  • Тестируйте небольшие, изолированные юниты: Каждая функция или метод должны быть протестированы независимо.
  • Используйте вспомогательные функции: Для создания тестовых данных (fixtures) или для повторяющихся настроек (beforeEach, afterEach).
  • Разделяйте тесты: Используйте describe для группировки тестов по функционалу или классу.
  • Стремитесь к читаемости: Тесты должны быть легко читаемы, как обычный код. Названия тестов (it) должны быть описательными.
  • Используйте инструменты покрытия кода: Такие как Istanbul (встроен в Jest) для отслеживания, какая часть вашего кода покрыта тестами. Стремитесь к высокому покрытию, но помните, что 100% покрытие не гарантирует отсутствие багов, только то, что весь код был выполнен хотя бы раз.