import { http, HttpResponse } from 'msw';
export interface Product {
id: number;
name: string;
}
export const PRODUCTS: Product[] = Array.from({ length: 45 }, (_, i) => ({
id: i + 1,
name: `Product ${i + 1}`,
}));
export const handlers = [
http.get('/api/products', ({ request }) => {
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') ?? '1');
const limit = Number(url.searchParams.get('limit') ?? '20');
const start = (page - 1) * limit;
const items = PRODUCTS.slice(start, start + limit);
const hasMore = start + limit < PRODUCTS.length;
return HttpResponse.json({ items, page, hasMore });
}),
];
export const errorHandler = http.get('/api/products', () => {
return HttpResponse.json({ message: 'boom' }, { status: 500 });
});
import { afterAll, afterEach, beforeAll } from 'vitest';
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
import { useInfiniteQuery } from '@tanstack/react-query';
import type { Product } from './handlers';
interface Page {
items: Product[];
page: number;
hasMore: boolean;
}
async function fetchPage(page: number): Promise<Page> {
const res = await fetch(`/api/products?page=${page}&limit=20`);
if (!res.ok) {
throw new Error(`Request failed with ${res.status}`);
}
return res.json();
}
export function useProducts() {
return useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.page + 1 : undefined,
});
}
import { describe, expect, it } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { server } from './server';
import { errorHandler } from './handlers';
import { useProducts } from './useProducts';
function wrapper({ children }: { children: React.ReactNode }) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
describe('useProducts', () => {
it('loads the first page', async () => {
const { result } = renderHook(() => useProducts(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.pages[0].items).toHaveLength(20);
expect(result.current.hasNextPage).toBe(true);
});
it('accumulates pages via fetchNextPage', async () => {
const { result } = renderHook(() => useProducts(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await result.current.fetchNextPage();
await waitFor(() => expect(result.current.data?.pages).toHaveLength(2));
const total = result.current.data!.pages.flatMap((p) => p.items);
expect(total).toHaveLength(40);
});
it('surfaces server errors', async () => {
server.use(errorHandler);
const { result } = renderHook(() => useProducts(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toContain('500');
});
});
This snippet shows how Mock Service Worker (MSW) intercepts HTTP at the network layer so that a React data hook can be tested against realistic paginated responses instead of stubbed fetch functions. The advantage of MSW over jest.mock('fetch') is that the code under test issues real requests through the same client it uses in production; nothing about the fetching logic is faked, only the server on the other end.
The handlers.ts tab defines a single GET /api/products handler with http.get. It reads page and limit from the query string, slices a fixed in-memory PRODUCTS array, and returns HttpResponse.json with a hasMore flag. Modeling the boundary conditions here — the last page, an empty page, an oversized page — is what makes the handler valuable, because those are exactly the branches the hook must get right. The errorHandler demonstrates runtime overrides: tests can call server.use(errorHandler) to force a 500 for a single case without touching the default happy-path handler.
The server setup tab wires MSW into the test lifecycle. setupServer starts a Node-side interceptor; beforeAll boots it, afterEach calls server.resetHandlers() so per-test overrides don't leak, and afterAll tears it down. onUnhandledRequest: 'error' is deliberate — it turns any un-mocked request into a loud failure rather than a silent real network call.
The useProducts hook tab is the subject under test. It uses React Query's useInfiniteQuery, deriving the next page from getNextPageParam by inspecting lastPage.hasMore. Because the hook only knows about the HTTP contract, it needs no test-specific seams.
The useProducts.test.tsx tab exercises the real behavior: it renders the hook inside a QueryClientProvider with retry: false so the error path fails fast, waits for the first page with waitFor, then calls fetchNextPage and asserts the accumulated pages. The final test installs errorHandler and asserts result.current.isError, proving the failure branch. The main pitfall this avoids is asserting on loading flags too eagerly; waitFor polls until React Query settles, keeping the tests free of arbitrary timeouts.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
use my_crate::add;
#[test]
fn test_public_api() {
assert_eq!(add(3, 4), 7);
}
Integration tests in tests/ directory
Share this code
Here's the card — post it anywhere.