20.07.2026 β€’ 24 min read

WireMock | Local-First API Mocking & Simulation for Reliable Backends

Cover Image

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:

ProblemReal SandboxWireMock
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

FeatureWireMockMSW (Mock Service Worker)vi.mock() + nockPostman Mock Server
LevelHTTP serverService worker in Node/browserNode libraryCloud 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 supportHTTP/HTTPSHTTP/HTTPS onlyHTTP/HTTPS onlyHTTP/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
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:

MatcherExampleDescription
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": trueAssert 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:

HelperExampleOutput
{{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:

  1. Grant Token β€” authenticate and get an access token
  2. Create Payment β€” initiate payment, get a bkashURL for the customer
  3. 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_key query/form parameter β€” no header-based auth
  • Both POST and GET methods 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 reset
  • EMPTY_RESPONSE β€” Complete empty response
  • MALFORMED_RESPONSE_CHUNK β€” Corrupted chunked encoding
  • RANDOM_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

FeatureWireMockMSWnockMockoonMountebank
RuntimeJVMNode.jsNode.jsNode.jsNode.js
Dev approachStandalone serverService workerHTTP interceptionGUI appStandalone 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 formatJSON filesJS/TS codeJS/TS codeJSON filesJSON files
Learning curveMediumLowLowLowMedium

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

  1. One mapping file per endpoint + scenario state β€” easy to reason about
  2. Use priorities β€” error stubs get priority: 1, happy-path gets priority: 5, catch-all proxy gets priority: 10
  3. Commit every mapping β€” the wiremock directory is part of your project, not a developer’s personal config
  4. Match exactly what the real API expects β€” headers, query params, body patterns β€” not what you wish it expected
  5. Template from request β€” use {{jsonPath request.body '$.amount'}} so responses feel real
  6. 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 --verbose in CI unless debugging β€” it slows down I/O
  • Prefer jsonBody over bodyFileName for 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:

  1. Read the real API docs and model your mappings after them
  2. Start WireMock β€” Docker, JAR, or npm package
  3. Point your services to http://localhost:8090
  4. 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. πŸš€


Further Reading