Prerequisites
- Understanding of REST APIs and HTTP protocols π
- Knowledge of Jest testing framework and async testing β‘
- Familiarity with React Testing Library and TypeScript π»
What you'll learn
- Test API calls with Mock Service Worker for realistic HTTP mocking π―
- Master testing error scenarios, network failures, and retry logic ποΈ
- Handle complex API interactions and integration testing patterns π
- Create maintainable test suites for API-driven applications β¨
π― Introduction
Welcome to the API testing command center! π If testing regular functions were like checking individual components in a factory, then testing API calls would be like testing the entire supply chain - complete with network requests, data transformation, error handling, and complex async workflows that need to work reliably even when external services are unavailable or behaving unpredictably!
API testing with Mock Service Worker (MSW) provides the most realistic testing environment by intercepting actual HTTP requests at the network level. Unlike simple mocking, MSW allows you to test your API layer exactly as it behaves in production, including error handling, request/response transformations, and complex async patterns.
By the end of this tutorial, youβll be a master of API testing, capable of thoroughly testing everything from simple GET requests to complex multi-step workflows with authentication, error recovery, and optimistic updates. Youβll learn to test API interactions with confidence, ensuring your applications handle all network scenarios gracefully. Letβs build some bulletproof API tests! π
π Understanding API Testing with MSW
π€ Why Mock Service Worker?
MSW provides network-level request interception, allowing you to test API interactions exactly as they occur in real applications without depending on external services.
// π Setting up comprehensive MSW testing environment
import React, { useState, useEffect, useCallback, useMemo, useContext, createContext } from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import '@testing-library/jest-dom';
// API response types and interfaces
interface User {
id: string;
name: string;
email: string;
avatar?: string;
isActive: boolean;
role: 'admin' | 'user' | 'moderator';
createdAt: string;
updatedAt: string;
}
interface Product {
id: string;
name: string;
price: number;
description: string;
category: string;
inStock: boolean;
stockCount: number;
imageUrl?: string;
tags: string[];
createdAt: string;
}
interface Order {
id: string;
userId: string;
items: OrderItem[];
total: number;
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
shippingAddress: Address;
createdAt: string;
updatedAt: string;
}
interface OrderItem {
productId: string;
quantity: number;
price: number;
product?: Product;
}
interface Address {
street: string;
city: string;
state: string;
zipCode: string;
country: string;
}
interface ApiError {
message: string;
code: string;
field?: string;
details?: Record<string, any>;
}
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
interface LoginRequest {
email: string;
password: string;
}
interface LoginResponse {
user: User;
token: string;
refreshToken: string;
expiresIn: number;
}
interface CreateProductRequest {
name: string;
price: number;
description: string;
category: string;
stockCount: number;
tags: string[];
}
interface UpdateProductRequest extends Partial<CreateProductRequest> {
id: string;
}
// API service with comprehensive error handling
class ApiService {
private baseUrl: string;
private token: string | null = null;
constructor(baseUrl: string = 'https://api.example.com') {
this.baseUrl = baseUrl;
}
setToken(token: string | null): void {
this.token = token;
}
private async makeRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (this.token) {
headers.Authorization = `Bearer ${this.token}`;
}
const config: RequestInit = {
...options,
headers,
};
try {
const response = await fetch(url, config);
if (!response.ok) {
const errorData = await response.json().catch(() => ({
message: 'An error occurred',
code: 'UNKNOWN_ERROR'
}));
throw new ApiError(
errorData.message || 'Request failed',
response.status,
errorData.code || 'HTTP_ERROR',
errorData
);
}
const contentType = response.headers.get('Content-Type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
}
return response.text() as unknown as T;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new ApiError('Network error', 0, 'NETWORK_ERROR');
}
throw new ApiError('Unknown error', 0, 'UNKNOWN_ERROR');
}
}
// Authentication endpoints
async login(credentials: LoginRequest): Promise<LoginResponse> {
return this.makeRequest<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
});
}
async logout(): Promise<void> {
await this.makeRequest<void>('/auth/logout', {
method: 'POST',
});
this.token = null;
}
async refreshToken(refreshToken: string): Promise<LoginResponse> {
return this.makeRequest<LoginResponse>('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
});
}
// User endpoints
async getCurrentUser(): Promise<User> {
return this.makeRequest<User>('/users/me');
}
async getUsers(page = 1, limit = 10): Promise<PaginatedResponse<User>> {
return this.makeRequest<PaginatedResponse<User>>(
`/users?page=${page}&limit=${limit}`
);
}
async getUserById(id: string): Promise<User> {
return this.makeRequest<User>(`/users/${id}`);
}
async updateUser(id: string, updates: Partial<User>): Promise<User> {
return this.makeRequest<User>(`/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
}
async deleteUser(id: string): Promise<void> {
return this.makeRequest<void>(`/users/${id}`, {
method: 'DELETE',
});
}
// Product endpoints
async getProducts(
page = 1,
limit = 10,
category?: string,
search?: string
): Promise<PaginatedResponse<Product>> {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
});
if (category) params.append('category', category);
if (search) params.append('search', search);
return this.makeRequest<PaginatedResponse<Product>>(`/products?${params}`);
}
async getProductById(id: string): Promise<Product> {
return this.makeRequest<Product>(`/products/${id}`);
}
async createProduct(product: CreateProductRequest): Promise<Product> {
return this.makeRequest<Product>('/products', {
method: 'POST',
body: JSON.stringify(product),
});
}
async updateProduct(product: UpdateProductRequest): Promise<Product> {
const { id, ...updates } = product;
return this.makeRequest<Product>(`/products/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
}
async deleteProduct(id: string): Promise<void> {
return this.makeRequest<void>(`/products/${id}`, {
method: 'DELETE',
});
}
// Order endpoints
async getOrders(page = 1, limit = 10): Promise<PaginatedResponse<Order>> {
return this.makeRequest<PaginatedResponse<Order>>(
`/orders?page=${page}&limit=${limit}`
);
}
async getOrderById(id: string): Promise<Order> {
return this.makeRequest<Order>(`/orders/${id}`);
}
async createOrder(orderData: {
items: Array<{ productId: string; quantity: number }>;
shippingAddress: Address;
}): Promise<Order> {
return this.makeRequest<Order>('/orders', {
method: 'POST',
body: JSON.stringify(orderData),
});
}
async updateOrderStatus(
id: string,
status: Order['status']
): Promise<Order> {
return this.makeRequest<Order>(`/orders/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
}
async cancelOrder(id: string): Promise<Order> {
return this.makeRequest<Order>(`/orders/${id}/cancel`, {
method: 'POST',
});
}
}
// Custom ApiError class
class ApiError extends Error {
constructor(
message: string,
public statusCode: number,
public code: string,
public details?: any
) {
super(message);
this.name = 'ApiError';
}
}
// Hook for API operations with comprehensive error handling
interface UseApiOptions {
immediate?: boolean;
retries?: number;
retryDelay?: number;
onSuccess?: (data: any) => void;
onError?: (error: ApiError) => void;
}
interface UseApiResult<T> {
data: T | null;
loading: boolean;
error: ApiError | null;
execute: (...args: any[]) => Promise<T>;
reset: () => void;
}
const useApi = <T>(
apiCall: (...args: any[]) => Promise<T>,
options: UseApiOptions = {}
): UseApiResult<T> => {
const {
immediate = false,
retries = 0,
retryDelay = 1000,
onSuccess,
onError
} = options;
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(immediate);
const [error, setError] = useState<ApiError | null>(null);
const execute = useCallback(
async (...args: any[]): Promise<T> => {
setLoading(true);
setError(null);
let lastError: ApiError;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const result = await apiCall(...args);
setData(result);
setLoading(false);
if (onSuccess) {
onSuccess(result);
}
return result;
} catch (err) {
lastError = err instanceof ApiError ? err : new ApiError(
'Unknown error',
0,
'UNKNOWN_ERROR'
);
if (attempt < retries) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}
setError(lastError!);
setLoading(false);
if (onError) {
onError(lastError!);
}
throw lastError!;
},
[apiCall, retries, retryDelay, onSuccess, onError]
);
const reset = useCallback(() => {
setData(null);
setLoading(false);
setError(null);
}, []);
useEffect(() => {
if (immediate) {
execute();
}
}, [immediate, execute]);
return {
data,
loading,
error,
execute,
reset
};
};
// Mock data for testing
const mockUsers: User[] = [
{
id: 'user-1',
name: 'John Doe',
email: '[email protected]',
isActive: true,
role: 'user',
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z'
},
{
id: 'user-2',
name: 'Jane Smith',
email: '[email protected]',
avatar: 'https://example.com/avatar2.jpg',
isActive: true,
role: 'admin',
createdAt: '2023-01-02T00:00:00Z',
updatedAt: '2023-01-02T00:00:00Z'
},
{
id: 'user-3',
name: 'Bob Johnson',
email: '[email protected]',
isActive: false,
role: 'moderator',
createdAt: '2023-01-03T00:00:00Z',
updatedAt: '2023-01-03T00:00:00Z'
}
];
const mockProducts: Product[] = [
{
id: 'product-1',
name: 'Wireless Headphones',
price: 99.99,
description: 'High-quality wireless headphones with noise cancellation',
category: 'electronics',
inStock: true,
stockCount: 50,
imageUrl: 'https://example.com/headphones.jpg',
tags: ['audio', 'wireless', 'electronics'],
createdAt: '2023-01-01T00:00:00Z'
},
{
id: 'product-2',
name: 'Smartphone',
price: 699.99,
description: 'Latest smartphone with advanced features',
category: 'electronics',
inStock: true,
stockCount: 25,
imageUrl: 'https://example.com/smartphone.jpg',
tags: ['mobile', 'electronics', 'communication'],
createdAt: '2023-01-02T00:00:00Z'
},
{
id: 'product-3',
name: 'Coffee Mug',
price: 15.99,
description: 'Ceramic coffee mug with ergonomic handle',
category: 'home',
inStock: false,
stockCount: 0,
imageUrl: 'https://example.com/mug.jpg',
tags: ['kitchen', 'ceramic', 'home'],
createdAt: '2023-01-03T00:00:00Z'
}
];
const mockOrders: Order[] = [
{
id: 'order-1',
userId: 'user-1',
items: [
{
productId: 'product-1',
quantity: 2,
price: 99.99
}
],
total: 199.98,
status: 'delivered',
shippingAddress: {
street: '123 Main St',
city: 'New York',
state: 'NY',
zipCode: '10001',
country: 'USA'
},
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-05T00:00:00Z'
}
];
// MSW request handlers
const handlers = [
// Authentication endpoints
rest.post('https://api.example.com/auth/login', (req, res, ctx) => {
const { email, password } = req.body as LoginRequest;
if (email === '[email protected]' && password === 'password123') {
const user = mockUsers[0];
return res(
ctx.status(200),
ctx.json<LoginResponse>({
user,
token: 'mock-jwt-token',
refreshToken: 'mock-refresh-token',
expiresIn: 3600
})
);
}
if (email === '[email protected]') {
return res(
ctx.status(401),
ctx.json({
message: 'Invalid credentials',
code: 'INVALID_CREDENTIALS'
})
);
}
if (email === '[email protected]') {
return res(
ctx.status(423),
ctx.json({
message: 'Account locked',
code: 'ACCOUNT_LOCKED'
})
);
}
return res(
ctx.status(400),
ctx.json({
message: 'Invalid request data',
code: 'VALIDATION_ERROR',
field: 'email'
})
);
}),
rest.post('https://api.example.com/auth/logout', (req, res, ctx) => {
const authHeader = req.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res(
ctx.status(401),
ctx.json({
message: 'Unauthorized',
code: 'UNAUTHORIZED'
})
);
}
return res(ctx.status(200));
}),
rest.post('https://api.example.com/auth/refresh', (req, res, ctx) => {
const { refreshToken } = req.body as { refreshToken: string };
if (refreshToken === 'mock-refresh-token') {
const user = mockUsers[0];
return res(
ctx.status(200),
ctx.json<LoginResponse>({
user,
token: 'new-mock-jwt-token',
refreshToken: 'new-mock-refresh-token',
expiresIn: 3600
})
);
}
return res(
ctx.status(401),
ctx.json({
message: 'Invalid refresh token',
code: 'INVALID_REFRESH_TOKEN'
})
);
}),
// User endpoints
rest.get('https://api.example.com/users/me', (req, res, ctx) => {
const authHeader = req.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res(
ctx.status(401),
ctx.json({
message: 'Unauthorized',
code: 'UNAUTHORIZED'
})
);
}
return res(
ctx.status(200),
ctx.json<User>(mockUsers[0])
);
}),
rest.get('https://api.example.com/users', (req, res, ctx) => {
const page = Number(req.url.searchParams.get('page')) || 1;
const limit = Number(req.url.searchParams.get('limit')) || 10;
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedUsers = mockUsers.slice(startIndex, endIndex);
return res(
ctx.status(200),
ctx.json<PaginatedResponse<User>>({
data: paginatedUsers,
pagination: {
page,
limit,
total: mockUsers.length,
totalPages: Math.ceil(mockUsers.length / limit)
}
})
);
}),
rest.get('https://api.example.com/users/:id', (req, res, ctx) => {
const { id } = req.params;
const user = mockUsers.find(u => u.id === id);
if (!user) {
return res(
ctx.status(404),
ctx.json({
message: 'User not found',
code: 'USER_NOT_FOUND'
})
);
}
return res(
ctx.status(200),
ctx.json<User>(user)
);
}),
rest.patch('https://api.example.com/users/:id', (req, res, ctx) => {
const { id } = req.params;
const updates = req.body as Partial<User>;
const userIndex = mockUsers.findIndex(u => u.id === id);
if (userIndex === -1) {
return res(
ctx.status(404),
ctx.json({
message: 'User not found',
code: 'USER_NOT_FOUND'
})
);
}
const updatedUser = { ...mockUsers[userIndex], ...updates, updatedAt: new Date().toISOString() };
mockUsers[userIndex] = updatedUser;
return res(
ctx.status(200),
ctx.json<User>(updatedUser)
);
}),
rest.delete('https://api.example.com/users/:id', (req, res, ctx) => {
const { id } = req.params;
const userIndex = mockUsers.findIndex(u => u.id === id);
if (userIndex === -1) {
return res(
ctx.status(404),
ctx.json({
message: 'User not found',
code: 'USER_NOT_FOUND'
})
);
}
if (id === 'user-1') {
return res(
ctx.status(403),
ctx.json({
message: 'Cannot delete admin user',
code: 'FORBIDDEN'
})
);
}
mockUsers.splice(userIndex, 1);
return res(ctx.status(204));
}),
// Product endpoints
rest.get('https://api.example.com/products', (req, res, ctx) => {
const page = Number(req.url.searchParams.get('page')) || 1;
const limit = Number(req.url.searchParams.get('limit')) || 10;
const category = req.url.searchParams.get('category');
const search = req.url.searchParams.get('search');
let filteredProducts = mockProducts;
if (category) {
filteredProducts = filteredProducts.filter(p => p.category === category);
}
if (search) {
const searchLower = search.toLowerCase();
filteredProducts = filteredProducts.filter(p =>
p.name.toLowerCase().includes(searchLower) ||
p.description.toLowerCase().includes(searchLower) ||
p.tags.some(tag => tag.toLowerCase().includes(searchLower))
);
}
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedProducts = filteredProducts.slice(startIndex, endIndex);
return res(
ctx.status(200),
ctx.json<PaginatedResponse<Product>>({
data: paginatedProducts,
pagination: {
page,
limit,
total: filteredProducts.length,
totalPages: Math.ceil(filteredProducts.length / limit)
}
})
);
}),
rest.get('https://api.example.com/products/:id', (req, res, ctx) => {
const { id } = req.params;
// Simulate slow network for testing
if (id === 'slow-product') {
return res(
ctx.delay(2000),
ctx.status(200),
ctx.json<Product>({
...mockProducts[0],
id: 'slow-product',
name: 'Slow Loading Product'
})
);
}
const product = mockProducts.find(p => p.id === id);
if (!product) {
return res(
ctx.status(404),
ctx.json({
message: 'Product not found',
code: 'PRODUCT_NOT_FOUND'
})
);
}
return res(
ctx.status(200),
ctx.json<Product>(product)
);
}),
rest.post('https://api.example.com/products', (req, res, ctx) => {
const productData = req.body as CreateProductRequest;
if (!productData.name || productData.name.length < 3) {
return res(
ctx.status(400),
ctx.json({
message: 'Product name must be at least 3 characters',
code: 'VALIDATION_ERROR',
field: 'name'
})
);
}
if (productData.price <= 0) {
return res(
ctx.status(400),
ctx.json({
message: 'Product price must be greater than 0',
code: 'VALIDATION_ERROR',
field: 'price'
})
);
}
const newProduct: Product = {
id: `product-${Date.now()}`,
...productData,
inStock: productData.stockCount > 0,
createdAt: new Date().toISOString()
};
mockProducts.push(newProduct);
return res(
ctx.status(201),
ctx.json<Product>(newProduct)
);
}),
rest.patch('https://api.example.com/products/:id', (req, res, ctx) => {
const { id } = req.params;
const updates = req.body as Partial<CreateProductRequest>;
const productIndex = mockProducts.findIndex(p => p.id === id);
if (productIndex === -1) {
return res(
ctx.status(404),
ctx.json({
message: 'Product not found',
code: 'PRODUCT_NOT_FOUND'
})
);
}
if (updates.price !== undefined && updates.price <= 0) {
return res(
ctx.status(400),
ctx.json({
message: 'Product price must be greater than 0',
code: 'VALIDATION_ERROR',
field: 'price'
})
);
}
const updatedProduct = {
...mockProducts[productIndex],
...updates,
inStock: updates.stockCount !== undefined ? updates.stockCount > 0 : mockProducts[productIndex].inStock
};
mockProducts[productIndex] = updatedProduct;
return res(
ctx.status(200),
ctx.json<Product>(updatedProduct)
);
}),
rest.delete('https://api.example.com/products/:id', (req, res, ctx) => {
const { id } = req.params;
const productIndex = mockProducts.findIndex(p => p.id === id);
if (productIndex === -1) {
return res(
ctx.status(404),
ctx.json({
message: 'Product not found',
code: 'PRODUCT_NOT_FOUND'
})
);
}
mockProducts.splice(productIndex, 1);
return res(ctx.status(204));
}),
// Order endpoints
rest.get('https://api.example.com/orders', (req, res, ctx) => {
const page = Number(req.url.searchParams.get('page')) || 1;
const limit = Number(req.url.searchParams.get('limit')) || 10;
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedOrders = mockOrders.slice(startIndex, endIndex);
return res(
ctx.status(200),
ctx.json<PaginatedResponse<Order>>({
data: paginatedOrders,
pagination: {
page,
limit,
total: mockOrders.length,
totalPages: Math.ceil(mockOrders.length / limit)
}
})
);
}),
rest.get('https://api.example.com/orders/:id', (req, res, ctx) => {
const { id } = req.params;
const order = mockOrders.find(o => o.id === id);
if (!order) {
return res(
ctx.status(404),
ctx.json({
message: 'Order not found',
code: 'ORDER_NOT_FOUND'
})
);
}
return res(
ctx.status(200),
ctx.json<Order>(order)
);
}),
rest.post('https://api.example.com/orders', (req, res, ctx) => {
const orderData = req.body as {
items: Array<{ productId: string; quantity: number }>;
shippingAddress: Address;
};
if (!orderData.items || orderData.items.length === 0) {
return res(
ctx.status(400),
ctx.json({
message: 'Order must have at least one item',
code: 'VALIDATION_ERROR',
field: 'items'
})
);
}
const invalidProduct = orderData.items.find(item =>
!mockProducts.find(p => p.id === item.productId)
);
if (invalidProduct) {
return res(
ctx.status(400),
ctx.json({
message: 'Invalid product in order',
code: 'INVALID_PRODUCT',
details: { productId: invalidProduct.productId }
})
);
}
const orderItems: OrderItem[] = orderData.items.map(item => {
const product = mockProducts.find(p => p.id === item.productId)!;
return {
productId: item.productId,
quantity: item.quantity,
price: product.price,
product
};
});
const total = orderItems.reduce(
(sum, item) => sum + (item.price * item.quantity),
0
);
const newOrder: Order = {
id: `order-${Date.now()}`,
userId: 'user-1',
items: orderItems,
total,
status: 'pending',
shippingAddress: orderData.shippingAddress,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
mockOrders.push(newOrder);
return res(
ctx.status(201),
ctx.json<Order>(newOrder)
);
}),
rest.patch('https://api.example.com/orders/:id/status', (req, res, ctx) => {
const { id } = req.params;
const { status } = req.body as { status: Order['status'] };
const orderIndex = mockOrders.findIndex(o => o.id === id);
if (orderIndex === -1) {
return res(
ctx.status(404),
ctx.json({
message: 'Order not found',
code: 'ORDER_NOT_FOUND'
})
);
}
const currentOrder = mockOrders[orderIndex];
if (currentOrder.status === 'delivered' && status !== 'delivered') {
return res(
ctx.status(400),
ctx.json({
message: 'Cannot change status of delivered order',
code: 'INVALID_STATUS_CHANGE'
})
);
}
const updatedOrder = {
...currentOrder,
status,
updatedAt: new Date().toISOString()
};
mockOrders[orderIndex] = updatedOrder;
return res(
ctx.status(200),
ctx.json<Order>(updatedOrder)
);
}),
rest.post('https://api.example.com/orders/:id/cancel', (req, res, ctx) => {
const { id } = req.params;
const orderIndex = mockOrders.findIndex(o => o.id === id);
if (orderIndex === -1) {
return res(
ctx.status(404),
ctx.json({
message: 'Order not found',
code: 'ORDER_NOT_FOUND'
})
);
}
const currentOrder = mockOrders[orderIndex];
if (['shipped', 'delivered'].includes(currentOrder.status)) {
return res(
ctx.status(400),
ctx.json({
message: 'Cannot cancel shipped or delivered order',
code: 'CANNOT_CANCEL_ORDER'
})
);
}
const cancelledOrder = {
...currentOrder,
status: 'cancelled' as const,
updatedAt: new Date().toISOString()
};
mockOrders[orderIndex] = cancelledOrder;
return res(
ctx.status(200),
ctx.json<Order>(cancelledOrder)
);
}),
// Network error simulation
rest.get('https://api.example.com/network-error', (req, res, ctx) => {
return res.networkError('Failed to connect');
}),
// Server error simulation
rest.get('https://api.example.com/server-error', (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({
message: 'Internal server error',
code: 'INTERNAL_SERVER_ERROR'
})
);
}),
// Timeout simulation
rest.get('https://api.example.com/timeout', (req, res, ctx) => {
return res(
ctx.delay(5000),
ctx.status(200),
ctx.json({ message: 'This will timeout' })
);
})
];
// Test server setup
const server = setupServer(...handlers);
export { server, ApiService, useApi, mockUsers, mockProducts, mockOrders };
π§ͺ Basic API Testing Patterns
π§ Setting Up MSW Test Environment
Learn to configure MSW for comprehensive API testing with proper setup and teardown.
// π― Comprehensive MSW test setup and configuration
// Test setup file (setupTests.ts)
import { server } from './mocks/server';
import '@testing-library/jest-dom';
// Enable API mocking before all tests
beforeAll(() => {
server.listen({
onUnhandledRequest: 'error' // Fail if any unhandled requests
});
});
// Reset any runtime request handlers between tests
afterEach(() => {
server.resetHandlers();
});
// Clean up after all tests are finished
afterAll(() => {
server.close();
});
// Basic API service testing
describe('ApiService', () => {
let apiService: ApiService;
beforeEach(() => {
apiService = new ApiService();
});
describe('Authentication', () => {
it('should login successfully with valid credentials', async () => {
const credentials: LoginRequest = {
email: '[email protected]',
password: 'password123'
};
const result = await apiService.login(credentials);
expect(result).toEqual({
user: expect.objectContaining({
id: 'user-1',
name: 'John Doe',
email: '[email protected]',
isActive: true,
role: 'user'
}),
token: 'mock-jwt-token',
refreshToken: 'mock-refresh-token',
expiresIn: 3600
});
});
it('should throw error for invalid credentials', async () => {
const credentials: LoginRequest = {
email: '[email protected]',
password: 'wrongpassword'
};
await expect(apiService.login(credentials)).rejects.toThrow(
new ApiError('Invalid credentials', 401, 'INVALID_CREDENTIALS')
);
});
it('should handle account locked error', async () => {
const credentials: LoginRequest = {
email: '[email protected]',
password: 'password123'
};
await expect(apiService.login(credentials)).rejects.toThrow(
new ApiError('Account locked', 423, 'ACCOUNT_LOCKED')
);
});
it('should handle validation errors', async () => {
const credentials: LoginRequest = {
email: 'invalid-email',
password: 'password123'
};
await expect(apiService.login(credentials)).rejects.toThrow(
new ApiError('Invalid request data', 400, 'VALIDATION_ERROR')
);
});
it('should logout successfully', async () => {
apiService.setToken('valid-token');
await expect(apiService.logout()).resolves.toBeUndefined();
expect(apiService['token']).toBeNull();
});
it('should throw error when logging out without token', async () => {
await expect(apiService.logout()).rejects.toThrow(
new ApiError('Unauthorized', 401, 'UNAUTHORIZED')
);
});
it('should refresh token successfully', async () => {
const result = await apiService.refreshToken('mock-refresh-token');
expect(result).toEqual({
user: expect.objectContaining({
id: 'user-1',
name: 'John Doe'
}),
token: 'new-mock-jwt-token',
refreshToken: 'new-mock-refresh-token',
expiresIn: 3600
});
});
it('should handle invalid refresh token', async () => {
await expect(
apiService.refreshToken('invalid-token')
).rejects.toThrow(
new ApiError('Invalid refresh token', 401, 'INVALID_REFRESH_TOKEN')
);
});
});
describe('User Operations', () => {
beforeEach(() => {
apiService.setToken('valid-token');
});
it('should get current user', async () => {
const user = await apiService.getCurrentUser();
expect(user).toEqual({
id: 'user-1',
name: 'John Doe',
email: '[email protected]',
isActive: true,
role: 'user',
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z'
});
});
it('should get users with pagination', async () => {
const result = await apiService.getUsers(1, 2);
expect(result).toEqual({
data: expect.arrayContaining([
expect.objectContaining({
id: 'user-1',
name: 'John Doe'
}),
expect.objectContaining({
id: 'user-2',
name: 'Jane Smith'
})
]),
pagination: {
page: 1,
limit: 2,
total: 3,
totalPages: 2
}
});
});
it('should get user by id', async () => {
const user = await apiService.getUserById('user-2');
expect(user).toEqual(
expect.objectContaining({
id: 'user-2',
name: 'Jane Smith',
email: '[email protected]',
role: 'admin'
})
);
});
it('should throw error for non-existent user', async () => {
await expect(
apiService.getUserById('non-existent')
).rejects.toThrow(
new ApiError('User not found', 404, 'USER_NOT_FOUND')
);
});
it('should update user successfully', async () => {
const updates = { name: 'Updated Name', isActive: false };
const updatedUser = await apiService.updateUser('user-1', updates);
expect(updatedUser).toEqual(
expect.objectContaining({
id: 'user-1',
name: 'Updated Name',
isActive: false,
updatedAt: expect.any(String)
})
);
});
it('should delete user successfully', async () => {
await expect(
apiService.deleteUser('user-3')
).resolves.toBeUndefined();
});
it('should prevent deletion of admin user', async () => {
await expect(
apiService.deleteUser('user-1')
).rejects.toThrow(
new ApiError('Cannot delete admin user', 403, 'FORBIDDEN')
);
});
});
describe('Product Operations', () => {
it('should get products with filtering and pagination', async () => {
const result = await apiService.getProducts(1, 10, 'electronics', 'wireless');
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual(
expect.objectContaining({
name: 'Wireless Headphones',
category: 'electronics'
})
);
expect(result.pagination).toEqual({
page: 1,
limit: 10,
total: 1,
totalPages: 1
});
});
it('should get product by id', async () => {
const product = await apiService.getProductById('product-1');
expect(product).toEqual(
expect.objectContaining({
id: 'product-1',
name: 'Wireless Headphones',
price: 99.99,
category: 'electronics'
})
);
});
it('should handle slow loading products', async () => {
const startTime = Date.now();
const product = await apiService.getProductById('slow-product');
const endTime = Date.now();
expect(endTime - startTime).toBeGreaterThanOrEqual(2000);
expect(product.name).toBe('Slow Loading Product');
});
it('should create product successfully', async () => {
const productData: CreateProductRequest = {
name: 'New Product',
price: 49.99,
description: 'A new test product',
category: 'test',
stockCount: 10,
tags: ['test', 'new']
};
const createdProduct = await apiService.createProduct(productData);
expect(createdProduct).toEqual(
expect.objectContaining({
id: expect.stringMatching(/^product-\d+$/),
name: 'New Product',
price: 49.99,
inStock: true,
createdAt: expect.any(String)
})
);
});
it('should validate product creation data', async () => {
const invalidProductData: CreateProductRequest = {
name: 'AB', // Too short
price: -10, // Invalid price
description: 'Invalid product',
category: 'test',
stockCount: 5,
tags: []
};
await expect(
apiService.createProduct(invalidProductData)
).rejects.toThrow(
new ApiError(
'Product name must be at least 3 characters',
400,
'VALIDATION_ERROR'
)
);
});
it('should update product successfully', async () => {
const updates: UpdateProductRequest = {
id: 'product-1',
name: 'Updated Headphones',
price: 129.99
};
const updatedProduct = await apiService.updateProduct(updates);
expect(updatedProduct).toEqual(
expect.objectContaining({
id: 'product-1',
name: 'Updated Headphones',
price: 129.99
})
);
});
it('should validate product update data', async () => {
const invalidUpdates: UpdateProductRequest = {
id: 'product-1',
price: -50 // Invalid price
};
await expect(
apiService.updateProduct(invalidUpdates)
).rejects.toThrow(
new ApiError(
'Product price must be greater than 0',
400,
'VALIDATION_ERROR'
)
);
});
it('should delete product successfully', async () => {
await expect(
apiService.deleteProduct('product-3')
).resolves.toBeUndefined();
});
});
describe('Order Operations', () => {
it('should create order successfully', async () => {
const orderData = {
items: [
{ productId: 'product-1', quantity: 2 },
{ productId: 'product-2', quantity: 1 }
],
shippingAddress: {
street: '456 Oak St',
city: 'Los Angeles',
state: 'CA',
zipCode: '90210',
country: 'USA'
}
};
const createdOrder = await apiService.createOrder(orderData);
expect(createdOrder).toEqual(
expect.objectContaining({
id: expect.stringMatching(/^order-\d+$/),
userId: 'user-1',
items: expect.arrayContaining([
expect.objectContaining({
productId: 'product-1',
quantity: 2,
price: 99.99
}),
expect.objectContaining({
productId: 'product-2',
quantity: 1,
price: 699.99
})
]),
total: 899.97, // (99.99 * 2) + 699.99
status: 'pending'
})
);
});
it('should validate order items', async () => {
const invalidOrderData = {
items: [], // Empty items
shippingAddress: {
street: '456 Oak St',
city: 'Los Angeles',
state: 'CA',
zipCode: '90210',
country: 'USA'
}
};
await expect(
apiService.createOrder(invalidOrderData)
).rejects.toThrow(
new ApiError(
'Order must have at least one item',
400,
'VALIDATION_ERROR'
)
);
});
it('should validate product existence in order', async () => {
const invalidOrderData = {
items: [
{ productId: 'non-existent-product', quantity: 1 }
],
shippingAddress: {
street: '456 Oak St',
city: 'Los Angeles',
state: 'CA',
zipCode: '90210',
country: 'USA'
}
};
await expect(
apiService.createOrder(invalidOrderData)
).rejects.toThrow(
new ApiError('Invalid product in order', 400, 'INVALID_PRODUCT')
);
});
it('should update order status', async () => {
const updatedOrder = await apiService.updateOrderStatus(
'order-1',
'processing'
);
expect(updatedOrder).toEqual(
expect.objectContaining({
id: 'order-1',
status: 'processing',
updatedAt: expect.any(String)
})
);
});
it('should prevent invalid status changes', async () => {
// First update to delivered
await apiService.updateOrderStatus('order-1', 'delivered');
// Then try to change back to processing
await expect(
apiService.updateOrderStatus('order-1', 'processing')
).rejects.toThrow(
new ApiError(
'Cannot change status of delivered order',
400,
'INVALID_STATUS_CHANGE'
)
);
});
it('should cancel order successfully', async () => {
const cancelledOrder = await apiService.cancelOrder('order-1');
expect(cancelledOrder).toEqual(
expect.objectContaining({
id: 'order-1',
status: 'cancelled'
})
);
});
it('should prevent cancelling shipped orders', async () => {
// Update order to shipped status
await apiService.updateOrderStatus('order-1', 'shipped');
// Try to cancel shipped order
await expect(
apiService.cancelOrder('order-1')
).rejects.toThrow(
new ApiError(
'Cannot cancel shipped or delivered order',
400,
'CANNOT_CANCEL_ORDER'
)
);
});
});
});
ποΈ Testing Custom API Hooks
π Hook State Management Testing
Test custom hooks that manage API state with comprehensive coverage of loading, success, and error states.
// π― Testing custom API hooks with comprehensive state management
describe('useApi Hook', () => {
let apiService: ApiService;
beforeEach(() => {
apiService = new ApiService();
});
describe('Basic Functionality', () => {
it('should initialize with correct default state', () => {
const mockApiCall = jest.fn().mockResolvedValue({ data: 'test' });
const { result } = renderHook(() => useApi(mockApiCall));
expect(result.current.data).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
expect(typeof result.current.execute).toBe('function');
expect(typeof result.current.reset).toBe('function');
});
it('should execute API call when immediate is true', async () => {
const mockApiCall = jest.fn().mockResolvedValue({ data: 'test' });
const { result } = renderHook(() =>
useApi(mockApiCall, { immediate: true })
);
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
expect(result.current.error).toBeNull();
// Wait for completion
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ data: 'test' });
expect(result.current.error).toBeNull();
expect(mockApiCall).toHaveBeenCalledTimes(1);
});
it('should handle successful API execution', async () => {
const testData = { id: '1', name: 'Test User' };
const mockApiCall = jest.fn().mockResolvedValue(testData);
const { result } = renderHook(() => useApi(mockApiCall));
act(() => {
result.current.execute();
});
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
expect(result.current.error).toBeNull();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(testData);
expect(result.current.error).toBeNull();
expect(mockApiCall).toHaveBeenCalledTimes(1);
});
it('should handle API execution with parameters', async () => {
const testData = { id: '123', name: 'Specific User' };
const mockApiCall = jest.fn().mockResolvedValue(testData);
const { result } = renderHook(() => useApi(mockApiCall));
act(() => {
result.current.execute('param1', 'param2', 123);
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(testData);
expect(mockApiCall).toHaveBeenCalledWith('param1', 'param2', 123);
});
it('should handle API errors', async () => {
const apiError = new ApiError('Test error', 404, 'NOT_FOUND');
const mockApiCall = jest.fn().mockRejectedValue(apiError);
const { result } = renderHook(() => useApi(mockApiCall));
act(() => {
result.current.execute();
});
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(result.current.error).toEqual(apiError);
});
it('should reset state correctly', async () => {
const testData = { id: '1', name: 'Test User' };
const mockApiCall = jest.fn().mockResolvedValue(testData);
const { result } = renderHook(() => useApi(mockApiCall));
// Execute API call
act(() => {
result.current.execute();
});
await waitFor(() => {
expect(result.current.data).toEqual(testData);
});
// Reset state
act(() => {
result.current.reset();
});
expect(result.current.data).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
});
describe('Error Handling and Retries', () => {
it('should retry failed requests', async () => {
const apiError = new ApiError('Network error', 0, 'NETWORK_ERROR');
const mockApiCall = jest.fn()
.mockRejectedValueOnce(apiError)
.mockRejectedValueOnce(apiError)
.mockResolvedValue({ data: 'success' });
const { result } = renderHook(() =>
useApi(mockApiCall, { retries: 2, retryDelay: 100 })
);
act(() => {
result.current.execute();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ data: 'success' });
expect(result.current.error).toBeNull();
expect(mockApiCall).toHaveBeenCalledTimes(3);
});
it('should fail after exhausting retries', async () => {
const apiError = new ApiError('Persistent error', 500, 'SERVER_ERROR');
const mockApiCall = jest.fn().mockRejectedValue(apiError);
const { result } = renderHook(() =>
useApi(mockApiCall, { retries: 2, retryDelay: 50 })
);
act(() => {
result.current.execute();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(result.current.error).toEqual(apiError);
expect(mockApiCall).toHaveBeenCalledTimes(3); // Original + 2 retries
});
it('should respect retry delay', async () => {
const apiError = new ApiError('Network error', 0, 'NETWORK_ERROR');
const mockApiCall = jest.fn()
.mockRejectedValueOnce(apiError)
.mockResolvedValue({ data: 'success' });
const startTime = Date.now();
const { result } = renderHook(() =>
useApi(mockApiCall, { retries: 1, retryDelay: 200 })
);
act(() => {
result.current.execute();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const endTime = Date.now();
expect(endTime - startTime).toBeGreaterThanOrEqual(200);
expect(result.current.data).toEqual({ data: 'success' });
});
it('should handle non-ApiError exceptions', async () => {
const genericError = new Error('Generic error');
const mockApiCall = jest.fn().mockRejectedValue(genericError);
const { result } = renderHook(() => useApi(mockApiCall));
act(() => {
result.current.execute();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toEqual(
new ApiError('Unknown error', 0, 'UNKNOWN_ERROR')
);
});
});
describe('Callback Integration', () => {
it('should call onSuccess callback', async () => {
const testData = { id: '1', name: 'Test User' };
const mockApiCall = jest.fn().mockResolvedValue(testData);
const onSuccess = jest.fn();
const { result } = renderHook(() =>
useApi(mockApiCall, { onSuccess })
);
act(() => {
result.current.execute();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(onSuccess).toHaveBeenCalledWith(testData);
expect(onSuccess).toHaveBeenCalledTimes(1);
});
it('should call onError callback', async () => {
const apiError = new ApiError('Test error', 404, 'NOT_FOUND');
const mockApiCall = jest.fn().mockRejectedValue(apiError);
const onError = jest.fn();
const { result } = renderHook(() =>
useApi(mockApiCall, { onError })
);
act(() => {
result.current.execute();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(onError).toHaveBeenCalledWith(apiError);
expect(onError).toHaveBeenCalledTimes(1);
});
it('should not call onSuccess after retries if final attempt fails', async () => {
const apiError = new ApiError('Persistent error', 500, 'SERVER_ERROR');
const mockApiCall = jest.fn().mockRejectedValue(apiError);
const onSuccess = jest.fn();
const onError = jest.fn();
const { result } = renderHook(() =>
useApi(mockApiCall, { retries: 2, onSuccess, onError })
);
act(() => {
result.current.execute();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(onSuccess).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(apiError);
expect(onError).toHaveBeenCalledTimes(1);
});
});
describe('Real API Integration', () => {
it('should login successfully', async () => {
const { result } = renderHook(() =>
useApi(() => apiService.login({
email: '[email protected]',
password: 'password123'
}))
);
act(() => {
result.current.execute();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({
user: expect.objectContaining({
id: 'user-1',
email: '[email protected]'
}),
token: 'mock-jwt-token',
refreshToken: 'mock-refresh-token',
expiresIn: 3600
});
expect(result.current.error).toBeNull();
});
it('should handle login failure', async () => {
const { result } = renderHook(() =>
useApi(() => apiService.login({
email: '[email protected]',
password: 'wrongpassword'
}))
);
act(() => {
result.current.execute();
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(result.current.error).toEqual(
new ApiError('Invalid credentials', 401, 'INVALID_CREDENTIALS')
);
});
it('should fetch users with pagination', async () => {
apiService.setToken('valid-token');
const { result } = renderHook(() =>
useApi((page: number, limit: number) =>
apiService.getUsers(page, limit)
)
);
act(() => {
result.current.execute(1, 2);
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({
data: expect.arrayContaining([
expect.objectContaining({ id: 'user-1' }),
expect.objectContaining({ id: 'user-2' })
]),
pagination: {
page: 1,
limit: 2,
total: 3,
totalPages: 2
}
});
});
it('should create product successfully', async () => {
const productData: CreateProductRequest = {
name: 'New Test Product',
price: 29.99,
description: 'A product created in tests',
category: 'test',
stockCount: 15,
tags: ['test', 'new']
};
const { result } = renderHook(() =>
useApi((data: CreateProductRequest) =>
apiService.createProduct(data)
)
);
act(() => {
result.current.execute(productData);
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(
expect.objectContaining({
id: expect.stringMatching(/^product-\d+$/),
name: 'New Test Product',
price: 29.99,
inStock: true
})
);
});
});
});
π Advanced MSW Patterns
π Dynamic Response Handling
Master advanced MSW patterns for complex testing scenarios including conditional responses and stateful mocking.
// π― Advanced MSW patterns for complex API testing scenarios
describe('Advanced MSW Patterns', () => {
describe('Dynamic Response Handling', () => {
it('should handle conditional responses based on request data', async () => {
// Override default handler with conditional logic
server.use(
rest.post('https://api.example.com/auth/login', (req, res, ctx) => {
const { email, password } = req.body as LoginRequest;
if (email === '[email protected]' && password === 'premium123') {
return res(
ctx.status(200),
ctx.json<LoginResponse>({
user: {
...mockUsers[0],
id: 'premium-user',
role: 'admin',
email: '[email protected]'
},
token: 'premium-jwt-token',
refreshToken: 'premium-refresh-token',
expiresIn: 7200 // Longer expiry for premium users
})
);
}
return res(
ctx.status(401),
ctx.json({
message: 'Invalid credentials',
code: 'INVALID_CREDENTIALS'
})
);
})
);
const apiService = new ApiService();
const result = await apiService.login({
email: '[email protected]',
password: 'premium123'
});
expect(result.user.role).toBe('admin');
expect(result.expiresIn).toBe(7200);
expect(result.token).toBe('premium-jwt-token');
});
it('should handle stateful responses with request counting', async () => {
let requestCount = 0;
server.use(
rest.get('https://api.example.com/products/rate-limited', (req, res, ctx) => {
requestCount++;
if (requestCount <= 3) {
return res(
ctx.status(200),
ctx.json({
id: 'rate-limited-product',
name: `Product Request ${requestCount}`,
requestCount
})
);
}
return res(
ctx.status(429),
ctx.json({
message: 'Rate limit exceeded',
code: 'RATE_LIMIT_EXCEEDED',
retryAfter: 60
})
);
})
);
const apiService = new ApiService();
// First 3 requests should succeed
for (let i = 1; i <= 3; i++) {
const result = await apiService.getProductById('rate-limited');
expect(result.requestCount).toBe(i);
}
// 4th request should be rate limited
await expect(
apiService.getProductById('rate-limited')
).rejects.toThrow(
new ApiError('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED')
);
});
it('should simulate progressive loading states', async () => {
const loadingStates = ['initializing', 'processing', 'finalizing', 'complete'];
let stateIndex = 0;
server.use(
rest.get('https://api.example.com/long-process/:id', (req, res, ctx) => {
const currentState = loadingStates[stateIndex];
if (stateIndex < loadingStates.length - 1) {
stateIndex++;
return res(
ctx.status(202), // Accepted but not complete
ctx.json({
id: req.params.id,
status: currentState,
progress: Math.round((stateIndex / loadingStates.length) * 100)
})
);
}
return res(
ctx.status(200),
ctx.json({
id: req.params.id,
status: 'complete',
progress: 100,
result: 'Process completed successfully'
})
);
})
);
const apiService = new ApiService();
// Simulate polling until complete
let response;
let attempts = 0;
const maxAttempts = 5;
do {
response = await apiService.makeRequest('/long-process/test-123');
attempts++;
if (response.status !== 'complete' && attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 100));
}
} while (response.status !== 'complete' && attempts < maxAttempts);
expect(response.status).toBe('complete');
expect(response.progress).toBe(100);
expect(response.result).toBe('Process completed successfully');
});
it('should handle request headers and authentication states', async () => {
server.use(
rest.get('https://api.example.com/secure-data', (req, res, ctx) => {
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return res(
ctx.status(401),
ctx.json({
message: 'No authorization header',
code: 'NO_AUTH_HEADER'
})
);
}
if (authHeader === 'Bearer expired-token') {
return res(
ctx.status(401),
ctx.json({
message: 'Token expired',
code: 'TOKEN_EXPIRED'
})
);
}
if (authHeader === 'Bearer admin-token') {
return res(
ctx.status(200),
ctx.json({
data: 'Top secret admin data',
accessLevel: 'admin'
})
);
}
if (authHeader === 'Bearer user-token') {
return res(
ctx.status(200),
ctx.json({
data: 'Regular user data',
accessLevel: 'user'
})
);
}
return res(
ctx.status(403),
ctx.json({
message: 'Invalid token',
code: 'INVALID_TOKEN'
})
);
})
);
const apiService = new ApiService();
// Test no auth
await expect(
apiService.makeRequest('/secure-data')
).rejects.toThrow(
new ApiError('No authorization header', 401, 'NO_AUTH_HEADER')
);
// Test expired token
apiService.setToken('expired-token');
await expect(
apiService.makeRequest('/secure-data')
).rejects.toThrow(
new ApiError('Token expired', 401, 'TOKEN_EXPIRED')
);
// Test admin access
apiService.setToken('admin-token');
const adminResponse = await apiService.makeRequest('/secure-data');
expect(adminResponse.accessLevel).toBe('admin');
// Test user access
apiService.setToken('user-token');
const userResponse = await apiService.makeRequest('/secure-data');
expect(userResponse.accessLevel).toBe('user');
});
});
describe('Network Condition Simulation', () => {
it('should simulate network errors', async () => {
const apiService = new ApiService();
await expect(
apiService.makeRequest('/network-error')
).rejects.toThrow(
new ApiError('Network error', 0, 'NETWORK_ERROR')
);
});
it('should simulate server errors', async () => {
const apiService = new ApiService();
await expect(
apiService.makeRequest('/server-error')
).rejects.toThrow(
new ApiError('Internal server error', 500, 'INTERNAL_SERVER_ERROR')
);
});
it('should simulate slow network conditions', async () => {
server.use(
rest.get('https://api.example.com/slow-endpoint', (req, res, ctx) => {
return res(
ctx.delay(1500), // 1.5 second delay
ctx.status(200),
ctx.json({ message: 'Slow response' })
);
})
);
const apiService = new ApiService();
const startTime = Date.now();
const response = await apiService.makeRequest('/slow-endpoint');
const endTime = Date.now();
expect(endTime - startTime).toBeGreaterThanOrEqual(1500);
expect(response.message).toBe('Slow response');
});
it('should simulate intermittent connectivity', async () => {
let requestCount = 0;
server.use(
rest.get('https://api.example.com/intermittent', (req, res, ctx) => {
requestCount++;
// Fail every other request
if (requestCount % 2 === 0) {
return res.networkError('Connection failed');
}
return res(
ctx.status(200),
ctx.json({
message: 'Connection successful',
requestNumber: requestCount
})
);
})
);
const apiService = new ApiService();
// First request should succeed
const firstResponse = await apiService.makeRequest('/intermittent');
expect(firstResponse.requestNumber).toBe(1);
// Second request should fail
await expect(
apiService.makeRequest('/intermittent')
).rejects.toThrow(
new ApiError('Network error', 0, 'NETWORK_ERROR')
);
// Third request should succeed again
const thirdResponse = await apiService.makeRequest('/intermittent');
expect(thirdResponse.requestNumber).toBe(3);
});
});
describe('Complex Response Scenarios', () => {
it('should handle pagination with different page sizes', async () => {
server.use(
rest.get('https://api.example.com/variable-pagination', (req, res, ctx) => {
const page = Number(req.url.searchParams.get('page')) || 1;
const limit = Number(req.url.searchParams.get('limit')) || 10;
// Simulate a dataset of 47 items
const totalItems = 47;
const totalPages = Math.ceil(totalItems / limit);
if (page > totalPages) {
return res(
ctx.status(404),
ctx.json({
message: 'Page not found',
code: 'PAGE_NOT_FOUND'
})
);
}
const startIndex = (page - 1) * limit;
const endIndex = Math.min(startIndex + limit, totalItems);
const itemsOnPage = endIndex - startIndex;
const items = Array.from({ length: itemsOnPage }, (_, index) => ({
id: `item-${startIndex + index + 1}`,
name: `Item ${startIndex + index + 1}`
}));
return res(
ctx.status(200),
ctx.json({
data: items,
pagination: {
page,
limit,
total: totalItems,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
})
);
})
);
const apiService = new ApiService();
// Test first page with default limit
const firstPage = await apiService.makeRequest('/variable-pagination?page=1&limit=10');
expect(firstPage.data).toHaveLength(10);
expect(firstPage.pagination.hasNext).toBe(true);
expect(firstPage.pagination.hasPrev).toBe(false);
// Test last page
const lastPage = await apiService.makeRequest('/variable-pagination?page=5&limit=10');
expect(lastPage.data).toHaveLength(7); // 47 % 10 = 7 items on last page
expect(lastPage.pagination.hasNext).toBe(false);
expect(lastPage.pagination.hasPrev).toBe(true);
// Test page beyond range
await expect(
apiService.makeRequest('/variable-pagination?page=10&limit=10')
).rejects.toThrow(
new ApiError('Page not found', 404, 'PAGE_NOT_FOUND')
);
});
it('should handle complex filtering and sorting', async () => {
server.use(
rest.get('https://api.example.com/advanced-search', (req, res, ctx) => {
const search = req.url.searchParams.get('search');
const category = req.url.searchParams.get('category');
const minPrice = Number(req.url.searchParams.get('minPrice')) || 0;
const maxPrice = Number(req.url.searchParams.get('maxPrice')) || Infinity;
const sortBy = req.url.searchParams.get('sortBy') || 'name';
const sortOrder = req.url.searchParams.get('sortOrder') || 'asc';
let filteredProducts = [...mockProducts];
// Apply filters
if (search) {
const searchLower = search.toLowerCase();
filteredProducts = filteredProducts.filter(p =>
p.name.toLowerCase().includes(searchLower) ||
p.description.toLowerCase().includes(searchLower)
);
}
if (category) {
filteredProducts = filteredProducts.filter(p => p.category === category);
}
filteredProducts = filteredProducts.filter(p =>
p.price >= minPrice && p.price <= maxPrice
);
// Apply sorting
filteredProducts.sort((a, b) => {
let valueA = a[sortBy as keyof Product];
let valueB = b[sortBy as keyof Product];
if (typeof valueA === 'string') {
valueA = valueA.toLowerCase();
valueB = (valueB as string).toLowerCase();
}
if (valueA < valueB) return sortOrder === 'asc' ? -1 : 1;
if (valueA > valueB) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
return res(
ctx.status(200),
ctx.json({
data: filteredProducts,
filters: {
search,
category,
minPrice,
maxPrice,
sortBy,
sortOrder
},
count: filteredProducts.length
})
);
})
);
const apiService = new ApiService();
// Test search functionality
const searchResult = await apiService.makeRequest('/advanced-search?search=wireless');
expect(searchResult.data).toHaveLength(1);
expect(searchResult.data[0].name).toContain('Wireless');
// Test category filtering
const categoryResult = await apiService.makeRequest('/advanced-search?category=electronics');
expect(categoryResult.data).toHaveLength(2);
expect(categoryResult.data.every((p: Product) => p.category === 'electronics')).toBe(true);
// Test price range filtering
const priceResult = await apiService.makeRequest('/advanced-search?minPrice=50&maxPrice=200');
expect(priceResult.data.every((p: Product) => p.price >= 50 && p.price <= 200)).toBe(true);
// Test sorting
const sortedResult = await apiService.makeRequest('/advanced-search?sortBy=price&sortOrder=desc');
const prices = sortedResult.data.map((p: Product) => p.price);
expect(prices).toEqual([...prices].sort((a, b) => b - a));
});
});
});
π― Integration Testing Patterns
π Component-API Integration
Test complete user workflows involving API interactions with comprehensive coverage.
// π― Complete integration testing with API interactions
// UserProfile component for testing
interface UserProfileProps {
userId: string;
onUserUpdate?: (user: User) => void;
}
const UserProfile: React.FC<UserProfileProps> = ({ userId, onUserUpdate }) => {
const apiService = useMemo(() => new ApiService(), []);
const {
data: user,
loading,
error,
execute: fetchUser
} = useApi((id: string) => apiService.getUserById(id));
const {
loading: updating,
error: updateError,
execute: updateUser
} = useApi(
(id: string, updates: Partial<User>) => apiService.updateUser(id, updates),
{
onSuccess: (updatedUser) => {
if (onUserUpdate) onUserUpdate(updatedUser);
}
}
);
const [editMode, setEditMode] = useState(false);
const [formData, setFormData] = useState<Partial<User>>({});
useEffect(() => {
if (userId) {
fetchUser(userId);
}
}, [userId, fetchUser]);
useEffect(() => {
if (user) {
setFormData({
name: user.name,
email: user.email,
isActive: user.isActive
});
}
}, [user]);
const handleEdit = useCallback(() => {
setEditMode(true);
}, []);
const handleCancel = useCallback(() => {
setEditMode(false);
if (user) {
setFormData({
name: user.name,
email: user.email,
isActive: user.isActive
});
}
}, [user]);
const handleSave = useCallback(async () => {
if (user && formData) {
await updateUser(user.id, formData);
setEditMode(false);
}
}, [user, formData, updateUser]);
const handleInputChange = useCallback((field: keyof User, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
if (loading) {
return <div data-testid="loading">Loading user...</div>;
}
if (error) {
return (
<div data-testid="error" role="alert">
Error: {error.message}
</div>
);
}
if (!user) {
return <div data-testid="no-user">User not found</div>;
}
return (
<div data-testid="user-profile">
<h2>User Profile</h2>
{updateError && (
<div data-testid="update-error" role="alert">
Update Error: {updateError.message}
</div>
)}
{editMode ? (
<div data-testid="edit-form">
<div>
<label htmlFor="name">Name:</label>
<input
id="name"
data-testid="name-input"
value={formData.name || ''}
onChange={(e) => handleInputChange('name', e.target.value)}
disabled={updating}
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
data-testid="email-input"
type="email"
value={formData.email || ''}
onChange={(e) => handleInputChange('email', e.target.value)}
disabled={updating}
/>
</div>
<div>
<label>
<input
data-testid="active-checkbox"
type="checkbox"
checked={formData.isActive || false}
onChange={(e) => handleInputChange('isActive', e.target.checked)}
disabled={updating}
/>
Active User
</label>
</div>
<div>
<button
data-testid="save-button"
onClick={handleSave}
disabled={updating}
>
{updating ? 'Saving...' : 'Save'}
</button>
<button
data-testid="cancel-button"
onClick={handleCancel}
disabled={updating}
>
Cancel
</button>
</div>
</div>
) : (
<div data-testid="view-mode">
<p data-testid="user-name">Name: {user.name}</p>
<p data-testid="user-email">Email: {user.email}</p>
<p data-testid="user-role">Role: {user.role}</p>
<p data-testid="user-status">
Status: {user.isActive ? 'Active' : 'Inactive'}
</p>
<button data-testid="edit-button" onClick={handleEdit}>
Edit Profile
</button>
</div>
)}
</div>
);
};
// ProductList component for testing
interface ProductListProps {
category?: string;
onProductSelect?: (product: Product) => void;
}
const ProductList: React.FC<ProductListProps> = ({ category, onProductSelect }) => {
const apiService = useMemo(() => new ApiService(), []);
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState('');
const {
data: productsResponse,
loading,
error,
execute: fetchProducts
} = useApi((page: number, limit: number, cat?: string, search?: string) =>
apiService.getProducts(page, limit, cat, search)
);
useEffect(() => {
fetchProducts(currentPage, 10, category, searchTerm || undefined);
}, [currentPage, category, searchTerm, fetchProducts]);
const handleSearch = useCallback((search: string) => {
setSearchTerm(search);
setCurrentPage(1); // Reset to first page when searching
}, []);
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page);
}, []);
const handleProductClick = useCallback((product: Product) => {
if (onProductSelect) {
onProductSelect(product);
}
}, [onProductSelect]);
if (loading) {
return <div data-testid="products-loading">Loading products...</div>;
}
if (error) {
return (
<div data-testid="products-error" role="alert">
Error loading products: {error.message}
</div>
);
}
const products = productsResponse?.data || [];
const pagination = productsResponse?.pagination;
return (
<div data-testid="product-list">
<h2>Products {category && `- ${category}`}</h2>
<div data-testid="search-section">
<input
data-testid="search-input"
type="text"
placeholder="Search products..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{products.length === 0 ? (
<div data-testid="no-products">No products found</div>
) : (
<>
<div data-testid="products-grid">
{products.map((product) => (
<div
key={product.id}
data-testid={`product-${product.id}`}
className="product-card"
onClick={() => handleProductClick(product)}
style={{ cursor: 'pointer', border: '1px solid #ccc', margin: '8px', padding: '16px' }}
>
<h3 data-testid={`product-name-${product.id}`}>{product.name}</h3>
<p data-testid={`product-price-${product.id}`}>${product.price}</p>
<p data-testid={`product-category-${product.id}`}>{product.category}</p>
<p data-testid={`product-stock-${product.id}`}>
{product.inStock ? `In Stock (${product.stockCount})` : 'Out of Stock'}
</p>
</div>
))}
</div>
{pagination && pagination.totalPages > 1 && (
<div data-testid="pagination">
<button
data-testid="prev-page"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</button>
<span data-testid="page-info">
Page {pagination.page} of {pagination.totalPages}
</span>
<button
data-testid="next-page"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === pagination.totalPages}
>
Next
</button>
</div>
)}
</>
)}
</div>
);
};
// Integration tests
describe('API Integration Tests', () => {
describe('UserProfile Component Integration', () => {
it('should load and display user data', async () => {
render(<UserProfile userId="user-1" />);
// Initially shows loading
expect(screen.getByTestId('loading')).toBeInTheDocument();
// Wait for user data to load
await waitFor(() => {
expect(screen.getByTestId('user-profile')).toBeInTheDocument();
});
// Check displayed user data
expect(screen.getByTestId('user-name')).toHaveTextContent('Name: John Doe');
expect(screen.getByTestId('user-email')).toHaveTextContent('Email: [email protected]');
expect(screen.getByTestId('user-role')).toHaveTextContent('Role: user');
expect(screen.getByTestId('user-status')).toHaveTextContent('Status: Active');
});
it('should handle user not found error', async () => {
render(<UserProfile userId="non-existent" />);
await waitFor(() => {
expect(screen.getByTestId('error')).toBeInTheDocument();
});
expect(screen.getByTestId('error')).toHaveTextContent('Error: User not found');
});
it('should enable edit mode and update user', async () => {
const user = userEvent.setup();
const onUserUpdate = jest.fn();
render(<UserProfile userId="user-1" onUserUpdate={onUserUpdate} />);
// Wait for user data to load
await waitFor(() => {
expect(screen.getByTestId('user-profile')).toBeInTheDocument();
});
// Click edit button
await user.click(screen.getByTestId('edit-button'));
// Should show edit form
expect(screen.getByTestId('edit-form')).toBeInTheDocument();
expect(screen.getByTestId('name-input')).toHaveValue('John Doe');
expect(screen.getByTestId('email-input')).toHaveValue('[email protected]');
// Update name
await user.clear(screen.getByTestId('name-input'));
await user.type(screen.getByTestId('name-input'), 'John Updated');
// Toggle active status
await user.click(screen.getByTestId('active-checkbox'));
// Save changes
await user.click(screen.getByTestId('save-button'));
// Wait for update to complete
await waitFor(() => {
expect(screen.getByTestId('view-mode')).toBeInTheDocument();
});
// Check updated display
expect(screen.getByTestId('user-name')).toHaveTextContent('Name: John Updated');
expect(screen.getByTestId('user-status')).toHaveTextContent('Status: Inactive');
// Check callback was called
expect(onUserUpdate).toHaveBeenCalledWith(
expect.objectContaining({
id: 'user-1',
name: 'John Updated',
isActive: false
})
);
});
it('should handle update errors gracefully', async () => {
const user = userEvent.setup();
// Mock update failure
server.use(
rest.patch('https://api.example.com/users/:id', (req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
message: 'Invalid email format',
code: 'VALIDATION_ERROR',
field: 'email'
})
);
})
);
render(<UserProfile userId="user-1" />);
await waitFor(() => {
expect(screen.getByTestId('user-profile')).toBeInTheDocument();
});
await user.click(screen.getByTestId('edit-button'));
// Enter invalid email
await user.clear(screen.getByTestId('email-input'));
await user.type(screen.getByTestId('email-input'), 'invalid-email');
await user.click(screen.getByTestId('save-button'));
// Should show update error
await waitFor(() => {
expect(screen.getByTestId('update-error')).toBeInTheDocument();
});
expect(screen.getByTestId('update-error')).toHaveTextContent(
'Update Error: Invalid email format'
);
// Should remain in edit mode
expect(screen.getByTestId('edit-form')).toBeInTheDocument();
});
it('should cancel edit mode and restore original data', async () => {
const user = userEvent.setup();
render(<UserProfile userId="user-1" />);
await waitFor(() => {
expect(screen.getByTestId('user-profile')).toBeInTheDocument();
});
await user.click(screen.getByTestId('edit-button'));
// Make changes
await user.clear(screen.getByTestId('name-input'));
await user.type(screen.getByTestId('name-input'), 'Changed Name');
// Cancel changes
await user.click(screen.getByTestId('cancel-button'));
// Should return to view mode with original data
expect(screen.getByTestId('view-mode')).toBeInTheDocument();
expect(screen.getByTestId('user-name')).toHaveTextContent('Name: John Doe');
});
});
describe('ProductList Component Integration', () => {
it('should load and display products', async () => {
render(<ProductList />);
// Initially shows loading
expect(screen.getByTestId('products-loading')).toBeInTheDocument();
// Wait for products to load
await waitFor(() => {
expect(screen.getByTestId('product-list')).toBeInTheDocument();
});
// Check that products are displayed
expect(screen.getByTestId('products-grid')).toBeInTheDocument();
expect(screen.getByTestId('product-product-1')).toBeInTheDocument();
expect(screen.getByTestId('product-name-product-1')).toHaveTextContent('Wireless Headphones');
expect(screen.getByTestId('product-price-product-1')).toHaveTextContent('$99.99');
});
it('should filter products by category', async () => {
render(<ProductList category="electronics" />);
await waitFor(() => {
expect(screen.getByText('Products - electronics')).toBeInTheDocument();
});
// Should only show electronics products
const productElements = screen.getAllByTestId(/^product-product-/);
expect(productElements).toHaveLength(2); // Only electronics products
});
it('should search products', async () => {
const user = userEvent.setup();
render(<ProductList />);
await waitFor(() => {
expect(screen.getByTestId('product-list')).toBeInTheDocument();
});
// Search for wireless products
await user.type(screen.getByTestId('search-input'), 'wireless');
await waitFor(() => {
const productElements = screen.getAllByTestId(/^product-product-/);
expect(productElements).toHaveLength(1);
expect(screen.getByTestId('product-name-product-1')).toHaveTextContent('Wireless Headphones');
});
});
it('should handle pagination', async () => {
const user = userEvent.setup();
// Mock pagination response
server.use(
rest.get('https://api.example.com/products', (req, res, ctx) => {
const page = Number(req.url.searchParams.get('page')) || 1;
const limit = Number(req.url.searchParams.get('limit')) || 10;
const allProducts = Array.from({ length: 25 }, (_, i) => ({
id: `product-${i + 1}`,
name: `Product ${i + 1}`,
price: 10.99 + i,
description: `Description for product ${i + 1}`,
category: 'test',
inStock: true,
stockCount: 10,
tags: ['test'],
createdAt: '2023-01-01T00:00:00Z'
}));
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedProducts = allProducts.slice(startIndex, endIndex);
return res(
ctx.status(200),
ctx.json({
data: paginatedProducts,
pagination: {
page,
limit,
total: allProducts.length,
totalPages: Math.ceil(allProducts.length / limit)
}
})
);
})
);
render(<ProductList />);
await waitFor(() => {
expect(screen.getByTestId('pagination')).toBeInTheDocument();
});
// Check initial page info
expect(screen.getByTestId('page-info')).toHaveTextContent('Page 1 of 3');
expect(screen.getByTestId('prev-page')).toBeDisabled();
// Go to next page
await user.click(screen.getByTestId('next-page'));
await waitFor(() => {
expect(screen.getByTestId('page-info')).toHaveTextContent('Page 2 of 3');
});
expect(screen.getByTestId('prev-page')).toBeEnabled();
expect(screen.getByTestId('next-page')).toBeEnabled();
});
it('should handle product selection', async () => {
const user = userEvent.setup();
const onProductSelect = jest.fn();
render(<ProductList onProductSelect={onProductSelect} />);
await waitFor(() => {
expect(screen.getByTestId('product-list')).toBeInTheDocument();
});
// Click on a product
await user.click(screen.getByTestId('product-product-1'));
expect(onProductSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: 'product-1',
name: 'Wireless Headphones'
})
);
});
it('should show no products message when search yields no results', async () => {
const user = userEvent.setup();
render(<ProductList />);
await waitFor(() => {
expect(screen.getByTestId('product-list')).toBeInTheDocument();
});
// Search for non-existent product
await user.type(screen.getByTestId('search-input'), 'nonexistent');
await waitFor(() => {
expect(screen.getByTestId('no-products')).toBeInTheDocument();
});
expect(screen.getByTestId('no-products')).toHaveTextContent('No products found');
});
it('should handle API errors gracefully', async () => {
// Mock API error
server.use(
rest.get('https://api.example.com/products', (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({
message: 'Internal server error',
code: 'INTERNAL_SERVER_ERROR'
})
);
})
);
render(<ProductList />);
await waitFor(() => {
expect(screen.getByTestId('products-error')).toBeInTheDocument();
});
expect(screen.getByTestId('products-error')).toHaveTextContent(
'Error loading products: Internal server error'
);
});
});
describe('End-to-End Workflow Tests', () => {
it('should handle complete user management workflow', async () => {
const user = userEvent.setup();
render(<UserProfile userId="user-2" />);
// Load user
await waitFor(() => {
expect(screen.getByTestId('user-name')).toHaveTextContent('Name: Jane Smith');
});
// Edit user
await user.click(screen.getByTestId('edit-button'));
await user.clear(screen.getByTestId('name-input'));
await user.type(screen.getByTestId('name-input'), 'Jane Updated Smith');
// Save changes
await user.click(screen.getByTestId('save-button'));
// Verify update
await waitFor(() => {
expect(screen.getByTestId('user-name')).toHaveTextContent('Name: Jane Updated Smith');
});
// Edit again with different changes
await user.click(screen.getByTestId('edit-button'));
await user.click(screen.getByTestId('active-checkbox')); // Toggle status
// Cancel this time
await user.click(screen.getByTestId('cancel-button'));
// Should show previous saved state, not cancelled changes
expect(screen.getByTestId('user-name')).toHaveTextContent('Name: Jane Updated Smith');
expect(screen.getByTestId('user-status')).toHaveTextContent('Status: Active');
});
it('should handle product search and filtering workflow', async () => {
const user = userEvent.setup();
const onProductSelect = jest.fn();
render(<ProductList onProductSelect={onProductSelect} />);
// Load all products
await waitFor(() => {
expect(screen.getAllByTestId(/^product-product-/)).toHaveLength(3);
});
// Search for specific product
await user.type(screen.getByTestId('search-input'), 'smartphone');
await waitFor(() => {
expect(screen.getAllByTestId(/^product-product-/)).toHaveLength(1);
});
// Select the found product
await user.click(screen.getByTestId('product-product-2'));
expect(onProductSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: 'product-2',
name: 'Smartphone'
})
);
// Clear search to see all products again
await user.clear(screen.getByTestId('search-input'));
await waitFor(() => {
expect(screen.getAllByTestId(/^product-product-/)).toHaveLength(3);
});
});
});
});
π Conclusion and Best Practices
Congratulations! Youβve mastered the art of API testing with Mock Service Worker in TypeScript! π
π Key Takeaways
- MSW provides realistic testing by intercepting actual HTTP requests at the network level
- Test all scenarios: success, errors, network failures, and edge cases
- Use comprehensive mocking for different response conditions and request validation
- Test API hooks independently and in integration with components
- Cover complex workflows with multi-step user interactions and state management
π Best Practices Summary
- Setup MSW properly with beforeAll/afterEach/afterAll hooks
- Create reusable mock handlers for different API scenarios
- Test error conditions as thoroughly as success cases
- Use realistic test data that mirrors production responses
- Verify API calls with proper parameters and headers
- Test retry logic and timeout scenarios
- Cover edge cases like empty responses and validation errors
- Test integration flows that combine multiple API calls
π― Next Steps
With these API testing skills, youβre ready to:
- Build comprehensive test suites for API-driven applications
- Test complex async workflows with confidence
- Handle error scenarios and network failures gracefully
- Create maintainable tests that provide real value
- Integrate API testing into CI/CD pipelines
Keep practicing these patterns and exploring advanced MSW features to become a true API testing expert! π