msw для TypeScript: полное руководство с примерами
Что это и зачем нужна?
Mock Service Worker (msw) — это библиотека для перехвата и мокирования сетевых запросов на уровне Service Worker API. В отличие от традиционных решений (например, axios-mock-adapter или nock), msw работает на уровне браузера или Node.js, перехватывая реальные HTTP-запросы и возвращая заранее заданные ответы. Это позволяет тестировать приложения без необходимости запускать реальный сервер или изменять код приложения.
Основная цель msw — предоставить единый инструмент для мокирования API как в браузерных тестах (с использованием реального Service Worker), так и в серверных (Node.js). Библиотека особенно полезна для разработчиков, использующих TypeScript, так как предоставляет строгую типизацию для всех запросов и ответов. msw идеально подходит для юнит-тестов, интеграционных тестов, а также для разработки фронтенда в изоляции от бэкенда.
Ключевое преимущество msw — это то, что моки работают на уровне сети, а не на уровне кода. Это означает, что любые библиотеки для выполнения HTTP-запросов (fetch, axios, graphql-request и т.д.) будут автоматически перехвачены без дополнительной настройки. Библиотека поддерживает REST API, GraphQL, а также позволяет обрабатывать WebSocket соединения (через расширения).
Установка
Для установки msw в проект на TypeScript выполните одну из следующих команд:
# npm
npm install msw --save-dev
# yarn
yarn add msw --dev
# pnpm
pnpm add msw --save-dev
После установки необходимо инициализировать Service Worker для браузерного использования:
npx msw init public/ --save
Для серверного использования (Node.js) дополнительная инициализация не требуется.
Быстрый старт — минимальный рабочий пример
Создадим простой мок для REST API. Пример включает определение обработчика, настройку сервера и тестовый запрос.
// handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://api.example.com/user', () => {
return HttpResponse.json({
id: 'c7b3d8e0-5e0b-4b0f-8b3a-3b9f4b3d3b3f',
firstName: 'John',
lastName: 'Maverick',
});
}),
];
// server.ts (для Node.js)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// test.ts
import { server } from './server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('should fetch user', async () => {
const response = await fetch('https://api.example.com/user');
const data = await response.json();
expect(data.firstName).toBe('John');
});
Основные методы и функции
1. http.get()
Сигнатура: http.get(url: string | RegExp, resolver: (info: HttpRequestInfo) => HttpResponse)
Описание: Создает обработчик для GET-запроса по указанному URL. Поддерживает точные строки, регулярные выражения и шаблоны с параметрами.
import { http, HttpResponse } from 'msw';
export const handler = http.get('https://api.example.com/users/:id', ({ params }) => {
const { id } = params;
return HttpResponse.json({ id, name: 'User ' + id });
});
2. http.post()
Сигнатура: http.post(url: string | RegExp, resolver: (info: HttpRequestInfo) => HttpResponse)
Описание: Создает обработчик для POST-запроса. Позволяет читать тело запроса через request.json().
import { http, HttpResponse } from 'msw';
export const handler = http.post('https://api.example.com/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 'new-id', ...body }, { status: 201 });
});
3. http.all()
Сигнатура: http.all(url: string | RegExp, resolver: (info: HttpRequestInfo) => HttpResponse)
Описание: Создает обработчик для любого HTTP-метода (GET, POST, PUT, DELETE и т.д.) по указанному URL.
import { http, HttpResponse } from 'msw';
export const handler = http.all('https://api.example.com/*', ({ request }) => {
return HttpResponse.json({ method: request.method, url: request.url });
});
4. HttpResponse.json()
Сигнатура: HttpResponse.json(body: any, init?: ResponseInit)
Описание: Создает HTTP-ответ с JSON-телом. Вторым аргументом можно передать статус, заголовки и другие параметры.
import { HttpResponse } from 'msw';
const response = HttpResponse.json(
{ message: 'Success' },
{ status: 200, headers: { 'X-Custom': 'value' } }
);
5. HttpResponse.text()
Сигнатура: HttpResponse.text(body: string, init?: ResponseInit)
Описание: Создает HTTP-ответ с текстовым телом. Полезно для мокирования HTML, CSV или plain text ответов.
import { HttpResponse } from 'msw';
const response = HttpResponse.text('Hello, World!', {
status: 200,
headers: { 'Content-Type': 'text/plain' },
});
6. HttpResponse.xml()
Сигнатура: HttpResponse.xml(body: string, init?: ResponseInit)
Описание: Создает HTTP-ответ с XML-телом. Автоматически устанавливает заголовок Content-Type в application/xml.
import { HttpResponse } from 'msw';
const response = HttpResponse.xml('<root><item>value</item></root>');
7. HttpResponse.arrayBuffer()
Сигнатура: HttpResponse.arrayBuffer(body: ArrayBuffer, init?: ResponseInit)
Описание: Создает HTTP-ответ с бинарными данными (ArrayBuffer). Используется для мокирования изображений, файлов и других бинарных ресурсов.
import { HttpResponse } from 'msw';
const buffer = new ArrayBuffer(8);
const response = HttpResponse.arrayBuffer(buffer, {
headers: { 'Content-Type': 'application/octet-stream' },
});
8. delay()
Сигнатура: delay(ms: number): Promise<void>
Описание: Вспомогательная функция для добавления искусственной задержки в обработчике. Полезна для симуляции сетевых задержек.
import { http, HttpResponse, delay } from 'msw';
export const handler = http.get('https://api.example.com/slow', async () => {
await delay(2000); // 2 секунды задержки
return HttpResponse.json({ status: 'ok' });
});
9. setupServer()
Сигнатура: setupServer(...handlers: HttpHandler[]): SetupServerApi
Описание: Создает сервер для мокирования в среде Node.js. Возвращает объект с методами listen, close, resetHandlers и use.
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterAll(() => server.close());
10. setupWorker()
Сигнатура: setupWorker(...handlers: HttpHandler[]): SetupWorkerApi
Описание: Создает Service Worker для мокирования в браузере. Используется в development-режиме или в браузерных тестах.
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
const worker = setupWorker(...handlers);
worker.start();
11. bypass()
Сигнатура: bypass(request: Request): Promise<Response>
Описание: Позволяет пропустить запрос через мок и выполнить реальный сетевой запрос. Используется внутри обработчика для частичного мокирования.
import { http, HttpResponse, bypass } from 'msw';
export const handler = http.get('https://api.example.com/proxy', async ({ request }) => {
if (request.headers.get('x-mock') === 'true') {
return HttpResponse.json({ mocked: true });
}
const realResponse = await bypass(request);
return realResponse;
});
Реальный практический пример
Рассмотрим полноценный пример тестирования приложения для управления задачами (Todo App) с использованием msw и TypeScript. Приложение взаимодействует с REST API для CRUD-операций.
// types.ts
export interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: string;
}
// api.ts
export class TodoApi {
private baseUrl = 'https://api.example.com/todos';
async getAll(): Promise<Todo[]> {
const response = await fetch(this.baseUrl);
if (!response.ok) throw new Error('Failed to fetch todos');
return response.json();
}
async create(title: string): Promise<Todo> {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, completed: false }),
});
if (!response.ok) throw new Error('Failed to create todo');
return response.json();
}
async update(id: string, data: Partial<Todo>): Promise<Todo> {
const response = await fetch(`${this.baseUrl}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to update todo');
return response.json();
}
async delete(id: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete todo');
}
}
// handlers.ts
import { http, HttpResponse, delay } from 'msw';
import { Todo } from './types';
const todos: Todo[] = [
{ id: '1', title: 'Learn MSW', completed: false, createdAt: '2024-01-01' },
{ id: '2', title: 'Write tests', completed: true, createdAt: '2024-01-02' },
];
export const handlers = [
http.get('https://api.example.com/todos', async () => {
await delay(100);
return HttpResponse.json(todos);
}),
http.post('https://api.example.com/todos', async ({ request }) => {
await delay(150);
const body = await request.json() as { title: string };
const newTodo: Todo = {
id: String(Date.now()),
title: body.title,
completed: false,
createdAt: new Date().toISOString(),
};
todos.push(newTodo);
return HttpResponse.json(newTodo, { status: 201 });
}),
http.patch('https://api.example.com/todos/:id', async ({ params, request }) => {
await delay(100);
const { id } = params;
const body = await request.json() as Partial<Todo>;
const index = todos.findIndex(t => t.id === id);
if (index === -1) {
return HttpResponse.json({ error: 'Todo not found' }, { status: 404 });
}
todos[index] = { ...todos[index], ...body };
return HttpResponse.json(todos[index]);
}),
http.delete('https://api.example.com/todos/:id', async ({ params }) => {
await delay(100);
const { id } = params;
const index = todos.findIndex(t => t.id === id);
if (index === -1) {
return HttpResponse.json({ error: 'Todo not found' }, { status: 404 });
}
todos.splice(index, 1);
return HttpResponse.json(null, { status: 204 });
}),
];
// server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// todo.test.ts
import { server } from './server';
import { TodoApi } from './api';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('TodoApi', () => {
const api = new TodoApi();
test('should fetch all todos', async () => {
const todos = await api.getAll();
expect(todos).toHaveLength(2);
expect(todos[0].title).toBe('Learn MSW');
});
test('should create a new todo', async () => {
const newTodo = await api.create('Test MSW');
expect(newTodo.title).toBe('Test MSW');
expect(newTodo.completed).toBe(false);
expect(newTodo.id).toBeDefined();
});
test('should update a todo', async () => {
const updated = await api.update('1', { completed: true });
expect(updated.completed).toBe(true);
});
test('should delete a todo', async () => {
await api.delete('2');
const todos = await api.getAll();
expect(todos).toHaveLength(2); // один остался от create, один изначальный
});
test('should handle 404 on update', async () => {
await expect(api.update('999', { title: 'Ghost' })).rejects.toThrow('Failed to update todo');
});
test('should handle network error', async () => {
server.use(
http.get('https://api.example.com/todos', () => {
return HttpResponse.error();
})
);
await expect(api.getAll()).rejects.toThrow('Failed to fetch todos');
});
});
Советы по производительности и типичные ошибки
Советы по производительности
- Используйте
server.listen()один раз: В тестах вызывайте