Skip to content

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:

  1. Network interception — route handlers that let you mock, modify, or block HTTP/HTTPS requests the app makes.
  2. Network capture — passive recording of all network traffic into traces for post-test inspection.

Both features share the same underlying MITM proxy infrastructure.

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:

Terminal window
npx tapsmith test --trace on

The --trace on flag enables tracing with all defaults, including network: true. To disable network capture while keeping other trace features, use --no-network:

Terminal window
npx tapsmith test --trace on --no-network

When network capture is off, device.route() silently registers the handler but it will never fire because no traffic passes through the proxy.


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:

ParameterTypeDescription
urlstring | RegExp | ((url: URL) => boolean)URL pattern to match
handler(route: Route) => Promise<void> | voidHandler function
options.timesnumberAuto-remove after N matches

Glob string — the most common form. ** matches any path segment, * matches within a segment.

// Match any request to the /api/posts endpoint
await device.route("**/api/posts", async (route) => {
await route.fulfill({ json: [] })
})
// Match a specific host
await 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 path
await device.route("**/graphql", async (route) => {
await route.fulfill({ json: { data: { viewer: null } } })
})

RegExp — for complex matching patterns.

// Match versioned API endpoints
await device.route(/\/api\/v[12]\/users/, async (route) => {
await route.fulfill({ json: [] })
})
// Match requests with specific query parameters
await 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 endpoint
await 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()
}
},
)

Limit how many requests a handler intercepts before it is automatically removed:

// Only mock the first request, then let subsequent ones through
await device.route("**/api/config", async (route) => {
await route.fulfill({ json: { featureFlag: true } })
}, { times: 1 })

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).

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()
})

Return a mock response without contacting the server. This is the primary method for mocking API responses.

OptionTypeDefaultDescription
statusnumber200HTTP status code
headersRecord<string, string>{}Response headers
bodystring | BufferemptyResponse body
contentTypestringautoContent-Type header
jsonunknownJSON-serializes and sets content-type to application/json
pathstringRead 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" },
})
})

Abort the request, simulating a network failure. The app sees the request fail as if the network were unreachable.

Error CodeEffect
(none)Generic abort
"connectionrefused"Connection refused
"connectionreset"Connection reset
"timedout"Request timed out
// Simulate a network timeout
await device.route("**/api/slow-endpoint", async (route) => {
await route.abort("timedout")
})
// Block all analytics requests
await device.route("**/analytics/**", async (route) => {
await route.abort()
})
// Simulate server unreachable
await device.route("**/api/health", async (route) => {
await route.abort("connectionrefused")
})

Let the request proceed to the server with optional modifications. Use this to change the request before it reaches the backend.

OverrideTypeDescription
urlstringOverride the request URL. Supports same-origin paths and cross-origin redirection.
methodstringOverride the HTTP method
headersRecord<string, string>Override request headers
postDatastring | BufferOverride the request body

Same-origin path change:

// Redirect v1 API calls to v2
await 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 server
await 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()
}
})

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 / MethodTypeDescription
statusnumberHTTP status code
headersRecord<string, string>Response headers
body()BufferRaw response body
text()stringBody as UTF-8 string
json()unknownBody 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(),
})
})

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 } })
}
// Register
await device.route("**/api/data", handler)
// Remove this specific handler
await device.unroute("**/api/data", handler)
// Or remove all handlers for this pattern
await device.unroute("**/api/data")

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
// ...
})

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:

ParameterTypeDescription
urlOrPredicatestring | RegExp | ((request: TapsmithRequest) => boolean)URL pattern or predicate
options.timeoutnumberTimeout in ms (default: device timeout, 30s)
// Wait for a request by glob
const request = await device.waitForRequest("**/api/orders")
expect(request.method).toBe("POST")
// Wait for a request by RegExp
const req = await device.waitForRequest(/\/api\/v\d+\/users/)
console.log(req.url)
// Wait for a request matching a predicate
const 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 response
const response = await device.waitForResponse("**/api/orders")
expect(response.status).toBe(200)
// Wait for a specific status code
const errorResponse = await device.waitForResponse(
(resp) => resp.url.includes("/api/login") && resp.status === 401,
)
// Parse the response body
const resp = await device.waitForResponse("**/api/users/me")
const user = JSON.parse(resp.body!.toString())
expect(user.name).toBe("Alice")

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)
})

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 test
const 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)

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()
})

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()
})

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()
})

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)
})

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
})

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.

The Rust daemon (tapsmith-core) runs a local MITM proxy. Traffic routing depends on the platform:

PlatformMechanismScope
Androidadb reverse + http_proxy system settingAll apps on the emulator/device
iOS simulatormacOS Network Extension (per-PID filtering)Only the simulator’s process tree
iOS physicalWi-Fi proxy via .mobileconfig profileSystem-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.

Open a trace archive:

Terminal window
npx tapsmith show-trace tapsmith-results/traces/trace-my_test.zip

The 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.

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()
})

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.

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.


  • API Reference — full method signatures for device.route(), Route, TapsmithRequest, and related types
  • Trace Viewer — how to view and navigate trace archives
  • ConfigurationTraceConfig options including network, networkHosts, networkIgnoreHosts
  • iOS Network Capture — iOS-specific setup, troubleshooting, and the Network Extension architecture