Component Testing¶
Mockly is designed for component testing — testing how your application behaves when a dependency returns errors, timeouts, unexpected data, or edge-case responses. The config file is owned by the dependency team; consuming teams just load it and toggle scenarios.
Typical workflow¶
dependency-service/
└── mockly/
├── mockly.yaml # base happy-path mocks
└── scenarios/ # optional split-out scenario files
├── auth-down.yaml
└── payment-timeout.yaml
Your test:
// start mockly with the dependency's config
// activate a scenario to simulate a failure
// make requests to your app and assert it handles the error correctly
// call the verification API to confirm your app called the right endpoints
Generating mocks from an OpenAPI, AsyncAPI, or Protobuf spec¶
If the dependency already publishes an OpenAPI 3.x document, an AsyncAPI
2.x/3.x document, or a Protobuf (.proto) service definition, skip
hand-writing the happy-path mocks and generate them instead. mockly
generate auto-detects which kind of spec it's looking at (from the file's
top-level openapi:/asyncapi: field, or a .proto extension / syntax =
"proto2|3"; declaration), so the same command works for all three:
OpenAPI → for every operation (path + method) in the spec, this picks a
representative response (preferring 200/201/202/204, then any other
2xx) and builds its body from:
- the response's
exampleorexamples, if the spec defines one, or - a value synthesised from the response's JSON schema — objects and arrays
are built recursively,
enums use their first value, and strings use a format-aware placeholder (date-time,date,email,uuid,uri,byteall get a sensible fake value instead of the literal"string").
Path templates carry over as-is: an operation on /pets/{petId} becomes a
mock with path: /pets/{petId}, which Mockly already matches and captures
into {{.request.params.petId}} for response templates.
Operations Mockly couldn't derive a usable response for (no response defined in the spec, or a binary content-type with no example) are skipped with a warning printed to stderr — generation still succeeds for the rest.
AsyncAPI → channels/operations become Kafka, MQTT, AMQP, NATS and
WebSocket mocks, depending on which protocol(s) the spec's servers use.
Not every AsyncAPI direction has a Mockly equivalent:
- Kafka: an operation where the application emits messages
(2.x
publish/ 3.xaction: send) becomes a pre-seeded topic (data a consumer can read immediately). The opposite direction (the app consuming messages) has no static Mockly equivalent and is skipped. - MQTT/AMQP/NATS: an operation where the application receives
messages (2.x
subscribe/ 3.xaction: receive) becomes a reactive listener on that topic/routing-key/subject, optionally replying if the operation declares areplymessage (3.x only). The opposite direction (the app spontaneously publishing) has no scheduled/spontaneous publish mechanism in Mockly yet and is skipped — use the management API to publish a message on demand instead. - WebSocket: a channel's
addressbecomes the mock'spath; areceiveoperation becomes anon_messagerule (replying if areplyis declared), asendoperation becomes anon_connectpush.
Skipped operations print a warning to stderr but don't fail generation, as long as at least one mock could be produced.
Protobuf → for every RPC method in the spec's service(s), this builds a
gRPC mock whose response body is synthesised from the method's output
message type: scalar fields get a type-appropriate placeholder (64-bit
integer kinds become JSON strings, per the standard proto3 JSON mapping),
repeated/map fields get a single representative entry, nested messages
recurse, and the handful of google.protobuf.* well-known types
(Timestamp, Duration, Empty, the wrapper types, ...) get their
JSON-mapped placeholder directly rather than being expanded field-by-field.
Only one member of each real oneof is included (proto3 optional fields
use a synthetic oneof under the hood and are unaffected).
imports are resolved only from the spec file's own directory (no absolute
paths, no ../ escaping it) plus the standard google/protobuf/*.proto
well-known types — anything else is a hard error, not a skip, since an
unresolvable type can't be faithfully represented at all. Streaming methods
(client-streaming, server-streaming, bidirectional) are generated as
minimal single-message stream/client_stream stubs (see
gRPC) rather than skipped, with a warning noting it's a starting
point meant to be hand-edited into a fuller sequence. Any RPC method whose
name collides with one already seen in a different service in the same
file is skipped with a warning instead (Mockly matches gRPC calls by method
name alone, not service, so a later duplicate would just shadow the first
and never be reachable).
The generated file is a complete, runnable config (management API/UI ports included), not a fragment — review it, add scenarios/faults/state as needed, and commit it like any other Mockly config.
Alternative: reference a spec directly from mockly.yaml¶
mockly generate is a one-time snapshot: it turns a spec into a static file
you then own — regenerating overwrites it, so any fault/scenario/mock you
hand-added is lost unless you re-apply it yourself. If you'd rather the spec
stay a live input — e.g. it's still evolving, or you want hand-written
faults/scenarios/extra mocks to automatically keep working against a spec
you don't want to manually resync — reference it inline instead, using the
same openapi/asyncapi/proto key next to mocks: in the relevant
protocol block. Every time the config is loaded (mockly start,
mockly apply, mockly config validate), Mockly regenerates mocks from the
spec and layers mocks: on top: an entry whose id matches a generated
mock overrides it (e.g. to attach a fault, pin a different response, or
add state); any other id is simply appended alongside it. Relative spec
paths are resolved against the config file's own directory, not the current
working directory.
A generated mock's id is derived automatically (from the OpenAPI
operation's operationId, or the AsyncAPI operation's key, or, for
Protobuf, <service>-<method> — always lowercased/slugified, e.g.
operationId: getUser → id: getuser, rpc GetUser on service Users →
id: users-getuser). Run mockly generate <spec> -o /tmp/preview.yaml once
to see the exact IDs a given spec produces before writing overrides for it —
guessing the id wrong just appends a harmless extra mock instead of
overriding anything, so it fails quietly rather than loudly.
protocols:
http:
enabled: true
port: 8080
openapi: ./api.yaml # generates one HTTP mock per operation
mocks:
- id: getuser # matches operationId "getUser", slugified -> overrides it
request: { method: GET, path: "/users/{id}" }
response: { status: 200, body: '{"id":"{{.request.params.id}}","name":"Alice"}' }
fault: { delay: 200ms, error_rate: 0.1 }
- id: admin-only-route # no spec counterpart -> just an extra mock
request: { method: GET, path: "/admin/ping" }
response: { status: 200, body: "pong" }
kafka:
enabled: true
asyncapi: ./events.yaml # same spec can be referenced by more than one protocol block
mocks: []
grpc:
enabled: true
port: 50051
services:
- proto: ./protos/users.proto
mocks:
- id: users-getuser # "<service>-<method>", slugified -> overrides it
method: GetUser
response: { id: "1", name: Alice }
Both approaches use the exact same generators under the hood and can be
mixed across protocols in the same file — use whichever fits each spec:
generate once for a spec you want to fully own and hand-edit from that
point on, an inline reference for one you want to keep regenerating from on
every start.
Record mode (bootstrap mocks from a real backend)¶
Hand-writing every mock is the biggest upfront cost of adopting a mock server. Record mode removes it: point Mockly at a real upstream, and any request that doesn't match an existing mock is transparently proxied there — the real response is both returned to the caller and saved as a new mock, so every subsequent identical request is replayed locally without hitting the upstream again.
mockly:
api:
port: 9091
protocols:
http:
enabled: true
port: 8080
record:
enabled: true
target: https://api.example.com # real upstream to proxy unmatched requests to
save_to: recorded-mocks.yaml # optional: persist captured mocks for review
mocks: [] # hand-written mocks still take precedence
mockly start -c mockly.yaml
# Run your test suite (or click around manually) against the real backend,
# through Mockly, once — every response it returns gets captured.
curl http://localhost:8080/v1/users/42
# Recorded mocks are immediately visible like any other mock...
curl http://localhost:9091/api/mocks/http
# ...and, if save_to was set, written to disk so you can review and fold
# them into your hand-written config:
cat recorded-mocks.yaml
A few things worth knowing:
- Hand-written mocks always take precedence — record mode only fires for requests that don't match anything already configured, so it's safe to mix recorded and hand-written mocks in the same file.
- If the upstream is unreachable, Mockly falls back to the normal 404 "no mock matched" response rather than failing the request outright.
- save_to is rewritten (not appended) after every new capture, and only ever contains recorded mocks — it won't clobber your hand-written config file.
Call verification¶
Check how many times your app hit a mock — without log scraping:
# How many times was POST /token called?
curl http://localhost:9091/api/calls/http/token-endpoint
# Block until the mock has been called at least 3 times (timeout 5s)
curl -X POST http://localhost:9091/api/calls/http/token-endpoint/wait \
-H 'Content-Type: application/json' \
-d '{"count":3,"timeout":"5s"}'
# Reset call counters for one mock
curl -X DELETE http://localhost:9091/api/calls/http/token-endpoint
# Reset all call counters
curl -X DELETE http://localhost:9091/api/calls/http
Response from GET /api/calls/http/{mockId}:
{
"mock_id": "token-endpoint",
"count": 3,
"calls": [
{"id": "...", "mock_id": "token-endpoint", "method": "POST", "path": "/token", "timestamp": "..."}
]
}
Full component-test example¶
protocols:
http:
enabled: true
port: 8080
mocks:
# Happy path
- id: get-token
request:
method: POST
path: /oauth/token
body_json:
grant_type: client_credentials
response:
status: 200
body: '{"access_token":"{{uuid}}","expires_in":3600}'
# Sequence: simulate token refresh after expiry
- id: get-token-expiry-flow
request:
method: POST
path: /oauth/token
body_json:
grant_type: refresh_token
sequence:
- status: 401
body: '{"error":"token_expired"}'
- status: 200
body: '{"access_token":"new-token","expires_in":3600}'
sequence_exhausted: hold_last
response:
status: 200
body: '{"access_token":"new-token","expires_in":3600}'
scenarios:
- id: auth-service-down
name: Auth Service Down
patches:
- mock_id: get-token
status: 503
body: '{"error":"service unavailable"}'
- id: rate-limited
name: Rate Limited
patches:
- mock_id: get-token
status: 429
body: '{"error":"too_many_requests"}'