
Introduction π―
Every backend that talks to external APIs faces the same problem: how do you develop and test against services that arenβt yours?
You could hit the real sandbox β and deal with rate limits, flaky networks, monthly quotas, and credentials that expire at 2 AM. Or you could mock everything with Vitest vi.mock() and discover in production that the real API returns snake_case while your mock returns camelCase.
WireMock solves this differently. Itβs an HTTP server that simulates real APIs β start it, tell it what to respond, point your code at it, and test with real HTTP calls. No cloud, no account, no API key, no network dependency. It runs anywhere your code runs: your laptop, a Docker container, or a CI pipeline.
// Your Express.js app talks to http://localhost:8090 β that's WireMock
const response = await axios.post('http://localhost:8090/tokenized/checkout/token/grant', {
app_key: 'test_app_key',
app_secret: 'test_app_secret',
}, {
headers: { username: 'test_user', password: 'test_pass' },
})
// Response comes from a stub, not a real server
console.log(response.data) // { id_token: "eyJhbGciOi...", expires_in: 3600 }
Weβll build real simulations for two common Bangladesh-based API providers β bKash (payment gateway, per developer.bka.sh) and SMS.NET.BD (SMS gateway, per sms.bd/api) β and wire them into an Express.js + TypeScript project that works identically in dev, test, and CI. No cloud, no cost, no compromises. π
Part 1: Why WireMock?
1.1 The Problem with Real Sandbox APIs
Every external sandbox has a dark side:
| Problem | Real Sandbox | WireMock |
|---|---|---|
| Network required | β Yes | β No |
| Rate limits | β Yes (often low) | β None |
| Credential expiry | β Yes | β None |
| Monthly quotas | β Yes (SMS providers are brutal) | β None |
| Race conditions | β Yes, with other devs sharing the sandbox | β Isolated |
| State reset | β Manual or not at all | β Programmatic |
| CI/CD integration | β οΈ Complex (VPN, secrets) | β Single Docker container |
1.2 WireMock vs Other Approaches
| Feature | WireMock | MSW (Mock Service Worker) | vi.mock() + nock | Postman Mock Server |
|---|---|---|---|---|
| Level | HTTP server | Service worker in Node/browser | Node library | Cloud service |
| Real HTTP calls | β Yes | β οΈ Intercepted | β οΈ Intercepted | β Yes |
| Stateful scenarios | β Built-in | β Manual | β Manual | β Limited |
| Request recording | β Built-in | β | β | β |
| Response templating | β Handlebars | β | β | β |
| No cloud required | β Always | β Always | β Always | β Requires account |
| CI/CD setup | β Docker image | β npm install | β npm install | β Cloud dependency |
| Protocol support | HTTP/HTTPS | HTTP/HTTPS only | HTTP/HTTPS only | HTTP/HTTPS only |
WireMockβs sweet spot: you need an entire fake API server β one that maintains state, responds differently to different sequences of calls, and runs in CI exactly as it runs locally.
Part 2: Installation & Setup
2.1 Standalone JAR (simplest)
# Download the standalone JAR
curl -LO https://repo1.maven.org/maven2/org/wiremock/wiremock-standalone/3.9.1/wiremock-standalone-3.9.1.jar
# Start it
java -jar wiremock-standalone-3.9.1.jar --port 8090
2.2 Docker (recommended for CI/CD)
docker run -d \
--name wiremock \
-p 8090:8080 \
-v ./wiremock:/home/wiremock \
wiremock/wiremock:3.9.1
The ./wiremock directory holds your stub files β mapping definitions, response bodies, and initialisation scripts. Commit this to your repo.
2.3 Docker Compose (for dev environments)
# docker-compose.yml
services:
wiremock:
image: wiremock/wiremock:3.9.1
ports:
- "8090:8080"
volumes:
- ./wiremock:/home/wiremock
command: ["--verbose", "--global-response-templating"]
2.4 npm Package (for Node.js tests)
npm install -D wiremock
// tests/setup-wiremock.ts
import { WireMock } from 'wiremock'
export const wiremock = new WireMock('http://localhost:8090')
beforeAll(async () => {
await wiremock.reset()
})
afterAll(async () => {
await wiremock.resetAll()
})
2.5 Verify It Works
# Set up a stub
curl -X POST http://localhost:8090/__admin/mappings \
-H 'Content-Type: application/json' \
-d '{
"request": { "method": "GET", "url": "/health" },
"response": { "status": 200, "jsonBody": { "status": "ok" } }
}'
# Hit the mock
curl http://localhost:8090/health
# β {"status": "ok"}
Part 3: Core Concepts β Stubs, Mappings & Requests
3.1 Static Mapping Files
WireMock reads mapping files from mappings/ under the wiremock directory. Each file is a JSON mapping definition:
// wiremock/mappings/health.json
{
"priority": 1,
"request": {
"method": "GET",
"url": "/health"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"service": "mock-server",
"status": "healthy",
"timestamp": "{{now}}"
}
}
}
3.2 Request Matching
WireMock can match requests by any combination of criteria:
{
"request": {
"method": "POST",
"urlPath": "/tokenized/checkout/token/grant",
"headers": {
"username": {
"matches": ".+"
},
"password": {
"matches": ".+"
}
},
"bodyPatterns": [
{
"matchesJsonPath": "$.app_key"
},
{
"matchesJsonPath": "$.app_secret"
}
]
}
}
Matcher types available:
| Matcher | Example | Description |
|---|---|---|
equalTo | "equalTo": "GET" | Exact string match |
contains | "contains": "Bearer" | Substring match |
matches | "matches": "^[A-Z]{3}" | Regex match |
doesNotMatch | "doesNotMatch": "admin" | Negative regex |
matchesJsonPath | "matchesJsonPath": "$.amount" | JSON path existence |
matchesXPath | "matchesXPath": "//user" | XML path existence |
absent | "absent": true | Assert field is missing |
3.3 Response Templates (Handlebars)
WireMock uses Handlebars templates to generate dynamic responses:
{
"request": {
"method": "POST",
"urlPath": "/tokenized/checkout/token/grant"
},
"response": {
"status": 200,
"jsonBody": {
"id_token": "{{randomValue length=128 type='ALPHANUMERIC'}}",
"expires_in": 3600,
"token_type": "Bearer",
"refresh_token": "{{randomValue length=64 type='ALPHANUMERIC'}}"
},
"headers": {
"Content-Type": "application/json"
}
}
}
Available template helpers:
| Helper | Example | Output |
|---|---|---|
{{now}} | {{now format='yyyy-MM-dd'}} | Current date/time |
{{randomValue}} | {{randomValue length=12 type='ALPHANUMERIC'}} | Random string |
{{jsonPath}} | {{jsonPath request.body '$.email'}} | Extract from request body |
{{request.pathSegments.[1]}} | {{request.pathSegments.[0]}} | URL path segments |
{{request.query.paramName}} | {{request.query.status}} | Query parameter |
{{request.headers.HEADER_NAME}} | {{request.headers.Authorization}} | Header value |
{{pickRandom}} | {{pickRandom (list 'a' 'b' 'c')}} | Random array item |
3.4 Admin API
WireMock exposes a powerful admin API at /__admin/:
# Create a stub
POST /__admin/mappings
# Get all stubs
GET /__admin/mappings
# Delete a stub
DELETE /__admin/mappings/{id}
# Reset everything (all stubs + state)
POST /__admin/reset
# Get recorded requests
GET /__admin/requests
# Get recorded requests matching criteria
POST /__admin/requests/count
Part 4: Stateful Simulations with Scenarios
This is what separates WireMock from simple stubbing. Scenarios let you simulate stateful workflows β a payment that goes through multiple statuses.
4.1 Scenario Basics
{
"scenarioName": "Payment Lifecycle",
"requiredScenarioState": "Started",
"newScenarioState": "Initiated",
"request": {
"method": "POST",
"urlPath": "/tokenized/checkout/create"
},
"response": {
"status": 201,
"jsonBody": {
"paymentID": "TR0001TEST",
"transactionStatus": "Initiated"
}
}
}
Each scenario starts in "Started". After a matched request, WireMock transitions to "newScenarioState". Then the next matching mapping with "requiredScenarioState" equal to the current state fires instead.
4.2 Resetting Scenarios in Tests
// Reset scenario state between tests
await wiremock.resetScenarios()
// Or reset everything
await wiremock.resetAll()
Part 5: Proxying & Recording
5.1 Proxy All Unmatched Requests
You donβt have to stub everything. WireMock can proxy unmatched requests to a real server:
// wiremock/mappings/proxy.json
{
"priority": 10,
"request": {
"method": "ANY",
"urlPattern": ".*"
},
"response": {
"proxyBaseUrl": "https://sandbox.bkash.com"
}
}
With this, only your defined stubs are mocked; everything else passes through to the real sandbox.
5.2 Recording Mode
Start a recording session to capture real API traffic, then save the mappings:
# Start recording β proxy all traffic to the real API
curl -X POST http://localhost:8090/__admin/recordings/start \
-H 'Content-Type: application/json' \
-d '{
"targetBaseUrl": "https://sandbox.bkash.com",
"format": "json"
}'
# Now hit WireMock as if it were bKash
curl -X POST http://localhost:8090/tokenized/checkout/token/grant \
-H 'Content-Type: application/json' \
-H 'username: test_user' \
-H 'password: test_pass' \
-d '{"app_key": "test_key", "app_secret": "test_secret"}'
# Stop recording β mappings are saved
curl -X POST http://localhost:8090/__admin/recordings/stop
This is invaluable when youβre migrating from real sandbox to mocked tests: record once, replay forever.
Part 6: Real-World Simulation β bKash Payment Gateway (Tokenized Checkout)
bKashβs Tokenized Checkout flow works in three phases:
- Grant Token β authenticate and get an access token
- Create Payment β initiate payment, get a
bkashURLfor the customer - Execute Payment β after the customer approves on the bKash app/web, finalise
All API paths, headers, and response shapes below mirror the actual bKash Developer API Specification (full spec requires registering on the dev portal β the mapping files here serve as a standalone offline simulation).
6.1 Project Structure
wiremock/
βββ mappings/
β βββ bkash/
β β βββ grant-token.json
β β βββ create-payment.json
β β βββ execute-payment.json
β β βββ query-payment.json
β β βββ refund-transaction.json
β β βββ errors/
β β βββ invalid-app-key.json
β β βββ insufficient-balance.json
β βββ sms-net-bd/
β β βββ send-sms.json
β β βββ delivery-report.json
β β βββ balance.json
β β βββ errors/
β β βββ insufficient-balance.json
β β βββ invalid-number.json
β βββ health.json
βββ __files/
βββ (response bodies too large for inline)
6.2 Grant Token
Before calling any payment API, the merchant obtains an access token:
// wiremock/mappings/bkash/grant-token.json
{
"priority": 1,
"request": {
"method": "POST",
"urlPath": "/tokenized/checkout/token/grant",
"headers": {
"Content-Type": { "equalTo": "application/json" },
"username": { "matches": ".+" },
"password": { "matches": ".+" }
},
"bodyPatterns": [
{ "matchesJsonPath": "$.app_key" },
{ "matchesJsonPath": "$.app_secret" }
]
},
"response": {
"status": 200,
"jsonBody": {
"id_token": "{{randomValue length=128 type='ALPHANUMERIC'}}",
"expires_in": 3600,
"token_type": "Bearer",
"refresh_token": "{{randomValue length=64 type='ALPHANUMERIC'}}"
},
"headers": {
"Content-Type": "application/json"
}
}
}
6.3 Create Payment
The create payment request initiates a new payment session and returns a bkashURL to which the merchant redirects the customer:
// wiremock/mappings/bkash/create-payment.json
{
"scenarioName": "bKash Payment",
"requiredScenarioState": "Started",
"newScenarioState": "Payment Created",
"request": {
"method": "POST",
"urlPath": "/tokenized/checkout/create",
"headers": {
"Authorization": { "matches": ".+" },
"X-App-Key": { "matches": ".+" }
},
"bodyPatterns": [
{ "matchesJsonPath": "$.mode" },
{ "matchesJsonPath": "$.payerReference" },
{ "matchesJsonPath": "$.callbackURL" },
{ "matchesJsonPath": "$.agreementID" },
{ "matchesJsonPath": "$.amount" },
{ "matchesJsonPath": "$.merchantInvoiceNumber" }
]
},
"response": {
"status": 200,
"jsonBody": {
"statusCode": "0000",
"statusMessage": "Successful",
"paymentID": "TR{{randomValue length=20 type='ALPHANUMERIC'}}",
"agreementID": "{{jsonPath request.body '$.agreementID'}}",
"payerReference": "{{jsonPath request.body '$.payerReference'}}",
"amount": "{{jsonPath request.body '$.amount'}}",
"currency": "BDT",
"intent": "sale",
"transactionStatus": "Initiated",
"merchantInvoiceNumber": "{{jsonPath request.body '$.merchantInvoiceNumber'}}",
"callbackURL": "{{jsonPath request.body '$.callbackURL'}}",
"bkashURL": "https://mock.bkash.com/pay?paymentID=TR{{randomValue length=20 type='ALPHANUMERIC'}}",
"successCallbackURL": "{{jsonPath request.body '$.callbackURL'}}?status=success&paymentID=TR{{randomValue length=20 type='ALPHANUMERIC'}}",
"failureCallbackURL": "{{jsonPath request.body '$.callbackURL'}}?status=failure",
"cancelledCallbackURL": "{{jsonPath request.body '$.callbackURL'}}?status=cancel",
"paymentCreateTime": "{{now format='yyyy-MM-dd\'T\'HH:mm:ss'}} GMT+0600"
},
"headers": {
"Content-Type": "application/json"
}
}
}
6.4 Execute Payment
After the customer enters their PIN on bKashβs page, the merchant finalises with the paymentID:
// wiremock/mappings/bkash/execute-payment.json
{
"scenarioName": "bKash Payment",
"requiredScenarioState": "Payment Created",
"newScenarioState": "Completed",
"request": {
"method": "POST",
"urlPath": "/tokenized/checkout/execute",
"headers": {
"Authorization": { "matches": ".+" },
"X-App-Key": { "matches": ".+" }
},
"bodyPatterns": [
{ "matchesJsonPath": "$.paymentID" }
]
},
"response": {
"status": 200,
"jsonBody": {
"statusCode": "0000",
"statusMessage": "Successful",
"paymentID": "{{jsonPath request.body '$.paymentID'}}",
"agreementID": "AGR{{randomValue length=20 type='ALPHANUMERIC'}}",
"payerReference": "01770618575",
"customerMsisdn": "01770618575",
"trxID": "{{randomValue length=10 type='ALPHANUMERIC'}}",
"amount": "{{jsonPath request.body '$.amount' default='12'}}",
"transactionStatus": "Completed",
"paymentExecuteTime": "{{now format='yyyy-MM-dd\'T\'HH:mm:ss'}} GMT+0600",
"currency": "BDT",
"intent": "sale",
"merchantInvoiceNumber": "Inv0124"
},
"headers": {
"Content-Type": "application/json"
}
}
}
6.5 Query Payment (Status Check)
bKash provides a query endpoint to check the status of a given payment:
// wiremock/mappings/bkash/query-payment.json
{
"scenarioName": "bKash Payment",
"requiredScenarioState": "Completed",
"request": {
"method": "GET",
"urlPathPattern": "/tokenized/checkout/status/(.+)",
"headers": {
"Authorization": { "matches": ".+" },
"X-App-Key": { "matches": ".+" }
}
},
"response": {
"status": 200,
"jsonBody": {
"statusCode": "0000",
"statusMessage": "Successful",
"paymentID": "{{request.pathSegments.[3]}}",
"transactionStatus": "Completed",
"trxID": "{{randomValue length=10 type='ALPHANUMERIC'}}",
"amount": "1500",
"currency": "BDT",
"intent": "sale",
"merchantInvoiceNumber": "Inv0124",
"customerMsisdn": "01770618575"
},
"headers": {
"Content-Type": "application/json"
}
}
}
6.6 Refund Transaction
bKashβs refund API accepts paymentId, trxId, refundAmount, and optional sku / reason:
// wiremock/mappings/bkash/refund-transaction.json
{
"scenarioName": "bKash Payment",
"requiredScenarioState": "Completed",
"newScenarioState": "Refunded",
"request": {
"method": "POST",
"urlPath": "/v2/tokenized-checkout/refund/payment/transaction",
"headers": {
"Authorization": { "matches": ".+" },
"X-App-Key": { "matches": ".+" }
},
"bodyPatterns": [
{ "matchesJsonPath": "$.paymentId" },
{ "matchesJsonPath": "$.trxId" },
{ "matchesJsonPath": "$.refundAmount" }
]
},
"response": {
"status": 200,
"jsonBody": {
"originalTrxId": "{{jsonPath request.body '$.trxId'}}",
"refundTrxId": "{{jsonPath request.body '$.trxId'}}RFD",
"refundTransactionStatus": "Completed",
"originalTrxAmount": "{{jsonPath request.body '$.refundAmount'}}",
"refundAmount": "{{jsonPath request.body '$.refundAmount'}}",
"currency": "BDT",
"completedTime": "{{now format='yyyy-MM-dd\'T\'HH:mm:ss'}} GMT+0600",
"sku": "{{jsonPath request.body '$.sku'}}",
"reason": "{{jsonPath request.body '$.reason'}}"
},
"headers": {
"Content-Type": "application/json"
}
}
}
6.7 Error Scenarios
bKash uses numeric error codes. Per their Error Codes reference:
// wiremock/mappings/bkash/errors/invalid-app-key.json
{
"priority": 1,
"request": {
"method": "POST",
"urlPath": "/tokenized/checkout/token/grant",
"bodyPatterns": [
{
"equalToJson": { "app_key": "invalid_key", "app_secret": "invalid_secret" }
}
]
},
"response": {
"status": 401,
"jsonBody": {
"errorCode": "2001",
"errorMessage": "Invalid App Key"
},
"headers": {
"Content-Type": "application/json"
}
}
}
// wiremock/mappings/bkash/errors/insufficient-balance.json
{
"priority": 1,
"request": {
"method": "POST",
"urlPath": "/tokenized/checkout/create",
"bodyPatterns": [
{
"matchesJsonPath": "$.amount"
},
{
"equalToJson": { "mode": "0001", "amount": "999999" }
}
]
},
"response": {
"status": 200,
"jsonBody": {
"errorCode": "2023",
"errorMessage": "Insufficient Balance"
},
"headers": {
"Content-Type": "application/json"
}
}
}
6.8 bKash Client in Express.js
This client mirrors the live bKash API contract β token management first, then create-payment + execute-payment via paymentID:
// src/services/bkash-client.ts
import axios from 'axios'
import type { AxiosInstance } from 'axios'
export interface BkashConfig {
baseUrl: string
appKey: string
appSecret: string
username: string
password: string
}
export interface GrantTokenResponse {
id_token: string
expires_in: number
token_type: string
refresh_token: string
statusCode?: string
statusMessage?: string
}
export interface CreatePaymentInput {
mode: string
payerReference: string
callbackURL: string
agreementID: string
amount: string
currency: string
intent: string
merchantInvoiceNumber: string
merchantAssociationInfo?: string
}
export interface CreatePaymentResponse {
statusCode: string
statusMessage: string
paymentID: string
agreementID: string
payerReference: string
amount: string
currency: string
transactionStatus: 'Initiated'
merchantInvoiceNumber: string
bkashURL: string
successCallbackURL: string
failureCallbackURL: string
cancelledCallbackURL: string
paymentCreateTime: string
}
export interface ExecutePaymentResponse {
statusCode: string
statusMessage: string
paymentID: string
agreementID: string
payerReference: string
customerMsisdn: string
trxID: string
amount: string
transactionStatus: 'Completed'
paymentExecuteTime: string
currency: string
intent: string
merchantInvoiceNumber: string
}
export interface RefundInput {
paymentId: string
trxId: string
refundAmount: string
sku?: string
reason?: string
}
export interface RefundResponse {
originalTrxId: string
refundTrxId: string
refundTransactionStatus: string
originalTrxAmount: string
refundAmount: string
currency: string
completedTime: string
sku: string
reason: string
}
export class BkashClient {
private client: AxiosInstance
private config: BkashConfig
private token: GrantTokenResponse | null = null
constructor(config: BkashConfig) {
this.config = config
this.client = axios.create({
baseURL: config.baseUrl,
timeout: 30000, // bKash recommends 30s timeout
})
}
async grantToken(): Promise<GrantTokenResponse> {
const { data } = await this.client.post<GrantTokenResponse>(
'/tokenized/checkout/token/grant',
{
app_key: this.config.appKey,
app_secret: this.config.appSecret,
},
{
headers: {
username: this.config.username,
password: this.config.password,
'Content-Type': 'application/json',
},
},
)
this.token = data
return data
}
async createPayment(input: CreatePaymentInput): Promise<CreatePaymentResponse> {
if (!this.token) await this.grantToken()
const { data } = await this.client.post<CreatePaymentResponse>(
'/tokenized/checkout/create',
input,
{
headers: {
Authorization: this.token!.id_token,
'X-App-Key': this.config.appKey,
},
},
)
return data
}
async executePayment(paymentID: string): Promise<ExecutePaymentResponse> {
if (!this.token) await this.grantToken()
const { data } = await this.client.post<ExecutePaymentResponse>(
'/tokenized/checkout/execute',
{ paymentID },
{
headers: {
Authorization: this.token!.id_token,
'X-App-Key': this.config.appKey,
},
},
)
return data
}
async queryPayment(paymentID: string): Promise<ExecutePaymentResponse> {
if (!this.token) await this.grantToken()
const { data } = await this.client.get<ExecutePaymentResponse>(
`/tokenized/checkout/status/${paymentID}`,
{
headers: {
Authorization: this.token!.id_token,
'X-App-Key': this.config.appKey,
},
},
)
return data
}
async refund(input: RefundInput): Promise<RefundResponse> {
if (!this.token) await this.grantToken()
const { data } = await this.client.post<RefundResponse>(
'/v2/tokenized-checkout/refund/payment/transaction',
input,
{
headers: {
Authorization: this.token!.id_token,
'X-App-Key': this.config.appKey,
},
},
)
return data
}
}
Part 7: Real-World Simulation β SMS.NET.BD (sms.bd)
SMS.NET.BD (Alpha SMS) is a popular Bangladeshi SMS gateway. The API is refreshingly simple β query/form-parameter based, with numeric error codes.
Key characteristics (verified from their live docs):
- Auth via
api_keyquery/form parameter β no header-based auth - Both
POSTandGETmethods supported - Send SMS:
POST/GET https://api.sms.net.bd/sendsms - Delivery report:
GET https://api.sms.net.bd/report/request/{id}/?api_key=... - Balance check:
GET https://api.sms.net.bd/user/balance/?api_key=... - Response envelope:
{ error: 0|1, msg: "...", data: { ... } }
7.1 Send SMS Simulation
Since SMS.bd accepts both form-encoded POST and query-parameter GET, we model the POST variant. The api_key is passed as a form field, not a header:
// wiremock/mappings/sms-net-bd/send-sms.json
{
"request": {
"method": "POST",
"urlPath": "/sendsms",
"bodyPatterns": [
{
"matchesJsonPath": "$.api_key"
},
{
"matchesJsonPath": "$.msg"
},
{
"matchesJsonPath": "$.to"
}
]
},
"response": {
"status": 200,
"jsonBody": {
"error": 0,
"msg": "Request successfully submitted",
"data": {
"request_id": {{randomValue length=6 type='NUMERIC'}}
}
},
"headers": {
"Content-Type": "application/json"
}
}
}
7.2 Delivery Report Simulation
The actual delivery check endpoint is GET /report/request/{request_id}/?api_key=.... We use WireMockβs query parameter matching and URL path patterns:
// wiremock/mappings/sms-net-bd/delivery-report.json
{
"scenarioName": "SMS Delivery",
"requiredScenarioState": "Started",
"newScenarioState": "Reported",
"request": {
"method": "GET",
"urlPathPattern": "/report/request/(\\d+)/",
"queryParameters": {
"api_key": {
"matches": ".+"
}
}
},
"response": {
"status": 200,
"jsonBody": {
"error": 0,
"msg": "Success",
"data": {
"request_id": "{{request.pathSegments.[2]}}",
"request_status": "Complete",
"request_charge": "0.2500",
"recipients": [
{
"number": "8801800000000",
"charge": "0.2500",
"status": "Sent"
}
]
}
},
"headers": {
"Content-Type": "application/json"
}
}
}
7.3 Balance Check
// wiremock/mappings/sms-net-bd/balance.json
{
"request": {
"method": "GET",
"urlPath": "/user/balance/",
"queryParameters": {
"api_key": {
"matches": ".+"
}
}
},
"response": {
"status": 200,
"jsonBody": {
"error": 0,
"msg": "Success",
"data": {
"balance": "9999.5000"
}
},
"headers": {
"Content-Type": "application/json"
}
}
}
7.4 Error Scenarios
SMS.bd uses numeric error codes. The most relevant ones for simulation:
// wiremock/mappings/sms-net-bd/errors/insufficient-balance.json
{
"priority": 1,
"request": {
"method": "POST",
"urlPath": "/sendsms",
"bodyPatterns": [
{
"equalToJson": {
"api_key": "LOW_BALANCE_KEY",
"msg": "Test",
"to": "8801800000000"
}
}
]
},
"response": {
"status": 200,
"jsonBody": {
"error": 417,
"msg": "Insufficient balance"
},
"headers": {
"Content-Type": "application/json"
}
}
}
// wiremock/mappings/sms-net-bd/errors/invalid-number.json
{
"priority": 1,
"request": {
"method": "POST",
"urlPath": "/sendsms",
"bodyPatterns": [
{
"matchesJsonPath": "$.api_key"
},
{
"matchesJsonPath": "$.msg"
},
{
"matchesJsonPath": "$.to"
}
],
"bodyPatterns": [
{
"matchesJsonPath": "$.to",
"not": true
}
]
},
"response": {
"status": 200,
"jsonBody": {
"error": 416,
"msg": "No valid number found"
},
"headers": {
"Content-Type": "application/json"
}
}
}
7.5 SMS Client in Express.js
The client sends api_key as a request body field (not a header), matching the actual API:
// src/services/sms-client.ts
import axios from 'axios'
import type { AxiosInstance } from 'axios'
export interface SmsConfig {
apiKey: string
baseUrl?: string // defaults to https://api.sms.net.bd
}
export interface SendSmsInput {
to: string
msg: string
sender_id?: string
schedule?: string
}
export interface SmsSendResponse {
error: number
msg: string
data: {
request_id: number
}
}
export interface SmsReportResponse {
error: number
msg: string
data: {
request_id: number
request_status: string
request_charge: string
recipients: Array<{
number: string
charge: string
status: string
}>
}
}
export interface SmsBalanceResponse {
error: number
msg: string
data: {
balance: string
}
}
export class SmsClient {
private client: AxiosInstance
private config: SmsConfig
constructor(config: SmsConfig) {
this.config = config
this.client = axios.create({
baseURL: config.baseUrl ?? 'https://api.sms.net.bd',
timeout: 5000,
})
}
async send(input: SendSmsInput): Promise<SmsSendResponse> {
const { data } = await this.client.post<SmsSendResponse>(
'/sendsms',
{
api_key: this.config.apiKey,
msg: input.msg,
to: input.to,
sender_id: input.sender_id,
schedule: input.schedule,
},
{
headers: { 'Content-Type': 'application/json' },
},
)
if (data.error !== 0) {
throw new Error(`SMS API error (${data.error}): ${data.msg}`)
}
return data
}
async getReport(requestId: number): Promise<SmsReportResponse> {
const { data } = await this.client.get<SmsReportResponse>(
`/report/request/${requestId}/`,
{
params: { api_key: this.config.apiKey },
},
)
return data
}
async getBalance(): Promise<SmsBalanceResponse> {
const { data } = await this.client.get<SmsBalanceResponse>(
'/user/balance/',
{
params: { api_key: this.config.apiKey },
},
)
return data
}
}
Part 8: Integration with Express.js + TypeScript
8.1 Environment-Driven Configuration
// src/config/services.ts
export const services = {
bkash: {
// In dev/test, point to WireMock. In production, point to real API.
baseUrl: process.env.BKASH_BASE_URL ?? 'http://localhost:8090',
appKey: process.env.BKASH_APP_KEY ?? 'test_app_key',
appSecret: process.env.BKASH_APP_SECRET ?? 'test_app_secret',
username: process.env.BKASH_USERNAME ?? 'test_user',
password: process.env.BKASH_PASSWORD ?? 'test_pass',
},
sms: {
baseUrl: process.env.SMS_BASE_URL ?? 'http://localhost:8090',
apiKey: process.env.SMS_API_KEY ?? 'test_api_key_12345',
},
}
8.2 Docker Compose for Development
# docker-compose.dev.yml
services:
api:
build: .
ports:
- "3000:3000"
environment:
BKASH_BASE_URL: http://wiremock:8080
SMS_BASE_URL: http://wiremock:8080
NODE_ENV: development
depends_on:
- wiremock
wiremock:
image: wiremock/wiremock:3.9.1
ports:
- "8090:8080"
volumes:
- ./wiremock:/home/wiremock
command:
- "--global-response-templating"
- "--verbose"
Workflow: docker compose -f docker-compose.dev.yml up starts the API and WireMock together. Every commit of wiremock/ mappings is live-reloaded by default.
8.3 Integration Tests with Vitest
npm install -D vitest wiremock
// tests/integration/bkash-payment.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { WireMock } from 'wiremock'
import { BkashClient } from '@/services/bkash-client'
const wiremock = new WireMock('http://localhost:8090')
const bkash = new BkashClient({
baseUrl: 'http://localhost:8090',
appKey: 'test_app_key',
appSecret: 'test_app_secret',
username: 'test_user',
password: 'test_pass',
})
beforeAll(async () => {
await wiremock.resetAll()
})
afterAll(async () => {
await wiremock.resetAll()
})
describe('bKash Tokenized Checkout', () => {
it('should grant an access token', async () => {
const token = await bkash.grantToken()
expect(token.id_token).toBeTruthy()
expect(token.token_type).toBe('Bearer')
expect(token.expires_in).toBe(3600)
})
it('should create a payment and return bkashURL', async () => {
const payment = await bkash.createPayment({
mode: '0001',
payerReference: '01712345678',
callbackURL: 'https://myapp.com/callback',
agreementID: 'AGR001',
amount: '1500',
currency: 'BDT',
intent: 'sale',
merchantInvoiceNumber: 'INV-001',
})
expect(payment.statusCode).toBe('0000')
expect(payment.transactionStatus).toBe('Initiated')
expect(payment.paymentID).toMatch(/^TR/)
expect(payment.bkashURL).toContain('https://')
expect(payment.amount).toBe('1500')
})
it('should execute a payment and return Completed', async () => {
const payment = await bkash.createPayment({
mode: '0001',
payerReference: '01712345678',
callbackURL: 'https://myapp.com/callback',
agreementID: 'AGR002',
amount: '500',
currency: 'BDT',
intent: 'sale',
merchantInvoiceNumber: 'INV-002',
})
const executed = await bkash.executePayment(payment.paymentID)
expect(executed.transactionStatus).toBe('Completed')
expect(executed.trxID).toBeTruthy()
expect(executed.customerMsisdn).toBeTruthy()
})
it('should refund a completed transaction', async () => {
const payment = await bkash.createPayment({
mode: '0001',
payerReference: '01712345678',
callbackURL: 'https://myapp.com/callback',
agreementID: 'AGR003',
amount: '100',
currency: 'BDT',
intent: 'sale',
merchantInvoiceNumber: 'INV-003',
})
const executed = await bkash.executePayment(payment.paymentID)
const refund = await bkash.refund({
paymentId: payment.paymentID,
trxId: executed.trxID,
refundAmount: '100',
sku: 'test-sku',
reason: 'Customer request',
})
expect(refund.refundTransactionStatus).toBe('Completed')
expect(refund.refundTrxId).toBe(`${executed.trxID}RFD`)
})
})
describe('SMS.NET.BD Integration', () => {
const sms = new SmsClient({
baseUrl: 'http://localhost:8090',
apiKey: 'test_api_key_12345',
})
it('should send an SMS and return request_id', async () => {
const result = await sms.send({
to: '8801800000000',
msg: 'Your OTP is 123456',
})
expect(result.error).toBe(0)
expect(result.data.request_id).toBeGreaterThan(0)
})
it('should fetch delivery report', async () => {
const sent = await sms.send({
to: '8801700000000',
msg: 'Payment received',
})
const report = await sms.getReport(sent.data.request_id)
expect(report.data.request_status).toBe('Complete')
expect(report.data.recipients[0].status).toBe('Sent')
})
it('should check account balance', async () => {
const balance = await sms.getBalance()
expect(balance.error).toBe(0)
expect(balance.data.balance).toBeTruthy()
})
it('should throw on insufficient balance', async () => {
const brokeSms = new SmsClient({
baseUrl: 'http://localhost:8090',
apiKey: 'LOW_BALANCE_KEY',
})
await expect(
brokeSms.send({ to: '8801800000000', msg: 'Test' }),
).rejects.toThrow('417')
})
})
8.4 Vitest Global Setup for WireMock
// tests/setup.wiremock.ts
import { WireMock } from 'wiremock'
import { beforeAll, afterAll } from 'vitest'
const wiremockUrl = process.env.WIREMOCK_URL ?? 'http://localhost:8090'
export const wiremock = new WireMock(wiremockUrl)
beforeAll(async () => {
await wiremock.resetAll()
})
afterAll(async () => {
await wiremock.resetAll()
})
Part 9: CI/CD Integration
9.1 GitHub Actions
# .github/workflows/test.yml
name: API Tests
on:
push:
branches: [main]
pull_request:
jobs:
integration-tests:
runs-on: ubuntu-latest
services:
wiremock:
image: wiremock/wiremock:3.9.1
ports:
- 8090:8080
volumes:
- ./wiremock:/home/wiremock
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Wait for WireMock
run: |
for i in {1..10}; do
curl -s http://localhost:8090/__admin/health && break
sleep 2
done
- name: Run integration tests
run: npm run test:integration
env:
WIREMOCK_URL: http://localhost:8090
BKASH_BASE_URL: http://localhost:8090
SMS_BASE_URL: http://localhost:8090
9.2 GitLab CI
# .gitlab-ci.yml
integration-tests:
image: node:20
services:
- name: wiremock/wiremock:3.9.1
alias: wiremock
variables:
WIREMOCK_URL: http://wiremock:8080
BKASH_BASE_URL: http://wiremock:8080
SMS_BASE_URL: http://wiremock:8080
before_script:
- npm ci
script:
- npm run test:integration
9.3 Makefile Targets
# Start WireMock for local dev
.PHONY: mock-start
mock-start:
docker run -d --name wiremock \
-p 8090:8080 \
-v $(PWD)/wiremock:/home/wiremock \
wiremock/wiremock:3.9.1
# Stop WireMock
.PHONY: mock-stop
mock-stop:
docker stop wiremock && docker rm wiremock
# Reset all stubs and scenarios
.PHONY: mock-reset
mock-reset:
curl -X POST http://localhost:8090/__admin/reset
# Run tests with WireMock
.PHONY: test-integration
test-integration:
npm run test:integration
Part 10: Advanced Patterns
10.1 Delay Simulation
Simulate network latency to test your timeout handling:
{
"request": {
"method": "POST",
"urlPath": "/tokenized/checkout/create",
"bodyPatterns": [
{ "matchesJsonPath": "$.amount" }
]
},
"response": {
"status": 200,
"fixedDelayMilliseconds": 3000,
"jsonBody": {
"statusCode": "0000",
"transactionStatus": "Initiated",
"paymentID": "TR{{randomValue length=20 type='ALPHANUMERIC'}}"
}
}
}
Or add random delay with a distribution:
{
"response": {
"status": 200,
"delayDistribution": {
"type": "lognormal",
"median": 500,
"sigma": 0.4
},
"jsonBody": { "statusCode": "0000", "transactionStatus": "Initiated" }
}
}
10.2 Fault Injection
Test how your app handles network failures:
{
"request": {
"method": "GET",
"urlPath": "/sendsms"
},
"response": {
"fault": "CONNECTION_RESET_BY_PEER"
}
}
Available fault types:
CONNECTION_RESET_BY_PEERβ TCP connection resetEMPTY_RESPONSEβ Complete empty responseMALFORMED_RESPONSE_CHUNKβ Corrupted chunked encodingRANDOM_DATA_THEN_CLOSEβ Random bytes then close
10.3 State Per API Key (Session-Based Scenarios)
Use {{jsonPath request.body '$.api_key'}} to scope scenario state per API key:
{
"scenarioName": "SMS Send {{jsonPath request.body '$.api_key'}}",
"requiredScenarioState": "Started",
"newScenarioState": "Sent",
"request": {
"method": "POST",
"urlPath": "/sendsms",
"bodyPatterns": [
{ "matchesJsonPath": "$.api_key" },
{ "matchesJsonPath": "$.msg" },
{ "matchesJsonPath": "$.to" }
]
},
"response": {
"status": 200,
"jsonBody": {
"error": 0,
"msg": "Request successfully submitted",
"data": { "request_id": 1001 }
}
}
}
Now each API key independently flows through the SMS lifecycle β exactly like the real API.
10.4 Admin API Usage in Tests
// Programmatic stub creation for ad-hoc test scenarios
import { wiremock } from '../setup.wiremock'
it('should handle gateway timeout gracefully', async () => {
// Create ad-hoc stub for this test only
await wiremock.addStub({
request: {
method: 'POST',
urlPath: '/tokenized/checkout/token/grant',
},
response: {
status: 504,
jsonBody: {
errorCode: '503',
errorMessage: 'System is undergoing maintenance. Please try again later.',
},
fixedDelayMilliseconds: 5000,
},
})
const bkash = new BkashClient({
baseUrl: 'http://localhost:8090',
appKey: 'test',
appSecret: 'test',
username: 'test',
password: 'test',
})
await expect(bkash.grantToken()).rejects.toThrow()
})
Part 11: Comparison with Other Tools
| Feature | WireMock | MSW | nock | Mockoon | Mountebank |
|---|---|---|---|---|---|
| Runtime | JVM | Node.js | Node.js | Node.js | Node.js |
| Dev approach | Standalone server | Service worker | HTTP interception | GUI app | Standalone server |
| CI/CD | β Docker | β npm | β npm | β οΈ CLI mode | β Docker |
| Stateful scenarios | β Built-in | β Manual | β Manual | β | β |
| Recording | β Built-in | β | β | β | β |
| Templating | β Handlebars | β | β | β | β |
| Fault injection | β Built-in | β | β | β οΈ Timeout only | β |
| Admin API | β REST | β | β | β | β |
| Stub file format | JSON files | JS/TS code | JS/TS code | JSON files | JSON files |
| Learning curve | Medium | Low | Low | Low | Medium |
When WireMock wins:
- Your tests use real HTTP (not intercepted)
- You need stateful multi-step API simulations
- You want stable CI with zero external dependencies
- Youβre simulating multiple providers simultaneously
- You want shareable stub definitions committed to the repo
Part 12: Best Practices
12.1 Project Structure
wiremock/
βββ mappings/
β βββ health.json
β βββ bkash/
β β βββ grant-token.json
β β βββ create-payment.json
β β βββ execute-payment.json
β β βββ query-payment.json
β β βββ refund-transaction.json
β β βββ errors/
β β βββ invalid-app-key.json
β β βββ insufficient-balance.json
β βββ sms-net-bd/
β βββ send-sms.json
β βββ delivery-report.json
β βββ balance.json
β βββ errors/
β βββ insufficient-balance.json
β βββ invalid-number.json
βββ __files/
β βββ large-responses/
βββ README.md
12.2 Mapping Design Principles
- One mapping file per endpoint + scenario state β easy to reason about
- Use priorities β error stubs get
priority: 1, happy-path getspriority: 5, catch-all proxy getspriority: 10 - Commit every mapping β the wiremock directory is part of your project, not a developerβs personal config
- Match exactly what the real API expects β headers, query params, body patterns β not what you wish it expected
- Template from request β use
{{jsonPath request.body '$.amount'}}so responses feel real - Reset scenarios between test suites β
wiremock.resetScenarios()ensures test isolation
12.3 Testing Strategy
// β
GOOD: Test real HTTP flow end-to-end through WireMock
it('should complete payment lifecycle', async () => {
const token = await bkash.grantToken()
expect(token.id_token).toBeTruthy()
const payment = await bkash.createPayment({ ... })
expect(payment.transactionStatus).toBe('Initiated')
const executed = await bkash.executePayment(payment.paymentID)
expect(executed.transactionStatus).toBe('Completed')
})
// β BAD: Mocking axios directly
vi.mocked(axios.post).mockResolvedValue({ data: { status: 'Initiated' } })
// Tests axios, not your code
12.4 Performance Tips
- Start WireMock once per test suite (not per test):
beforeAll/afterAll - Use Docker for CI, standalone JAR for local dev
- Avoid
--verbosein CI unless debugging β it slows down I/O - Prefer
jsonBodyoverbodyFileNamefor small responses
Conclusion
WireMock gives you something no mocking library can: a real HTTP server that behaves like a real API provider β stateful, dynamic, and fault-tolerant β without any cloud dependency, API key, or network connection.
What you get:
- Local-first β no internet required to develop against bKash, SMS.NET.BD, or any provider
- CI-friendly β Docker container with zero configuration, identical behaviour in dev and CI
- Real HTTP β tests code paths, not mocked functions; catch serialization, timeout, and header bugs early
- Verified against real API docs β every mapping in this article is built from the actual bKash and SMS.bd specifications
- Stateful scenarios β simulate complete payment lifecycles, delivery confirmations, and complex error flows
- Shareable β commit WireMock mappings to your repo; every teammate and every CI run gets the same simulation
- Zero cost β no sandbox quota, no SMS credits, no premium plan
The pattern is always the same:
- Read the real API docs and model your mappings after them
- Start WireMock β Docker, JAR, or npm package
- Point your services to
http://localhost:8090 - Run your tests against real HTTP, real state, real responses
WireMock wonβt replace unit tests or contract tests. But for integration testing against external APIs, itβs the most reliable, cost-effective, and developer-friendly tool available.
Start with one provider (bKash), add one mapping file, and watch your tests become deterministic, fast, and cloud-free. π