@klogt/intercept
v2.0.2
Published
The lightweight HTTP interception library built for modern Node.js testing. MSW-inspired simplicity meets native `fetch` performance.
Maintainers
Readme
@klogt/intercept
Stop writing mocks. Start declaring routes.
The lightweight HTTP interception library built for modern Node.js testing.
MSW-inspired simplicity meets native fetch performance.
Why Choose @klogt/intercept?
Testing APIs shouldn't be hard. You shouldn't need to:
- Mock individual functions for every network call
- Set up complex test servers
- Learn heavyweight frameworks when you just need simple interception
- Wait for slow, flaky tests
With @klogt/intercept, you declare routes right in your tests — exactly like you think about them.
// That's it. No setup files, no complex config.
intercept.get("/users").resolve([{ id: 1, name: "Ada" }]);Built for Modern JavaScript
✨ Native Node 20+ fetch — no polyfills, no patches, just works
🎯 Zero dependencies — only 2.6kb gzipped, installs in milliseconds
🔧 Works with your stack — Vitest, Jest, React Testing Library, TanStack Query
🚀 Millisecond setup — one file, three lines, done
📦 TypeScript-first — full type inference, no any in sight
⚡ Lightning fast — tests run at native speed
Perfect For
- 🧪 Frontend developers testing React, Vue, or Svelte apps
- 🏗️ Integration tests that need predictable API responses
- 🔄 TanStack Query users who want cleaner test mocks
- 📱 Component testing with React Testing Library
- ⚙️ CI/CD pipelines where speed matters
Quick Example
import { intercept } from "@klogt/intercept";
it("fetches and displays users", async () => {
// Start intercepting with your API origin
intercept.listen({
origin: 'https://api.example.com',
onUnhandledRequest: 'error'
});
// Declare what the API should return
intercept.get("/users").resolve([
{ id: 1, name: "Ada Lovelace" },
{ id: 2, name: "Grace Hopper" }
]);
// Run your component/code
render(<UserList />);
// Assert the results
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
expect(await screen.findByText("Grace Hopper")).toBeInTheDocument();
});No mocks. No spies. No complexity. Just declare routes and test.
Features That Matter
🎯 Inline route declarations — define mocks exactly where you need them
📝 Smart defaults — POST returns 201, DELETE returns 204, etc.
🔍 Path parameters — /users/:id just works
⏱️ Delay simulation — test loading states with .delay(500)
🚨 Error scenarios — .reject({ status: 500 }) for error testing
🔄 Works with Axios — optional adapter for non-fetch clients
🧹 Test isolation — intercept.reset() between tests
🛠️ Unhandled request strategies — warn, bypass, or error on unexpected calls
Table of Contents
- Requirements
- Installation
- Quick start (Vitest)
- Understanding Origins
- Using with React + TanStack Query
- Defining routes
- Ignoring requests
- Unhandled requests
- Axios adapter (optional)
- Common Testing Patterns
- API reference
- Troubleshooting
- Comparison with MSW
- Contributing
- License
Requirements
- Node 20+ (uses built-in
fetch) - Test runner: Vitest or Jest
Installation
npm
npm i -D @klogt/interceptpnpm
pnpm add -D @klogt/interceptyarn
yarn add -D @klogt/interceptNo need to install axios unless you plan to attach the Axios adapter.
Quick start (Vitest)
Getting started: Inline approach
The simplest way to get started is to call intercept.listen() directly in your tests:
import { intercept } from "@klogt/intercept";
it("fetches users from API", async () => {
// Start intercepting with your API origin
intercept.listen({
origin: 'https://api.example.com',
onUnhandledRequest: 'error'
});
// Declare what the API should return
intercept.get("/users").resolve([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" }
]);
// Your code calls fetch('/users') - it gets the mocked response
const res = await fetch('/users');
const users = await res.json();
expect(users).toHaveLength(2);
expect(users[0].name).toBe("Ada");
});This works great for quick tests! However, you'll likely want to extract the setup to avoid repetition.
Note: onUnhandledRequest defaults to 'error' in test environments (Vitest/Jest) and 'warn' otherwise, so you can omit it if the default works for you.
Setup Option 1: Using setupIntercept() helper
For minimal boilerplate, use the setupIntercept() helper in a shared setup file:
// setupTests.ts
import { setupIntercept } from "@klogt/intercept";
setupIntercept({
origin: 'https://api.example.com',
onUnhandledRequest: 'error'
});This automatically registers beforeAll, afterEach, and afterAll hooks for you. Then configure this file in your vitest.config.ts:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
setupFiles: ['./setupTests.ts'],
},
});Now your tests can be clean and focused:
import { intercept } from "@klogt/intercept";
it("fetches users from API", async () => {
// Just declare routes - setup is handled automatically
intercept.get("/users").resolve([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" }
]);
const res = await fetch('/users');
const users = await res.json();
expect(users).toHaveLength(2);
});Alternative: Per-test suite setup
If you prefer to keep setup within your test files, you can use describe blocks with lifecycle hooks:
import { intercept } from "@klogt/intercept";
describe("User API", () => {
beforeAll(() => {
intercept.listen({
origin: 'https://api.example.com',
onUnhandledRequest: 'error'
});
});
afterEach(() => {
intercept.reset();
});
afterAll(() => {
intercept.close();
});
it("fetches users", async () => {
intercept.get("/users").resolve([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" }
]);
const res = await fetch('/users');
const users = await res.json();
expect(users).toHaveLength(2);
});
it("handles errors", async () => {
intercept.get("/users").reject({ status: 500 });
await expect(fetch('/users')).rejects.toThrow();
});
});This approach gives you control over the interceptor lifecycle per test suite without needing a shared setup file.
Setup Option 2: Shared setup file (manual approach)
For maximum control (custom logging, conditional setup, etc.), you can manually manage the lifecycle hooks:
// setupTests.ts
import { intercept } from "@klogt/intercept";
beforeAll(() => {
intercept.listen({
origin: 'https://api.example.com',
onUnhandledRequest: 'error'
});
});
afterEach(() => {
intercept.reset();
});
afterAll(() => {
intercept.close();
});Note: This is what setupIntercept() does for you under the hood. Use this manual approach when you need to add custom logic to the lifecycle hooks.
Configure this file in your vitest.config.ts:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
setupFiles: ['./setupTests.ts'],
},
});Understanding Origins
When you use relative paths like /users, you need to set a base URL:
intercept.listen({
origin: 'https://api.example.com',
onUnhandledRequest: 'error'
});
intercept.get("/users").resolve([{ id: 1 }]);
// Matches: https://api.example.com/usersAbsolute URLs bypass the origin and match exactly:
intercept.get("https://payments.stripe.com/v1/charges").resolve({ id: "ch_123" });
// Matches exactly - ignores origin settingYou can mix relative and absolute URLs in the same test as needed.
Using with React + TanStack Query
@klogt/intercept plays nicely with data fetching libraries like TanStack Query.
Here's a simple example component:
// Users.tsx
import { useQuery } from "@tanstack/react-query";
export function Users() {
const { data, isLoading, error } = useQuery({
queryKey: ["users"],
queryFn: async () => {
const res = await fetch("/users");
if (!res.ok) throw new Error("Failed to fetch users");
return res.json();
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data.map((u: { id: number; name: string }) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}Test with intercept
// Users.test.tsx
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Users } from "./Users";
import { intercept } from "@klogt/intercept";
function renderWithQuery(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
);
}
it("renders users from mocked API", async () => {
intercept.get("/users").resolve([
{ id: 1, name: "Ada" },
{ id: 2, name: "Grace" },
]);
renderWithQuery(<Users />);
expect(await screen.findByText("Ada")).toBeInTheDocument();
expect(await screen.findByText("Grace")).toBeInTheDocument();
});
it("shows error state on API failure", async () => {
intercept.get("/users").reject({
status: 500,
body: { message: "Server error" }
});
renderWithQuery(<Users />);
expect(await screen.findByText(/Error:/)).toBeInTheDocument();
});Defining routes
Basic responses
// GET /users → 200 with array
intercept.get("/users").resolve([{ id: 1, name: "Ada" }]);
// POST /users → 201 with body
intercept.post("/users").resolve({ id: 2, name: "Grace" });
// DELETE /users/2 → 204 (no content)
intercept.delete("/users/:id").resolve(null, { status: 204 });
// PUT with custom status and headers
intercept.put("/profile").resolve(
{ id: "me", updated: true },
{
status: 200,
headers: { "x-request-id": "abc123" },
}
);Error responses
// Reject with error status and body
intercept.post("/login").reject({
status: 401,
body: { code: "INVALID_CREDENTIALS", message: "Invalid username or password" }
});
// 404 Not Found
intercept.get("/users/:id").reject({
status: 404,
body: { error: "User not found" }
});
// 422 Validation Error
intercept.post("/users").reject({
status: 422,
body: {
errors: [
{ field: "email", message: "Email is required" }
]
}
});Path parameters
// Match dynamic segments with :param
intercept.get("/users/:id").resolve({ id: "123", name: "Ada" });
// Access params in dynamic resolver
intercept.get("/users/:id").handle(({ params }) => {
return Response.json({
id: params.id,
name: `User ${params.id}`
});
});
// Multiple params
intercept.get("/orgs/:orgId/repos/:repoId").handle(({ params }) => {
return Response.json({
org: params.orgId,
repo: params.repoId
});
});
// Catch-all wildcard
intercept.get("/*").resolve({ message: "Catch all requests" });Adding delays with .delay()
Simulate network latency to test loading states:
// Resolve after 500ms
intercept.get("/users").delay(500).resolve([{ id: 1, name: "Ada" }]);
// Reject after 1 second
intercept.post("/login").delay(1000).reject({
status: 401,
body: { error: "Timeout" }
});Dynamic resolvers with .handle()
Need to compute a response based on the incoming request? Use .handle() for full control:
intercept.post("/login").handle(async ({ request, body, params }) => {
// body is parsed JSON (best-effort)
if (body?.username === "admin" && body?.password === "secret") {
return Response.json({ token: "abc123", user: { id: 1, name: "Admin" } });
}
return Response.json(
{ error: "Invalid credentials" },
{ status: 401 }
);
});
// Access request headers
intercept.get("/protected").handle(({ request }) => {
const auth = request.headers.get("Authorization");
if (!auth) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
return Response.json({ data: "secret data" });
});
// Use path params
intercept.delete("/users/:id").handle(({ params }) => {
console.log(`Deleting user ${params.id}`);
return new Response(null, { status: 204 });
});Ignoring requests
Ignore requests to given paths across all HTTP methods. Handy for analytics, health checks, or any traffic you don't want your tests to care about. Returns 204 No Content immediately to prevent test failures.
intercept.ignore(paths: ReadonlyArray<Path>)Example:
// In setupTests.ts or beforeEach
intercept.ignore(['/analytics', '/ping', '/health-check']);
// All these return 204 immediately:
fetch('/analytics'); // GET
fetch('/ping', { method: 'POST' }); // POST
fetch('/health-check'); // any methodCommon use cases:
// Ignore analytics and monitoring
intercept.ignore([
'/api/analytics/*',
'/api/metrics',
'/api/telemetry'
]);
// Ignore third-party health checks
intercept.ignore([
'/health',
'/ready',
'/livez'
]);Unhandled requests
Configure once when starting the server:
intercept.listen({ onUnhandledRequest: "error" });Strategies
"error"(recommended for tests): Blocks unhandled requests with 501 status and logs an error[@klogt/intercept] ❌ Unhandled request (error mode) → GET https://api.example.com/unknown No intercept handler matched this request. The request was blocked with a 501 response. Tip: add one with: intercept.get('/unknown').resolve(...)"warn": Logs a warning but allows the request to pass through to the real transport[@klogt/intercept] 🚧 Unhandled request → GET https://api.example.com/unknown No intercept handler matched this request. Tip: add one with: intercept.get('/unknown').resolve(...)"bypass": Silently allows requests to pass through (no logging)Function: Decide dynamically per request
intercept.listen({ onUnhandledRequest: ({ request, url }) => { // Ignore OPTIONS requests if (request.method === 'OPTIONS') return 'bypass'; // Warn about others return 'warn'; } });
Axios adapter (optional)
@klogt/intercept can intercept both fetch and Axios. Simply pass your axios instance to intercept.listen():
import axios from "axios";
const apiClient = axios.create({
baseURL: "https://api.example.com"
});
// In setupTests.ts
intercept.listen({
onUnhandledRequest: 'error',
adapter: apiClient // Automatically wrapped and attached
});No runtime axios dependency: the adapter's types reference axios conditionally so your library/app doesn't pull axios unless you install it yourself.
How it works: When you pass an axios instance to the adapter option, intercept automatically wraps it with an internal adapter that makes your axios requests go through the same route handlers as fetch requests.
Common Testing Patterns
Baseline routes with per-test overrides
// setupTests.ts
beforeAll(() => {
intercept
.listen({ onUnhandledRequest: 'error' })
.origin('https://api.example.com');
// Baseline routes that apply to all tests
intercept.get("/config").resolve({ version: "1.0" });
intercept.get("/health").resolve({ status: "ok" });
});
// users.test.ts
it("fetches users", () => {
// Override or add specific routes for this test
intercept.get("/users").resolve([{ id: 1 }]);
// ... test code
});Testing error scenarios
it("handles network errors gracefully", async () => {
intercept.get("/users").reject({
status: 500,
body: { error: "Internal server error" }
});
const { result } = renderHook(() => useUsers(), {
wrapper: QueryClientProvider
});
await waitFor(() => {
expect(result.current.error).toBeTruthy();
});
});Ignoring non-essential requests
beforeEach(() => {
// Ignore analytics so they don't cause test failures
intercept.ignore([
'/api/analytics/*',
'/api/tracking',
'/health'
]);
});API reference
intercept.listen(options)
Start intercepting. Must be called before defining routes.
intercept.listen({
origin?: string;
onUnhandledRequest?: "warn" | "bypass" | "error" | ((args) => Strategy);
adapter?: unknown;
});Parameters:
origin: (optional) Base URL for relative paths. Can also be set later via.origin()onUnhandledRequest: (optional) Strategy for unhandled requests. Defaults to'error'in test environments (Vitest/Jest) and'warn'otherwiseadapter: (optional) Axios instance to intercept (see Axios adapter section)
Returns intercept for chaining with .origin().
intercept.origin(url)
Set the base URL for relative paths. Can be called in beforeAll (applies to whole file) or beforeEach (per-test).
intercept.origin('https://api.example.com');Returns intercept for chaining.
intercept.<method>(path)
Create a route for GET | POST | PUT | PATCH | DELETE | OPTIONS.
intercept.get(path: Path)
intercept.post(path: Path)
intercept.put(path: Path)
intercept.patch(path: Path)
intercept.delete(path: Path)
intercept.options(path: Path)Each returns an object with:
.delay(ms)
Add a delay before responding. Returns a chainable object with resolve/reject/handle methods:
delay(ms: number): {
resolve<T>(data: T, init?: ResolveInit): void;
reject<T>(opts?: RejectInit<T>): void;
handle<TRequest>(resolver: DynamicResolver<TRequest>): void;
}Example:
intercept.get("/users").delay(500).resolve([{ id: 1 }]);
intercept.post("/login").delay(1000).reject({ status: 401 });.resolve(data, init?)
Return successful JSON response:
resolve<T>(data: T, init?: {
status?: number; // Default: method-specific (200, 201, 204)
headers?: Record<string, string>;
}): void;If status is 204, body is stripped automatically.
.reject(opts?)
Return error response:
reject<T>(opts?: {
status?: number; // Default: 400
body?: T | undefined; // Optional error body
headers?: Record<string, string>;
}): void;.fetching(init?)
Simulate pending/loading state:
fetching(init?: {
delayMs?: number; // Undefined = hang forever
status?: number; // Default: 204
headers?: Record<string, string>;
}): void;.handle(resolver)
Full control with custom logic:
handle<TRequest>(
resolver: (args: {
request: Request;
url: URL;
params: Record<string, string>;
body: TRequest | undefined; // Parsed JSON, best-effort
}) => Response | Promise<Response>
): void;intercept.ignore(paths)
Ignore requests to given paths across all HTTP methods:
intercept.ignore(paths: ReadonlyArray<Path>): void;Returns 204 No Content immediately.
intercept.reset()
Clear all registered handlers. Use in afterEach for test isolation:
afterEach(() => {
intercept.reset();
});intercept.close()
Stop intercepting, detach adapters, restore globals, and clear all state. Use in afterAll:
afterAll(() => {
intercept.close();
});setupIntercept(options)
Convenience helper that automatically registers lifecycle hooks for minimal boilerplate. Use this when you don't need custom logic in your hooks.
setupIntercept(options?: {
origin?: string;
onUnhandledRequest?: "warn" | "bypass" | "error" | ((args) => Strategy);
adapter?: unknown;
}): void;Parameters:
options: (optional) Configuration object with the same options asintercept.listen():origin: Base URL for relative pathsonUnhandledRequest: Strategy for unhandled requests (default: auto-detected)adapter: Axios instance to intercept
What it does:
- Automatically calls
intercept.listen(options)in abeforeAllhook - Automatically calls
intercept.reset()in anafterEachhook - Automatically calls
intercept.close()in anafterAllhook
Example:
// setupTests.ts
import { setupIntercept } from "@klogt/intercept";
setupIntercept({
origin: 'https://api.example.com',
onUnhandledRequest: 'error'
});When to use:
- You have a single API origin for your entire test suite
- You don't need custom logic in lifecycle hooks
- You want minimal boilerplate
When to use manual setup instead:
- You need custom logging or debugging in hooks
- You need conditional or dynamic setup
- You need to integrate with other test setup/teardown
- You need per-test origins
Troubleshooting
"No intercept handler matched this request"
This error means you tried to make a request that doesn't have a matching route. The error message now includes helpful context:
[@klogt/intercept] ❌ Unhandled request (error mode)
→ GET https://api.example.com/user
No intercept handler matched this request.
The request was blocked with a 501 response.
Registered handlers:
GET /users
POST /users
GET /profile
Did you mean: GET /users?Common causes:
Forgot to set origin for relative paths:
// ❌ This won't work intercept.get("/users").resolve([...]); await fetch('/users'); // Error: no handler matched // ✅ Set origin first intercept.origin('https://api.example.com'); intercept.get("/users").resolve([...]); await fetch('/users'); // Works!Path mismatch (typo or wrong params):
intercept.get("/users").resolve([...]); await fetch('/user'); // ❌ Typo - check "Did you mean?" suggestionMethod mismatch:
intercept.get("/users").resolve([...]); await fetch('/users', { method: 'POST' }); // ❌ Handler is for GET
Tip: The error message now shows all registered handlers and may suggest the closest match to help you spot typos quickly!
"Cannot find module 'axios' or its type declarations"
You don't need axios unless you attach the adapter. If you see this error:
- Either install axios:
npm i axios - Or don't import from
@klogt/intercept/axios
The axios adapter uses conditional type imports to avoid a hard dependency.
Tests are flaky or handlers leak between tests
Always call intercept.reset() in afterEach:
afterEach(() => {
intercept.reset();
});This ensures each test starts with a clean slate.
Relative paths don't work
Make sure you've called .origin():
// In setupTests.ts or beforeAll
intercept
.listen({ onUnhandledRequest: 'error' })
.origin('https://api.example.com');Or use absolute URLs:
intercept.get("https://api.example.com/users").resolve([...]);Comparison with MSW
If you're familiar with MSW, here's how @klogt/intercept compares:
| Feature | @klogt/intercept | MSW |
|---------|------------------|-----|
| Setup complexity | Minimal - one setup file | Requires worker setup |
| Native fetch support | Built-in (Node 20+) | Via msw/node |
| Route definition | Inline in tests | Inline or separate handlers |
| Path params | :param syntax | :param syntax |
| TypeScript | First-class, no any | Good support |
| Browser support | No (test-only) | Yes |
| Bundle size | Small | Larger |
| Maturity | New | Established |
Choose @klogt/intercept if:
- You only need test interception (not browser)
- You want minimal setup
- You prefer Node 20+ native features
- You want a lightweight package
Choose MSW if:
- You need browser support (dev mode mocking)
- You want battle-tested stability
- You need advanced features like streaming
Contributing
Contributions are welcome! Please open an issue or PR on GitHub.
License
MIT © Klogt
