🚀 Socket Launch Week Day 5:Introducing Repository Access Permissions and Custom Roles.Learn more
Sign In

@sebspark/emulator

Package Overview
Dependencies
Maintainers
7
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@sebspark/emulator

Helper for building emulators or test fakes.

Source
npmnpm
Version
0.1.0
Version published
Weekly downloads
79
-45.14%
Maintainers
7
Weekly downloads
 
Created
Source

@sebspark/emulator

Helper for building emulators or test fakes.

Overview

This package provides a generic, type-safe emulator engine. The idea is that you wrap it in a concrete emulator that adapts a real transport (HTTP, Pub/Sub, gRPC, etc.) to the emulator's simple request/response model. Tests then configure the emulator to respond in specific ways, without needing a real backend.

Real transport (Pub/Sub message, HTTP request, …)
        │
        ▼
  Your emulator adapter        ← decodes, calls emulator.handle(...)
        │
        ▼
   createEmulator()            ← dispatches to registered responders
        │
        ▼
  Your test                    ← registers responders with .reply() / .callback()

Building an emulator

Define a MethodMap that describes every operation your external system exposes, then wire up the transport to call emulator.handle(...).

import { createEmulator, disposable, type Disposable } from '@sebspark/emulator'

// 1. Declare every method with its request and response types
type PaymentMethodMap = {
  authorise: {
    args: { amount: number; currency: string }
    resp: { authCode: string; status: 'approved' | 'declined' }
  }
  refund: {
    args: { authCode: string; amount: number }
    resp: { success: boolean }
  }
}

// 2. Expose a typed emulator handle
export type PaymentEmulator = Disposable<
  ReturnType<typeof createEmulator<PaymentMethodMap>>
>

// 3. Wire up the transport
export const startPaymentEmulator = (server: HttpServer): PaymentEmulator => {
  const emulator = createEmulator<PaymentMethodMap>()

  server.on('POST /authorise', async (req, res) => {
    await emulator.handle('authorise', req.body, async (response) => {
      res.json(response)
    })
  })

  server.on('POST /refund', async (req, res) => {
    await emulator.handle('refund', req.body, async (response) => {
      res.json(response)
    })
  })

  return disposable(emulator, () => server.close())
}

Using the emulator in tests

The intended test pattern is setup → execute → assert, keeping each step explicit and local to the test. Register exactly one responder, trigger exactly one call, check the result:

it('returns an auth code on approval', async () => {
  // Setup
  payments.authorise().reply({ authCode: 'ABC123', status: 'approved' })

  // Execute
  const result = await client.authorise({ amount: 100, currency: 'SEK' })

  // Assert
  expect(result.authCode).toBe('ABC123')
})

The responder is consumed after the call, so a missing setup will throw immediately rather than silently reusing state from another test.

Single response — .reply()

Register a static response or a function. The responder is consumed after one use.

// Static response
payments.authorise().reply({ authCode: 'ABC123', status: 'approved' })

// Computed from the request
payments.authorise().reply((args) => ({
  authCode: `CODE-${args.amount}`,
  status: args.amount > 0 ? 'approved' : 'declined',
}))

Streaming responses — .callback()

Use .callback() when a single trigger produces multiple responses (e.g. order status updates).

payments.authorise().callback((args, cb) => {
  cb({ authCode: 'PENDING', status: 'approved' })
  cb({ authCode: 'SETTLED', status: 'approved' })
})

Lifetime control

In most tests the one-shot default is exactly what you want. Lifetime modifiers are intended for more complex scenarios such as integration-style tests or helpers that need to serve many calls. Prefer explicit per-test setup over persistent responders wherever possible.

By default, a responder is consumed after one use. Control this with:

MethodBehaviour
.reply(...) / .callback(...)One-time (default)
.once().reply(...)One-time (explicit)
.twice().reply(...)Two uses
.thrice().reply(...)Three uses
.times(n).reply(...)n uses
.persist().reply(...)Unlimited uses
// Approve the first two, then always decline
payments.authorise().persist().reply({ authCode: '', status: 'declined' })
payments.authorise().twice().reply({ authCode: 'ABC', status: 'approved' })

Responders are matched in LIFO order — the most recently registered matching responder wins. This makes it easy to stack overrides.

Filters

Pass a filter function to restrict which requests a responder handles:

payments
  .authorise((args) => args.currency === 'SEK')
  .reply({ authCode: 'SEK-OK', status: 'approved' })

payments
  .authorise()
  .reply({ authCode: 'OTHER', status: 'declined' })

Stacking overrides

The most common pattern is a persistent default with one-time overrides layered on top. Because responders resolve in LIFO order, the override is consumed first, then every subsequent request falls through to the default:

// Always decline...
payments.authorise().persist().reply({ authCode: '', status: 'declined' })

// ...except the very next call, which is approved
payments.authorise().reply({ authCode: 'ABC123', status: 'approved' })

// First call  → approved (override consumed)
// Second call → declined (fallback)
// Third call  → declined (fallback)

Unhandled requests

If a request arrives with no matching responder registered, the emulator throws. This is intentional — it surfaces missing setup immediately rather than returning a silent default:

// No responder registered
await payments.authorise(...)
// throws: No responder found for .authorise(...)

Direct invocation — .execute()

Each registration returns an .execute() helper for triggering the responder directly in a test without going through the transport:

const { execute } = payments
  .authorise()
  .reply({ authCode: 'TEST', status: 'approved' })

const result = await execute({ amount: 100, currency: 'SEK' })
// result → { authCode: 'TEST', status: 'approved' }

Cleanup

disposable() adds .dispose() and the Symbol.dispose / Symbol.asyncDispose symbols for using / await using (Node 20+).

// Explicit
await payments.dispose()

// Or with the `using` keyword (TypeScript 5.2+, Node 20+)
await using payments = startPaymentEmulator(server)
// automatically disposed when the block exits

FAQs

Package last updated on 01 Apr 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts