50. Тестирование TypeScript
TypeScript: Тестирование — Держи код в форме!
Заголовок раздела «TypeScript: Тестирование — Держи код в форме!»Йоу, кодеры! Сегодня мы поговорим о том, как поддерживать наш TypeScript-код в отличной форме. Нет, не с помощью спортзала, а с помощью тестирования! Вы уже знаете, что TypeScript помогает нам ловить ошибки на этапе компиляции, но давайте будем честны: даже самый строгий тип не спасет от некорректной бизнес-логики или проблем в рантайме. Вот тут-то и приходит на помощь тестирование – наш личный фитнес-тренер для кода.
Что такое тестирование и зачем оно TypeScript?
Заголовок раздела «Что такое тестирование и зачем оно TypeScript?»Тестирование – это процесс проверки работоспособности нашего кода. Мы пишем специальные кусочки кода (тесты), которые вызывают функции, методы или компоненты нашей программы с определенными входными данными и затем проверяют, соответствуют ли полученные результаты нашим ожиданиям.
Почему это критично для TypeScript?
- Валидация логики: TypeScript проверяет типы, но не логику. Он не знает, должна ли функция
sum(1, 2)вернуть3или4. - Рефакторинг с уверенностью: Когда у вас есть надежный набор тестов, вы можете смело переписывать и улучшать свой код, зная, что если что-то сломается, тесты немедленно об этом сообщат.
- Документация: Тесты часто служат живой документацией о том, как должны работать различные части вашей системы.
- Снижение ошибок в продакшене: Чем больше ошибок мы поймаем на этапе разработки, тем меньше их попадет к нашим пользователям.
В мире JavaScript/TypeScript чаще всего используются фреймворки типа Jest или Vitest. Мы будем использовать Jest в наших примерах, но концепции применимы и к Vitest (который, к слову, зачастую быстрее). Jest имеет отличную встроенную поддержку TypeScript через ts-jest или нативные возможности.
Базовое тестирование функций
Заголовок раздела «Базовое тестирование функций»Начнем с простого: протестируем обычную функцию.
Представьте, у нас есть утилита для форматирования имени.
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__.
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 отлично справляется и с этим.
Представим сервис, который загружает данные пользователя.
interface User { id: number; name: string; email: string;}
// Предполагаем, что это реальная функция, которая делает fetchasync function fetchUserFromApi(userId: number): Promise<User> { // В реальном приложении здесь был бы fetch или axios return new Promise((resolve) => { setTimeout(() => { if (userId === 1) { } 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, чтобы не делать реальные сетевые запросы во время тестов.
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 () => { // Указываем, что мок должен вернуть, когда его вызовут 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); });});Мокирование зависимостей (Mocking Dependencies)
Заголовок раздела «Мокирование зависимостей (Mocking Dependencies)»В предыдущем примере мы уже начали мокировать. Мокирование — это замена реальной зависимости поддельной, которая контролируется тестом. Это позволяет изолировать тестируемый юнит и сфокусироваться только на его логике, не беспокоясь о поведении других систем.
Помимо jest.fn(), есть jest.spyOn() для мокирования методов уже существующих объектов/классов, и jest.mock() для мокирования целых модулей.
Представим систему уведомлений, которая использует внешний сервис логирования.
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.tsimport { 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.
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 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 ); });});Тестирование функций с Generics
Заголовок раздела «Тестирование функций с Generics»Когда мы работаем с дженериками, TypeScript проверяет типы на этапе компиляции. Наше тестирование сосредоточено на проверке того, что функция ведет себя корректно для различных типов данных, которые она может обрабатывать, а не на проверке самой типизации (для этого есть другие инструменты, типа tsd).
Рассмотрим универсальную функцию для фильтрации массива объектов.
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));}Теперь тесты для этих дженерик-функций:
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]); });});Типичные Ошибки и Как Их Избежать
Заголовок раздела «Типичные Ошибки и Как Их Избежать»- Забыли
awaitв асинхронных тестах: Если вы тестируете асинхронную функцию, но не используетеawait(или не возвращаете промис), тест может завершиться до того, как асинхронная операция будет выполнена, и дать ложноположительный результат.- Решение: Всегда используйте
async/awaitсexpect, который поддерживает промисы (например,expect(...).resolves.toBe(...)илиexpect(...).rejects.toThrow(...)).
- Решение: Всегда используйте
- Неправильное мокирование: Мокирование — мощный инструмент, но его неправильное использование может привести к тому, что вы тестируете мок, а не ваш код. Или моки слишком сложные.
- Решение: Мокируйте только необходимые зависимости. Используйте
jest.mock,jest.fn,jest.spyOnдля разных сценариев. Старайтесь, чтобы моки были максимально простыми.
- Решение: Мокируйте только необходимые зависимости. Используйте
- Тестирование реализации, а не поведения: Тесты должны проверять что делает функция, а не как она это делает. Если вы пишете тесты, которые тесно связаны с внутренней реализацией (например, проверяете приватные методы или слишком много деталей моков), то при рефакторинге вам придется переписывать кучу тестов.
- Решение: Сосредоточьтесь на публичном API. Тестируйте вход/выход, а не промежуточные шаги, если это не критично для бизнес-логики.
- Игнорирование ошибок TypeScript в тестовых файлах: Убедитесь, что ваш
tsconfig.jsonнастроен так, чтобы включать тестовые файлы и проверять их типы. Иначе вы теряете одно из главных преимуществ TypeScript.- Решение: Включите тестовые папки в
includeвашегоtsconfig.jsonили создайте отдельныйtsconfig.test.json.
- Решение: Включите тестовые папки в
- Неполное тестирование граничных случаев: Пустые входные данные,
null/undefined, ошибки API, большие числа, пустые массивы — это все граничные случаи, которые часто ломают код.- Решение: Всегда включайте тесты для граничных случаев.
🎯 Практика
Заголовок раздела «🎯 Практика»Время применить полученные знания! Создайте следующие файлы и напишите для них тесты, используя Jest.
Задание 1: Тестирование класса с управлением состоянием
Создайте класс ShoppingCart с методами addItem, removeItem, getTotalPrice и clearCart.
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 раз, пока она не будет успешной или не истекут все попытки.
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” должен быть замокан.
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% покрытие не гарантирует отсутствие багов, только то, что весь код был выполнен хотя бы раз.