Skip to content
epignosisxPublic

About

Record your test suite's HTTP interactions and replay them during future test runs for fast, deterministic, accurate tests.

Resources

Stars

10 stars

Watchers

1 watching

Forks

Repository files navigation

vcr-test

Record your test suite's HTTP interactions and replay them during future test runs for fast, deterministic, accurate tests.

Works with any HTTP client — fetch, axios, got, node-fetch, undici, or anything built on Node's http.ClientRequest. Powered by @mswjs/interceptors.

Installation

npm install vcr-test --save-dev

Requires Node.js 22 or newer (@mswjs/interceptors, which powers the interception, requires it). For Node 18 or 20, use vcr-test 1.x.

Usage

A cassette contains all the HTTP traffic generated by your code. The first time the test runs, vcr-test makes live HTTP calls and records them. Future test runs replay the recorded traffic.

Using await using (requires TypeScript 5.2+):

import { join } from 'node:path';
import { VCR, FileStorage } from 'vcr-test';
import { api } from './my-api'

describe('some suite', () => {
  it('some test', async () => {
    // Configure VCR
    const vcr = new VCR(new FileStorage(join(__dirname, '__cassettes__')));

    // Intercept HTTP traffic
    await using _cassette = await vcr.useCassette('cassette_name');
    const result = await api.myAwesomeApiCall();

    // Your regular assertions
    expect(result).toBeDefined();
  })
})

The cassette is automatically ejected when the enclosing scope exits — no nesting required.

Callback style

await using additionally needs TypeScript 5.2+. On older TypeScript, use the callback-based API:

await vcr.useCassette('cassette_name', async () => {
  const result = await api.myAwesomeApiCall();
  expect(result).toBeDefined();
});

Recording Modes

VCR supports different recording modes:

Mode Behavior
once Record if the cassette doesn't exist; otherwise play back. (default)
none Play back only. Never makes live calls, even if the cassette is missing.
update Play back recorded interactions, record new ones, delete unused ones.
all Re-record from scratch: ignore any existing cassette, make every call live, and replace the file with this run's traffic. Equivalent to deleting the cassette and recording it again.
import { VCR, RecordMode } from 'vcr-test';

const vcr = new VCR(...);
vcr.mode = RecordMode.update;

You can also override the mode via the VCR_MODE environment variable, which takes precedence over the programmatic setting. This is useful for CI pipelines:

VCR_MODE=none npm test

Cassette Format

Cassettes are stored as YAML files. Each file contains an array of HTTP interactions (request/response pairs):

- request:
    url: https://httpbin.org/post
    method: POST
    headers:
      content-type: application/json
      accept: application/json
    body: '{"name":"john"}'
    bodyEncoding: utf8
  response:
    status: 200
    statusText: OK
    headers:
      content-type: application/json
    body: |
      {"args": {}, "data": "{\"name\":\"john\"}"}
    bodyEncoding: utf8

You can edit cassettes by hand — they're just YAML. content-length is restated from the body on replay, so you do not need to keep it in step when you change one; vcr-test warns when it has to correct a cassette, and re-recording clears the warning.

Extensibility

Request masking

Your API calls might include sensitive data that you do not want to record in a cassette (API Keys, bearer tokens, etc). You can assign a request masker by:

import { VCR } from 'vcr-test';
const vcr = new VCR(...);
vcr.requestMasker = (req) => {
  req.headers['authorization'] = 'masked';
};

Response masking

Responses can carry sensitive data too — an upstream that echoes a token back, or PII in a body that is about to be committed to a cassette. Assign a response masker to rewrite it at record time. It receives the response, and the request that response answered:

import { VCR } from 'vcr-test';
const vcr = new VCR(...);

vcr.responseMasker = (res, req) => {
  // `req` scopes the mask to one endpoint, so unrelated bodies are left alone.
  if (!req.url.includes('/accounts/')) return;

  const body = JSON.parse(res.body);
  body.accessToken = 'masked';
  res.body = JSON.stringify(body);
};

The second parameter is optional — drop it to mask every response:

vcr.responseMasker = (res) => {
  res.body = res.body.replace(/\d{16}/g, 'MASKED_CARD_NUMBER');
};

Anything on the request is fair game for the decision — url, method, headers, body:

vcr.responseMasker = (res, req) => {
  if (req.method === 'POST' && req.headers['content-type']?.startsWith('application/json')) {
    res.body = maskJson(res.body);
  }
};

Two things to know about that request:

  • It is read-only. It is context for the decision, not a second place to mask requests — use requestMasker for that. TypeScript will reject assigning to it.
  • It has already been through requestMasker, so it is the request as the cassette will carry it. If your request masker rewrites the url, branch on what survives it — a check against a real account id or token will never match.

Rewrite the body freely: content-length is restated from whatever the body ends up being, so a masker never has to keep framing headers in step with its own edits. A masker that throws will fail the recording, so guard against bodies that are not the shape you expect (a non-JSON error page, an empty body).

Request pass-through

You may want certain requests to never be recorded. You can do it this way:

import { VCR } from 'vcr-test';
const vcr = new VCR(...);
vcr.requestPassThrough = (req) => {
  return req.url.startsWith('https://example.com');
};

Request matching

VCR will try to find a match in a cassette that matches on url, headers, and body. However, you may want to change this behavior to ignore certain headers and perform custom body checks.

