Network Interception
Tapsmith provides Playwright-style network interception for mobile apps. You can mock API responses, abort requests, modify traffic in flight, and wait for specific network activity — all from your test code. Network requests also appear in the trace viewer when capture is enabled.
This guide covers two related features:
- Network interception — route handlers that let you mock, modify, or block HTTP/HTTPS requests the app makes.
- Network capture — passive recording of all network traffic into traces for post-test inspection.
Both features share the same underlying MITM proxy infrastructure.
Prerequisites
Section titled “Prerequisites”Network interception requires the MITM proxy to be active. The proxy starts automatically when tracing is enabled with network: true (the default). Without it, route handlers never fire and waitForRequest/waitForResponse never resolve.
Enable tracing in your config:
import { defineConfig } from "tapsmith"
export default defineConfig({ trace: { mode: "on", network: true, // default when tracing is on },})Or from the CLI:
npx tapsmith test --trace onThe --trace on flag enables tracing with all defaults, including network: true. To disable network capture while keeping other trace features, use --no-network:
npx tapsmith test --trace on --no-networkWhen network capture is off, device.route() silently registers the handler but it will never fire because no traffic passes through the proxy.
Intercepting Requests with device.route()
Section titled “Intercepting Requests with device.route()”device.route(url, handler, options?)
Section titled “device.route(url, handler, options?)”Register a route handler that intercepts requests matching a URL pattern. The handler receives a Route object and must call one of route.fulfill(), route.continue(), route.abort(), or route.fetch() to resolve the request.
Parameters:
| Parameter | Type | Description |
|---|---|---|
url | string | RegExp | ((url: URL) => boolean) | URL pattern to match |
handler | (route: Route) => Promise<void> | void | Handler function |
options.times | number | Auto-remove after N matches |
URL pattern forms
Section titled “URL pattern forms”Glob string — the most common form. ** matches any path segment, * matches within a segment.
// Match any request to the /api/posts endpointawait device.route("**/api/posts", async (route) => { await route.fulfill({ json: [] })})
// Match a specific hostawait device.route("https://api.example.com/users/*", async (route) => { await route.fulfill({ json: { id: 1, name: "Test User" } })})
// Match any request containing "graphql" in the pathawait device.route("**/graphql", async (route) => { await route.fulfill({ json: { data: { viewer: null } } })})RegExp — for complex matching patterns.
// Match versioned API endpointsawait device.route(/\/api\/v[12]\/users/, async (route) => { await route.fulfill({ json: [] })})
// Match requests with specific query parametersawait device.route(/\/search\?.*q=/, async (route) => { await route.fulfill({ json: { results: [] } })})Predicate function — receives a URL object for full programmatic control.
// Match POST requests to any API endpointawait device.route( (url) => url.pathname.startsWith("/api/") && url.hostname === "example.com", async (route) => { if (route.request().method === "POST") { await route.fulfill({ status: 201, json: { created: true } }) } else { await route.continue() } },)Auto-removal with times
Section titled “Auto-removal with times”Limit how many requests a handler intercepts before it is automatically removed:
// Only mock the first request, then let subsequent ones throughawait device.route("**/api/config", async (route) => { await route.fulfill({ json: { featureFlag: true } })}, { times: 1 })Route Actions
Section titled “Route Actions”Every route handler receives a Route object. You must resolve the route by calling exactly one of these methods. If the handler returns without resolving, the request is continued upstream automatically (with a warning logged).
route.request()
Section titled “route.request()”Returns the intercepted TapsmithRequest with properties: method, url, headers, postData, isHttps.
await device.route("**/api/**", async (route) => { const request = route.request() console.log(`${request.method} ${request.url}`) console.log("Headers:", request.headers) if (request.postData) { console.log("Body:", request.postData.toString()) } await route.continue()})route.fulfill(options?)
Section titled “route.fulfill(options?)”Return a mock response without contacting the server. This is the primary method for mocking API responses.
| Option | Type | Default | Description |
|---|---|---|---|
status | number | 200 | HTTP status code |
headers | Record<string, string> | {} | Response headers |
body | string | Buffer | empty | Response body |
contentType | string | auto | Content-Type header |
json | unknown | — | JSON-serializes and sets content-type to application/json |
path | string | — | Read the response body from a file |
JSON mocking (most common):
await device.route("**/api/posts", async (route) => { await route.fulfill({ json: [ { id: 1, title: "First Post", author: "Alice" }, { id: 2, title: "Second Post", author: "Bob" }, ], })})Custom status codes:
await device.route("**/api/protected", async (route) => { await route.fulfill({ status: 401, json: { error: "Unauthorized", message: "Token expired" }, })})Response from a fixture file:
await device.route("**/api/products", async (route) => { await route.fulfill({ path: "./fixtures/products.json", contentType: "application/json", })})Custom headers:
await device.route("**/api/data", async (route) => { await route.fulfill({ status: 200, headers: { "Cache-Control": "no-cache", "X-Request-Id": "test-123", }, json: { data: "value" }, })})route.abort(errorCode?)
Section titled “route.abort(errorCode?)”Abort the request, simulating a network failure. The app sees the request fail as if the network were unreachable.
| Error Code | Effect |
|---|---|
| (none) | Generic abort |
"connectionrefused" | Connection refused |
"connectionreset" | Connection reset |
"timedout" | Request timed out |
// Simulate a network timeoutawait device.route("**/api/slow-endpoint", async (route) => { await route.abort("timedout")})
// Block all analytics requestsawait device.route("**/analytics/**", async (route) => { await route.abort()})
// Simulate server unreachableawait device.route("**/api/health", async (route) => { await route.abort("connectionrefused")})route.continue(overrides?)
Section titled “route.continue(overrides?)”Let the request proceed to the server with optional modifications. Use this to change the request before it reaches the backend.
| Override | Type | Description |
|---|---|---|
url | string | Override the request URL. Supports same-origin paths and cross-origin redirection. |
method | string | Override the HTTP method |
headers | Record<string, string> | Override request headers |
postData | string | Buffer | Override the request body |
Same-origin path change:
// Redirect v1 API calls to v2await device.route("**/api/v1/**", async (route) => { const url = route.request().url.replace("/api/v1/", "/api/v2/") await route.continue({ url })})Cross-origin redirection (the Host header is auto-updated):
// Redirect production API calls to a staging serverawait device.route("https://api.example.com/**", async (route) => { const url = route.request().url.replace( "https://api.example.com", "https://staging.example.com", ) await route.continue({ url })})Adding an auth header:
await device.route("**/api/**", async (route) => { await route.continue({ headers: { ...route.request().headers, "Authorization": "Bearer test-token-123", }, })})Modifying the request body:
await device.route("**/api/orders", async (route) => { const request = route.request() if (request.method === "POST" && request.postData) { const body = JSON.parse(request.postData.toString()) body.testMode = true await route.continue({ postData: JSON.stringify(body) }) } else { await route.continue() }})route.fetch(overrides?)
Section titled “route.fetch(overrides?)”Fetch the real response from the server, then inspect or modify it before fulfilling. This is the “modify response” pattern — the most powerful interception mode.
route.fetch() accepts the same overrides as route.continue() (url, method, headers, postData). It returns a FetchedAPIResponse with:
| Property / Method | Type | Description |
|---|---|---|
status | number | HTTP status code |
headers | Record<string, string> | Response headers |
body() | Buffer | Raw response body |
text() | string | Body as UTF-8 string |
json() | unknown | Body parsed as JSON |
After calling route.fetch(), you must call route.fulfill() to deliver the (potentially modified) response. You cannot call route.abort(), route.continue(), or route.fetch() again after fetching.
Modify a JSON response:
await device.route("**/api/users/me", async (route) => { // Get the real response const response = await route.fetch() const user = response.json() as { name: string; email: string; plan: string }
// Modify it user.plan = "premium" user.name = "Test User"
// Return the modified response await route.fulfill({ json: user })})Add a field to every API response:
await device.route("**/api/**", async (route) => { const response = await route.fetch() const data = response.json() as Record<string, unknown>
// Inject a test flag data._testEnvironment = true
await route.fulfill({ status: response.status, headers: response.headers, json: data, })})Remove sensitive fields from a response:
await device.route("**/api/users/*", async (route) => { const response = await route.fetch() const user = response.json() as Record<string, unknown>
delete user.ssn delete user.creditCard
await route.fulfill({ json: user })})Fetch from a different server and return its response:
await device.route("**/api/feature-flags", async (route) => { // Fetch from the staging environment instead const response = await route.fetch({ url: "https://staging.example.com/api/feature-flags", })
await route.fulfill({ status: response.status, headers: response.headers, body: response.body(), })})Removing Route Handlers
Section titled “Removing Route Handlers”device.unroute(url, handler?)
Section titled “device.unroute(url, handler?)”Remove a previously registered route handler. If handler is omitted, all handlers for that URL pattern are removed.
const handler = async (route: Route) => { await route.fulfill({ json: { mocked: true } })}
// Registerawait device.route("**/api/data", handler)
// Remove this specific handlerawait device.unroute("**/api/data", handler)
// Or remove all handlers for this patternawait device.unroute("**/api/data")device.unrouteAll()
Section titled “device.unrouteAll()”Remove all registered route handlers at once.
await device.unrouteAll()Best practice: Call device.unrouteAll() in afterEach to prevent route handlers from leaking between tests:
import { test, afterEach } from "tapsmith"
afterEach(async ({ device }) => { await device.unrouteAll()})
test("mocked list", async ({ device }) => { await device.route("**/api/items", async (route) => { await route.fulfill({ json: [{ id: 1, name: "Mocked" }] }) }) // ...})
test("real list", async ({ device }) => { // No route handler -- requests go to the real server // ...})Waiting for Network Events
Section titled “Waiting for Network Events”These methods let you wait for specific network activity without intercepting it. Useful for asserting that the app made the right API calls.
device.waitForRequest(urlOrPredicate, options?)
Section titled “device.waitForRequest(urlOrPredicate, options?)”Wait for an outgoing request matching the pattern. Returns a TapsmithRequest.
Parameters:
| Parameter | Type | Description |
|---|---|---|
urlOrPredicate | string | RegExp | ((request: TapsmithRequest) => boolean) | URL pattern or predicate |
options.timeout | number | Timeout in ms (default: device timeout, 30s) |
// Wait for a request by globconst request = await device.waitForRequest("**/api/orders")expect(request.method).toBe("POST")
// Wait for a request by RegExpconst req = await device.waitForRequest(/\/api\/v\d+\/users/)console.log(req.url)
// Wait for a request matching a predicateconst createReq = await device.waitForRequest( (req) => req.url.includes("/api/orders") && req.method === "POST",)const body = JSON.parse(createReq.postData!.toString())expect(body.items).toHaveLength(2)device.waitForResponse(urlOrPredicate, options?)
Section titled “device.waitForResponse(urlOrPredicate, options?)”Wait for a response matching the pattern. Returns a NetworkResponseEventData with method, url, status, headers, body, and routeAction.
// Wait for a successful responseconst response = await device.waitForResponse("**/api/orders")expect(response.status).toBe(200)
// Wait for a specific status codeconst errorResponse = await device.waitForResponse( (resp) => resp.url.includes("/api/login") && resp.status === 401,)
// Parse the response bodyconst resp = await device.waitForResponse("**/api/users/me")const user = JSON.parse(resp.body!.toString())expect(user.name).toBe("Alice")Combining waits with actions
Section titled “Combining waits with actions”The typical pattern is to start waiting before triggering the action that causes the network request:
test("submitting the form creates an order", async ({ device }) => { // Start waiting BEFORE the tap that triggers the request const requestPromise = device.waitForRequest("**/api/orders")
// Trigger the request await device.getByRole("button", { name: "Place Order" }).tap()
// Now await the request const request = await requestPromise expect(request.method).toBe("POST")
const body = JSON.parse(request.postData!.toString()) expect(body.items).toHaveLength(3) expect(body.total).toBe(29.97)})Subscribing to Network Events
Section titled “Subscribing to Network Events”For continuous monitoring (rather than waiting for a single event), use device.on() and device.off().
device.on('request', handler) / device.on('response', handler)
Section titled “device.on('request', handler) / device.on('response', handler)”Subscribe to all request or response events.
// Log all requests during a testconst requests: string[] = []device.on("request", (req) => { requests.push(`${req.method} ${req.url}`)})
// ... run test actions ...
console.log("Requests made:", requests)device.off('request', handler) / device.off('response', handler)
Section titled “device.off('request', handler) / device.off('response', handler)”Unsubscribe from events. Pass the same function reference used with device.on().
const handler = (req: TapsmithRequest) => { console.log(req.url)}
device.on("request", handler)// ... later ...device.off("request", handler)Practical Patterns
Section titled “Practical Patterns”Mocking an API endpoint
Section titled “Mocking an API endpoint”A complete test that mocks a list API and verifies the UI renders the mock data:
import { test, expect } from "tapsmith"
test("displays mocked product list", async ({ device }) => { // Mock the API before the screen loads the data await device.route("**/api/products", async (route) => { await route.fulfill({ json: [ { id: 1, name: "Widget", price: 9.99, inStock: true }, { id: 2, name: "Gadget", price: 19.99, inStock: false }, ], }) })
// Navigate to the screen that fetches products await device.openDeepLink("myapp://products")
// Verify the mocked data renders await expect(device.getByText("Widget")).toBeVisible() await expect(device.getByText("$9.99")).toBeVisible() await expect(device.getByText("Gadget")).toBeVisible() await expect(device.getByText("Out of Stock")).toBeVisible()})Testing error states
Section titled “Testing error states”Mock server errors and network failures to verify the app handles them correctly:
import { test, expect } from "tapsmith"
test("shows error screen on server failure", async ({ device }) => { await device.route("**/api/products", async (route) => { await route.fulfill({ status: 500, json: { error: "Internal Server Error" }, }) })
await device.openDeepLink("myapp://products") await expect(device.getByText("Something went wrong")).toBeVisible() await expect(device.getByRole("button", { name: "Retry" })).toBeVisible()})
test("shows offline banner on network failure", async ({ device }) => { await device.route("**/api/**", async (route) => { await route.abort("connectionrefused") })
await device.openDeepLink("myapp://dashboard") await expect(device.getByText("No internet connection")).toBeVisible()})
test("handles timeout gracefully", async ({ device }) => { await device.route("**/api/slow", async (route) => { await route.abort("timedout") })
await device.getByRole("button", { name: "Load Data" }).tap() await expect(device.getByText("Request timed out")).toBeVisible()})Modifying responses
Section titled “Modifying responses”Inject edge cases into real API responses to test boundary conditions:
import { test, expect } from "tapsmith"
test("handles empty list from API", async ({ device }) => { await device.route("**/api/notifications", async (route) => { // Fetch the real response, then replace the items with an empty array const response = await route.fetch() const data = response.json() as { items: unknown[]; total: number } data.items = [] data.total = 0 await route.fulfill({ json: data }) })
await device.openDeepLink("myapp://notifications") await expect(device.getByText("No notifications yet")).toBeVisible()})
test("handles extremely long names", async ({ device }) => { await device.route("**/api/users/me", async (route) => { const response = await route.fetch() const user = response.json() as { displayName: string } user.displayName = "A".repeat(200) // stress-test the UI with a very long name await route.fulfill({ json: user }) })
await device.openDeepLink("myapp://profile") // Verify the UI doesn't break -- name should be truncated or wrapped await expect(device.getByTestId("profile-name")).toBeVisible()})Asserting on network traffic
Section titled “Asserting on network traffic”Verify the app sends the correct requests with the right payloads:
import { test, expect } from "tapsmith"
test("search sends the right query parameters", async ({ device }) => { const requestPromise = device.waitForRequest("**/api/search**")
await device.getByPlaceholder("Search products").type("wireless headphones") await device.getByRole("button", { name: "Search" }).tap()
const request = await requestPromise expect(request.method).toBe("GET") expect(request.url).toContain("q=wireless+headphones")})
test("checkout sends correct order payload", async ({ device }) => { const requestPromise = device.waitForRequest( (req) => req.url.includes("/api/orders") && req.method === "POST", )
await device.getByRole("button", { name: "Place Order" }).tap()
const request = await requestPromise const body = JSON.parse(request.postData!.toString()) expect(body.items).toHaveLength(2) expect(body.currency).toBe("USD")})
test("login sends credentials to the right endpoint", async ({ device }) => { const [request, response] = await Promise.all([ device.waitForRequest("**/api/auth/login"), device.waitForResponse("**/api/auth/login"), // Trigger the login device.getByRole("button", { name: "Sign In" }).tap(), ])
expect(request.method).toBe("POST") expect(request.isHttps).toBe(true) expect(response.status).toBe(200)})Mocking once, then letting through
Section titled “Mocking once, then letting through”Use times to mock only the first request (e.g., show cached data on initial load, then real data on refresh):
import { test, expect } from "tapsmith"
test("pull-to-refresh fetches fresh data", async ({ device }) => { // First load returns stale data await device.route("**/api/feed", async (route) => { await route.fulfill({ json: [{ id: 1, title: "Cached Item" }], }) }, { times: 1 })
await device.openDeepLink("myapp://feed") await expect(device.getByText("Cached Item")).toBeVisible()
// Pull-to-refresh hits the real server (route was auto-removed after 1 match) await device.swipe("down") // The real server's response will render now})Network Capture in Traces
Section titled “Network Capture in Traces”When tracing is enabled with network: true, all HTTP/HTTPS traffic from the device is recorded in the trace archive. This is separate from route interception — capture records passively, while interception lets you modify traffic.
How capture works
Section titled “How capture works”The Rust daemon (tapsmith-core) runs a local MITM proxy. Traffic routing depends on the platform:
| Platform | Mechanism | Scope |
|---|---|---|
| Android | adb reverse + http_proxy system setting | All apps on the emulator/device |
| iOS simulator | macOS Network Extension (per-PID filtering) | Only the simulator’s process tree |
| iOS physical | Wi-Fi proxy via .mobileconfig profile | System-wide (all apps on the device) |
HTTPS is decrypted using an auto-generated CA certificate installed on the device. On iOS simulators, this is injected transparently via xcrun simctl keychain add-root-cert. On Android, it is pushed via ADB.
Captured requests appear in the trace viewer’s Network tab alongside screenshots, view hierarchy snapshots, and console output.
Viewing network data in traces
Section titled “Viewing network data in traces”Open a trace archive:
npx tapsmith show-trace tapsmith-results/traces/trace-my_test.zipThe Network tab shows a sortable table with columns for method, URL, status code, content type, duration, and response size. Click a row to expand request/response headers and bodies. JSON bodies are pretty-printed automatically.
Route handler actions also appear as events in the trace viewer’s actions panel (e.g., route.fulfill, route.abort), with the source location of your handler code highlighted.
API request fixture in traces
Section titled “API request fixture in traces”When using the request fixture for test-level API calls (seeding data, checking backend state), those calls also appear in the Network tab and the actions panel:
test("shows created item", async ({ device, request }) => { // This POST appears in the trace await request.post("https://api.example.com/items", { data: { name: "Test Item" }, })
await device.getByText("Refresh").tap() await expect(device.getByText("Test Item")).toBeVisible()})Filtering captured traffic
Section titled “Filtering captured traffic”On Android and iOS physical devices, the proxy captures traffic from all apps, not just the app under test. System services, Google Play, Apple background tasks, and other apps all show up. Use networkHosts and networkIgnoreHosts to control what ends up in the trace.
Allowlist — only keep traffic from your app’s API hosts:
import { defineConfig } from "tapsmith"
export default defineConfig({ trace: { mode: "retain-on-failure", networkHosts: ["*.myapp.com", "api.partner.example"], },})Denylist — keep everything except known noise:
import { defineConfig } from "tapsmith"
export default defineConfig({ trace: { mode: "on", networkIgnoreHosts: [ // Android emulator system traffic "connectivitycheck.gstatic.com", "*.googleapis.com", "play.googleapis.com", "mtalk.google.com", "android.clients.google.com", "www.google.com", "clients*.google.com", // iOS background traffic (physical devices) "*.apple.com", "*.icloud.com", "captive.apple.com", ], },})Both fields accept glob patterns. Matching is case-insensitive. When both are set, an entry is kept only if it matches the allowlist AND does not match the denylist — deny wins on conflicts.
iOS simulators are already per-PID filtered by the macOS Network Extension, so system noise is minimal. The filters still work there for additional cleanup if needed.
Platform differences
Section titled “Platform differences”Android emulators/devices: The HTTP proxy is set globally via adb shell settings put global http_proxy, so every app and system service on the device routes through it. Use networkHosts or networkIgnoreHosts to reduce noise in traces.
iOS simulators: The macOS Network Extension intercepts traffic per-PID, filtering to only the simulator’s process tree. Parallel iOS workers each get their own isolated capture session. Host browser traffic is never affected. See iOS Network Capture for first-run setup (a one-time System Extension approval).
iOS physical devices: A Wi-Fi proxy configuration profile (.mobileconfig) routes the device’s traffic through the host Mac. This is system-wide — there is no per-app filtering at the OS level. Use networkHosts to scope what appears in the trace. See iOS Network Capture for the device-specific setup flow.
Reference
Section titled “Reference”- API Reference — full method signatures for
device.route(),Route,TapsmithRequest, and related types - Trace Viewer — how to view and navigate trace archives
- Configuration —
TraceConfigoptions includingnetwork,networkHosts,networkIgnoreHosts - iOS Network Capture — iOS-specific setup, troubleshooting, and the Network Extension architecture