Async/await в TypeScript: практические паттерны тестирования
Асинхронный код в TypeScript стал стандартом современной разработки, но его тестирование требует особого подхода. В этой статье разберём практические паттерны тестирования async/await конструкций, которые помогут писать надёжные тесты для асинхронной логики.
Базовое тестирование async функций
Современные тестовые фреймворки (Jest, Vitest, Mocha) поддерживают async/await из коробки. Главное правило — тестовая функция должна быть async, если внутри используется await:
import { describe, it, expect } from 'vitest';
async function fetchUserData(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('User not found');
return response.json();
}
describe('fetchUserData', () => {
it('должна вернуть данные пользователя', async () => {
const user = await fetchUserData(1);
expect(user.id).toBe(1);
expect(user.name).toBeDefined();
});
});
Если забыть await, тест завершится до выполнения асинхронной операции, и проверки не сработают. TypeScript поможет отловить такие ошибки через типизацию Promise.
Мокирование асинхронных зависимостей
Для изоляции тестов нужно мокировать внешние вызовы. В Jest/Vitest это делается через vi.fn() или jest.fn():
import { vi } from 'vitest';
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('fetchUserData с моками', () => {
it('обрабатывает успешный ответ', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 1, name: 'Alice' })
});
const user = await fetchUserData(1);
expect(user.name).toBe('Alice');
expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
});
it('выбрасывает ошибку при 404', async () => {
mockFetch.mockResolvedValueOnce({ ok: false });
await expect(fetchUserData(999))
.rejects.toThrow('User not found');
});
});
Метод mockResolvedValueOnce возвращает resolved Promise, а mockRejectedValueOnce — rejected. Это позволяет тестировать как успешные сценарии, так и обработку ошибок.
Тестирование обработки ошибок
Async/await упрощает обработку ошибок через try/catch, но в тестах нужно проверять оба пути выполнения:
async function processData(data: string): Promise<Result> {
try {
const parsed = JSON.parse(data);
const validated = await validateSchema(parsed);
return { success: true, data: validated };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
describe('processData error handling', () => {
it('возвращает ошибку при невалидном JSON', async () => {
const result = await processData('invalid json');
expect(result.success).toBe(false);
expect(result.error).toContain('JSON');
});
it('возвращает ошибку при провале валидации', async () => {
vi.mocked(validateSchema).mockRejectedValueOnce(
new Error('Schema validation failed')
);
const result = await processData('{"valid": "json"}');
expect(result.success).toBe(false);
expect(result.error).toBe('Schema validation failed');
});
});
Параллельное выполнение и Promise.all
При тестировании параллельных операций важно проверить, что они действительно выполняются одновременно, а не последовательно:
async function fetchMultipleUsers(ids: number[]): Promise<User[]> {
const promises = ids.map(id => fetchUserData(id));
return Promise.all(promises);
}
describe('fetchMultipleUsers', () => {
it('выполняет запросы параллельно', async () => {
const startTime = Date.now();
mockFetch.mockImplementation(async (url: string) => {
await new Promise(resolve => setTimeout(resolve, 100));
const id = parseInt(url.split('/').pop()!);
return {
ok: true,
json: async () => ({ id, name: `User${id}` })
};
});
const users = await fetchMultipleUsers([1, 2, 3]);
const duration = Date.now() - startTime;
expect(users).toHaveLength(3);
expect(duration).toBeLessThan(200); // не 300+, значит параллельно
});
it('обрабатывает частичные ошибки', async () => {
mockFetch
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 1 }) })
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: 3 }) });
await expect(fetchMultipleUsers([1, 2, 3]))
.rejects.toThrow();
});
});
Подводные камни
- Забытый await — тест завершится раньше времени. Включите правило ESLint
@typescript-eslint/no-floating-promises. - Неочищенные моки — используйте
beforeEach(() => vi.clearAllMocks())для изоляции тестов. - Race conditions — при тестировании конкурентного кода добавляйте искусственные задержки через
vi.useFakeTimers(). - Unhandled rejections — всегда оборачивайте ожидаемые ошибки в
expect().rejectsили try/catch.
Вывод
Тестирование async/await в TypeScript требует внимания к деталям: правильное использование await в тестах, изоляция через моки, проверка обработки ошибок и корректное тестирование параллельных операций. Следуя этим паттернам, вы получите надёжные тесты, которые ловят баги до продакшена и документируют поведение асинхронного кода.