Response mocking
A mock rule replaces the response to a matching API call with one you define. You use it to reach the states that are hard to produce on demand — a 500, an empty list, a timeout, an endpoint the backend has not built yet — without changing your app's code or asking anyone to break a server.
What it changes, and what it does not
A mock is a real okhttp3.Response handed back from the interceptor. Your
app cannot tell the difference, which is the whole point: isSuccessful,
the response body, Retrofit's converters and a chained flow that only calls B after A
succeeds all behave exactly as they would against the live server.
Your outgoing request is never modified. The URL, query, headers and body your app sends are the ones it actually sends. Everything in a rule's match section is a filter used to decide whether the rule applies — not data that gets transmitted. This trips people up, so it is worth saying plainly: mocking changes what comes back, nothing else.
Where rules come from
Two places, and they are meant for different situations.
| Source | Lives in | Why you would use it |
|---|---|---|
| Bundled | assets/appinspect/mocks.json in your app |
Version-controlled, reviewable in a pull request, shared with the whole team, and already active on a fresh install with nobody opening the inspector. |
| Runtime | Created in the Mocks panel, stored on the device | Instant. No rebuild, no commit. What you reach for while you are actually debugging. |
When both a bundled and a runtime rule match the same request, the runtime one wins. Rules you make on a device can be pulled back into the project later — see getting device rules into git.
The two modes
Every rule is either a short circuit or an override, and the difference is whether the real request happens at all.
- SHORT_CIRCUITdefault
- The network is never touched. AppInspect returns your response immediately. This is what you want for an endpoint that does not exist, for offline behaviour, or when you simply do not want the call to reach production.
- OVERRIDE
-
The real call completes normally, and then its status, headers and body are
replaced with yours. The original status is preserved in an
X-AppInspect-Mock-Original-Statusresponse header, so you can still see what the server said. Use it when the request itself has a side effect you want to keep — a write that must really happen — while testing how your UI handles a different answer.
What a rule matches on
Four conditions, and every one you leave blank matches anything. A rule therefore only needs the fields you actually care about.
- methoddefault: ANY
- Exact HTTP method, or
ANY. - urlrequired, unless you use a query or body condition
-
A case-insensitive substring of the full URL, with
*as a wildcard. Because it is a substring,/v1/usersmatches on every host — so a rule keeps working after someone switches environments, which is usually what you want. - query
- Name and value pairs that must be present in the query string. Other parameters are ignored.
- bodyContains
- A substring the request body must contain. If the body was not captured, this condition fails closed — the rule does not fire rather than firing blindly.
What the rule sends back
A status code, a content type, any response headers you want, and a body. On top of that, two things that are hard to arrange any other way:
- A delay.
delayMillisholds the response back, which is how you catch a spinner that never stops or a race between two calls. - A failure.
IO_EXCEPTIONandSOCKET_TIMEOUTmake the call fail the way a real network failure does, so you can test timeout and offline handling on a perfectly good connection. For a failure rule there is no status code to set — the field is hidden.
Committing rules with your code
A bundled file is read once, on AppInspect's background install thread, so it never blocks startup. A missing file is the normal case. A malformed one produces errors surfaced in the Mocks panel rather than an exception.
{
"version": 1,
"mockingEnabled": true,
"rules": [
{
"id": "orders-empty",
"name": "Orders list — empty state",
"enabled": true,
"priority": 10,
"mode": "SHORT_CIRCUIT",
"delayMillis": 250,
"match": { "method": "GET", "url": "/v2/orders", "query": { "page": "1" } },
"response": {
"status": 200,
"contentType": "application/json",
"headers": { "cache-control": "no-store" },
"body": { "items": [], "total": 0 }
}
}
]
}
Assets are not stripped per variant the way code is. A mocks.json
under src/main/ is packaged into your release APK
too. Nothing there can be served in production, but the file itself — every
URL and canned response body in it — ships to users and is readable by
anyone who unzips the APK. A debug-only source set keeps it out entirely.
Also: rules are developer-authored configuration and are stored verbatim. Redaction applies to captured traffic, not to rules, so never put a real credential in a mock response body.
The JSON keys are shorter than the Kotlin names
Unknown keys are ignored, so a typo silently does nothing. This is the full mapping:
| JSON key | Meaning | If omitted |
|---|---|---|
id | Rule id | Required. A rule without one is skipped and reported. |
name | Label shown in the panel | Empty |
enabled | Whether the rule can fire | true |
priority | Higher wins among rules of the same source | 0 |
mode | SHORT_CIRCUIT or OVERRIDE | SHORT_CIRCUIT |
failure | NONE, IO_EXCEPTION, SOCKET_TIMEOUT | NONE |
delayMillis | Artificial delay | 0, clamped to the maximum delay |
match.method | HTTP method | ANY |
match.url | URL substring, * wildcards | Matches every URL |
match.query | JSON object of name to value | Matches any query string |
match.bodyContains | Request-body substring | Matches any body |
response.status | Status code, 100–599 | 200 |
response.contentType | Response content type | application/json |
response.headers | JSON object of name to value | None |
response.body | A JSON string, or an inline object or array | Empty |
match.query and response.headers are objects, not arrays,
and keep document order. response.body accepts either a string or an
inline object; the inline form is stored as compact JSON, which is usually the more
readable way to write it in a file a human will review.
Working with rules in the panel
The rule list is split into runtime rules and bundled rules. Every action, and what it actually does:
| Action | Where | Effect |
|---|---|---|
| Add rule | Runtime section header | A new rule with a generated rt-… id. |
| Edit | Runtime rule menu, or tap the row | Updates that rule in place. |
| Override on this device | Bundled rule menu, or tap the row | Opens the shipped rule with every field editable and saves a runtime rule under the same id — because an app cannot write into its own APK assets. |
| Edit device override | Bundled rule menu, once overridden | The same action again. The row gains an amber OVERRIDDEN badge. |
| Duplicate as new rule | Any rule menu | A genuinely separate rule with a fresh id and priority + 1. Use it when you want the shipped rule and a variant. |
| Restore from mocks.json | Overridden bundled rule menu | Drops the override; the file's version serves again. |
| Delete | Runtime rule menu | Removes it. Not offered on bundled rows — use Restore. |
| Row switch | Any rule | Enables or disables. On a bundled row this writes an override too, which is how a shipped rule gets silenced without editing the file. |
| Clear runtime rules | Panel overflow | Removes every device-made rule. Bundled rules are untouched. |
An override replaces the bundled rule rather than outranking it. So turning an override off silences the rule entirely instead of letting the shipped version resurface, and an export contains one entry per rule with your edits applied — never a stale copy of the original. The override is shown inside its bundled row rather than as a separate entry, which is why the Bundled rules count always equals the number of rules in your file.
In the rule editor
The editor is split into Match (filters on the outgoing request) and Response (what your app receives instead). Nothing in the Match section is ever sent.
Only two fields are marked required: the URL pattern — unless you supply a query or body condition instead — and the status code, which disappears for failure rules. Everything else blank means "matches anything".
When several rules match
Rules are evaluated in this order, and the first match wins. They are never merged.
- Runtime rules, by
prioritydescending, then most recently saved. - Bundled rules, by
prioritydescending, then file order.
Two rules with the same matcher and the same priority therefore resolve to whichever
was saved last, and the loser can never fire. Rather than leave that invisible, the
panel flags it with a NEVER FIRES badge. The usual cause is running
Mock this response twice on the same endpoint.
Turning mocking on and off
Mocking rewrites live traffic, so it is gated more heavily than the rest of the inspector. Five checks must all pass before a rule can fire — if a rule looks right but nothing happens, work down this list.
| Gate | Set in | Default |
|---|---|---|
Build tier / FLAG_DEBUGGABLE | AppInspectEnablement | Debug builds only |
allowResponseMocking | Your install() configuration | true |
| The device switch, in the Mocks panel | Persisted per install | Unset |
"mockingEnabled" | Your mocks.json, in git | true when the file exists |
The rule's own enabled | File, or the row switch | true |
The device switch is a tri-state override. Unset means "follow
mocks.json", which is what lets a shipped file work on a fresh install
with nobody opening the inspector. Flip the switch and the device wins;
Reset to mocks.json clears the override and hands control back.
With no file and no override, mocking is off.
So "mockingEnabled": true in a committed file does mean every
teammate's debug build starts mocking those endpoints on first launch. That is the
intent — it makes a test scenario reproducible and reviewable — and anyone
can take control on their own device without touching the file.
Getting device rules back into your project
Rules built on a device are worth keeping. AppInspect mirrors the current rule set to
app-scoped external storage on every change, so you can pull the file without
run-as:
# Downloads to ./mocks.json in your current directory
adb pull /sdcard/Android/data/<applicationId>/files/appinspect/mocks.json ./mocks.json
# Then move it into your module — adjust the module name
mkdir -p app/src/debug/assets/appinspect
mv mocks.json app/src/debug/assets/appinspect/mocks.json
Copy adb pull command in the panel overflow gives you that first
line with your package already filled in. It deliberately targets the current
directory rather than guessing a path: adb pull does not create
intermediate directories, and the library cannot know your module's name. The panel
also has Export rules (.json) through the share sheet and
Copy all as JSON, and the Mirror file (adb pull)
disclosure in the master card shows the exact on-device path.
The exported file contains one entry per rule: every runtime rule, plus every bundled rule that no runtime rule overrides. Edit a shipped rule on the device and the pulled file has your edited version, with no leftover copy of the original.
It lives on app-scoped external storage, is removed when the app is uninstalled, and is never written on a build where mocking is not allowed — but it is not app-private the way internal storage is. The security model covers what that means.
Three things that surprise people
Because a mock is a real response, almost everything behaves normally. The exceptions are worth knowing before you spend an hour on one.
- Your mock body still has to parse. If the response model expects fields your mock leaves out, the converter throws. The chain breaks at deserialization, not at the network layer — and that is by far the most common surprise.
SHORT_CIRCUITskips the rest of the chain. Interceptors registered after AppInspect's, and OkHttp's own bridge, cache, connect and call-server interceptors, do not run. Installing AppInspect last avoids this.OVERRIDEdoes not have the problem, because the real call completes first.- No cache write and no cookie storage on a short circuit, for the same reason.
One more, less obvious: application interceptors see a call once, before
redirects. A SHORT_CIRCUIT rule written against the final URL of a
redirect chain will not fire.
A mocked response is never silent
The worst failure mode for a tool like this is someone debugging a response that AppInspect invented. So every mocked call is labelled, in every place it appears:
- A
MOCKbadge on the row and in the call detail in Network. - A
MOCK ·prefix and an amber tint on the notification. - An amber badge on the Mocks tab for as long as mocking is on.
- A
Mock:line, naming the rule that fired, in.txtexports.
If a response is confusing someone on your team, those badges are the first thing to check.