The default request matcher allows you to change some of its behavior:

import { VCR, DefaultRequestMatcher } from 'vcr-test';

const vcr = new VCR(...);

const matcher = new DefaultRequestMatcher();

// the request headers will not be compared against recorded HTTP traffic.
matcher.compareHeaders = false; 

// the request body will not be compared against recorded HTTP traffic.
matcher.compareBody = false;

// This will ignore specific headers when doing request matching
matcher.ignoreHeaders.add('timestamp');
matcher.ignoreHeaders.add('content-length');

// Assign to VCR
vcr.matcher = matcher;

Alternatively, you can extend the default request matcher:

import { DefaultRequestMatcher } from 'vcr-test';

class MyCustomRequestMatcher extends DefaultRequestMatcher {
  public bodiesEqual(recorded: HttpRequest, request: HttpRequest): boolean {
    // custom body matching logic
  }

  public headersEqual(recorded: HttpRequest, request: HttpRequest): boolean {
    // custom headers matching logic
  }

  public urlEqual(recorded: HttpRequest, request: HttpRequest): boolean {
    // custom url matching logic
  }

  public methodEqual(recorded: HttpRequest, request: HttpRequest): boolean {
    // custom method matching logic
  }
}

const vcr = new VCR(...);
vcr.matcher = new MyCustomRequestMatcher();

If you have more advanced matching needs you can implement your own Request Matcher:

/**
 * Matches an app request against a list of HTTP interactions previously recorded
 */
export interface IRequestMatcher {
  /**
   * Finds the index of the recorded HTTP interaction that matches a given request
   * @param {HttpInteraction[]} calls recorded HTTP interactions
   * @param {HttpRequest} request app request
   * @returns {number} the index of the match or -1 if not found
   */
  indexOf(calls: HttpInteraction[], request: HttpRequest): number;
}

export class MyCustomRequestMatcher implements IRequestMatcher {
  ...
}

and assign the custom implementation like this:

const vcr = new VCR(...);
vcr.matcher = new MyCustomRequestMatcher();

For more details refer to the DefaultRequestMatcher implementation.

When playback fails to find a matching interaction, VCR throws a MatchNotFoundError with the unmatched request attached:

import { MatchNotFoundError } from 'vcr-test';

try {
  await vcr.useCassette('cassette_name', async () => {
    await api.myAwesomeApiCall();
  });
} catch (err) {
  if (err instanceof MatchNotFoundError) {
    console.log(err.unmatchedHttpRequest); // { url, method, headers, body }
  }
}

Storage

The library comes with a File storage implementation that saves files in YAML for readability. However, you may prefer to save the cassettes in a database and in JSON. You can change the storage and file format by creating a different storage implementation.

This is the interface you need to satisfy:

/**
 * Cassette storage
 */
export interface ICassetteStorage {
  /**
   * Loads a cassette from storage or undefined if not found.
   * @param {string} name cassette name
   * @returns {Promise<HttpInteraction[] | undefined>}
   */
  load(name: string): Promise<HttpInteraction[] | undefined>;

  /**
   * Saves HTTP traffic to a cassette with the specified name
   * @param {string} name cassette name
   * @param {HttpInteraction[]} interactions HTTP traffic
   * @returns {Promise<void>}
   */
  save(name: string, interactions: HttpInteraction[]): Promise<void>;
}

Then just initialize VCR with your implementation:

const vcr = new VCR(new DatabaseStorage());

For more details refer to the FileStorage implementation.

FAQ

How can I pretty print JSON bodies?

Here is a custom Cassette Storage implementation that adds a new field with the formatted request and response body. It does not modify the real bodies to keep 100% fidelity with what the app sends and receives.

class PrettifiedFileStorage extends FileStorage {
  override save(name: string, interactions: HttpInteraction[]): Promise<void> {
    for (const int of interactions) {
      let contentType = int.request.headers['content-type'];
      if (contentType?.startsWith('application/json')) {
        try {
          // @ts-expect-error dynamically adding field
          int.request.bodyf = JSON.stringify(JSON.parse(int.request.body), null, 2);
        } catch (err) {
          console.error('Failed to prettify request body', err);
        }
      }

      contentType = int.response.headers['content-type'];
      if (contentType?.startsWith('application/json')) {
        try {
          // @ts-expect-error dynamically adding field
          int.response.bodyf = JSON.stringify(JSON.parse(int.response.body), null, 2);
        } catch (err) {
          console.error('Failed to prettify response body', err);
        }
      }
    }

    return super.save(name, interactions);
  }
}

const vcr = new VCR(new PrettifiedFileStorage(...));

How do I update an existing cassette because of a change?

The simplest way is to just delete the cassette and re-record it making all live calls. However, this may be tricky if the is some HTTP call is dynamic and you would not get the exact same data you were testing for. Here are some other options:

  1. Change the cassette manually, after all it is just YAML. content-length is restated from the body on replay, so you do not need to keep it in step.
  2. Change VCR's mode to update, run the test, then change back. This will make live calls for the requests that were not found in the cassette.
  3. Change VCR's mode to all (or set VCR_MODE=all) to re-record the whole cassette, without having to delete the file first.

About

Record your test suite's HTTP interactions and replay them during future test runs for fast, deterministic, accurate tests.

Resources

Stars

10 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages