Force a 500 Without Touching Your Backend: Network Mocking in React Native (ExecBro)
get_network_requestsget_screen_statenetwork_conditionnetwork_mocknetwork_replayscan_metrotap
Every React Native app has error handling nobody has ever seen run.
Not because it is untested in principle — because reaching it is genuinely hard. You cannot ask your production API for a 500. The null avatarUrl that crashes the profile screen belongs to one customer in Belgium. The retry logic looks right, and that is the entire body of evidence for it.
So the usual move is to fake the outcome: dispatch the failure action directly, or flip a boolean in the store. That reaches the error screen, which is why it feels like it works. But it skips the request builder, the response parser, the catch block, the retry and the toast — every line between "request failed" and "user sees something", which is exactly where the bug lives.
ExecBro's mocking tools change the response instead. The app makes its real request, gets the answer you specified, and runs all of its own code from there.
| Tool | What it does |
|---|---|
network_mock |
Replace a response, or modify the real one |
network_condition |
Simulate offline / slow / normal |
network_replay |
Re-issue a request the app already made |
Everything below is real output from an iOS simulator and an Android emulator.
The loop
Connect once, then it is four steps:
mcp__execbro__scan_metro {}Metro scan results:
Port 8081: Found 1 device(s)
- Connected to com.gifted.production (iPhone Air) (iPhone Air)
add → reproduce → look → clear. That is the whole workflow.
1. Does this screen handle a 500?
The most common question, and usually the fastest bug to find.
mcp__execbro__network_mock {"action": "add", "url": "/api/orders", "status": 500, "body": "{\"error\":\"server error\"}"}Added [m1] ANY /api/orders -> replace on iPhone Air. Survives reload_app. Clear with network_mock({action:"clear"}).
Now reproduce — tap into the screen and read what the app did:
mcp__execbro__tap {"text": "Orders"}
mcp__execbro__get_screen_state {}url is a plain substring, so /api/orders matches https://api.example.com/v2/api/orders/17. The app's request never reaches the network; it gets your 500 and runs its own error path.
If get_screen_state shows a spinner that never resolves, or an empty list with no message, that is the finding. The screen has no error state. You proved it in about fifteen seconds, without a backend change or a feature flag.
2. What if this field is missing?
replace hands back a canned body, which is right for "the server is down" and wrong for "one field is null". For that you want the real response with one thing changed. That is tamper:
mcp__execbro__network_mock {"action": "add", "url": "itunes.apple.com/lookup", "mode": "tamper", "remove": ["resultCount"], "set": {"injected.flag": true}}Behind the scenes ExecBro issues the real request on a second, hidden XHR, mutates the JSON, and hands the result to your app. Here is the app reading it back:
{"status":200,"parsed":true,"hasResultCount":false,"injected":{"flag":true},"realResultsStillThere":1}The genuine results array survived, resultCount is gone, the nested injected.flag was created, and the app parsed it without complaint. Both set and remove take dotted paths, so set: {"data.subscription.status": "expired"} shows you the expired-subscription UI without an expired account.
If the response is not JSON, tamper passes it through untouched and tells you — you get a warning, not a mangled body and a parse error to debug.
3. Does my retry actually work?
times fires a rule a limited number of times and then lets traffic through. With times: 1, the first attempt fails and the second succeeds — the exact situation retry logic exists for.
mcp__execbro__network_mock {"action": "add", "url": "/api/sync", "status": 503, "times": 1}Two consecutive requests, from a real device:
attempt1=503
attempt2=200
And the rule reports what it did:
mcp__execbro__network_mock {"action": "list"}[m4] ANY /api/sync -> replace 503 (times:1, spent) hits=1
If your app recovered on its own, the retry works. If it showed an error and stopped, it does not retry at all — and you now know that instead of assuming.
4. What happens with no network?
mcp__execbro__network_condition {"mode": "offline"}Offline: every JS-originated request now fails with 'Network request failed'. (iPhone Air)
NetInfo: not-installed — the app does not use @react-native-community/netinfo. Request failure is unaffected.
Survives reload_app. Undo with network_condition({mode:"normal"}).
The app's next request fails the way a real connection drop fails:
rejected: Network request failed
That second line matters. Many apps gate their offline UI on useNetInfo() rather than on a failed request, so offline also tries to patch NetInfo — and then checks whether the patch actually took, reporting one of patched, reads-patched-only or not-installed. It tells you what it achieved rather than assuming. Request failure works in every case.
Put it back when you are done:
mcp__execbro__network_condition {"mode": "normal"}5. Is there a loading state?
Local APIs answer in 200ms, which is why nobody notices a missing skeleton until a user on a train does.
mcp__execbro__network_condition {"mode": "slow", "latencyMs": 3000}The same request, before and after:
status=200 elapsedMs=255 ← normal
status=200 elapsedMs=3048 ← slow
The response is otherwise untouched, so this is purely a timing test. Three seconds is long enough to catch a missing skeleton, a submit button you can tap twice while the first request is in flight, or a spinner that never appears at all.
6. Why did this POST 422?
Your app posted a form and the API rejected it. Rather than navigating back through the form to try again with one field changed, replay the captured request:
mcp__execbro__get_network_requests {"status": 422}
mcp__execbro__network_replay {"requestId": "js-a1b2-7"}
mcp__execbro__network_replay {"requestId": "js-a1b2-7", "body": "{\"quantity\": 1}"}Vary one field at a time and you find what the backend actually objects to in under a minute. The replay goes through the app's own network stack, so it carries the same auth token, TLS trust and proxy configuration as the original — it is the same request, not a reconstruction.
Mocked traffic is never invisible
An agent debugging a failure it caused itself is the one way a feature like this becomes worse than useless. So altered traffic announces itself. Mocked rows are tagged:
[js-wm68-6] 11:36:16 PM GET 503 7ms https://api.example.com/orders [MOCK m1]
And every network read carries a banner while any rule is live:
[1 mock rule(s) active (1 on iPhone Air) — responses below may be altered.
network_mock({action:"list"}) to inspect, {action:"clear"} to remove]
Clean up when you are done
Rules are per-device and survive reload_app. That is deliberate: a mock that vanished on reload would be useless for debugging anything on the startup path, which is where the hardest network bugs live.
The cost is that a forgotten rule quietly affects your next session, and the symptom looks exactly like a real bug. So finish with:
mcp__execbro__network_mock {"action": "clear"}
mcp__execbro__network_condition {"mode": "normal"}If something seems broken later, the banner is the first thing to check.
When a rule does not fire
Check list first. Matching is first-rule-wins, so a broad rule added earlier shadows a narrow one added later. A rule sitting at hits=0 when you expected it to fire is almost always shadowed by the one above it.
Check the pattern. url is a substring by default. For something precise, wrap it in slashes and it becomes a regex: "/\\/orders\\/\\d+$/". Patterns are validated before they are stored — a catastrophically backtracking one is rejected rather than sent to freeze your app's JS thread.
Check the layer. Mocking covers JS-originated HTTP: fetch, axios, anything riding XMLHttpRequest. Traffic from native modules — native analytics SDKs, <Image> loading — goes around it entirely.
The point
None of this is about avoiding a staging environment. It is about the gap between "the error screen renders" and "the error path works", which is where the bugs actually are: the parser that assumes a field, the retry that never fires, the button you can double-tap during a slow request.
Those are all one tool call away now, and they run your real code.
These tools ship in ExecBro 2.7.0. Every parameter is listed in the tool reference, and the full written guide — including tamper paths, regex matching and the SDK interaction — lives in docs/network-mocking.md.