Exchange Rate API Unit Testing: Mocks, Fixtures and Contract Tests

Reviewed by Madhushan, Fintech Developer — November 2026

Code that depends on live FX is awkward to test. The rate changes every minute, the network may be down in CI, and a test that asserts 0.9215 passes today and fails tomorrow. This guide shows how to test exchange rate API code properly: inject a rate provider, use fixtures with the real response shape, mock HTTP in Vitest or Jest with msw and in Python with responses, cover the 401 and 429 paths, and run one contract test against the real API every night.

Rule one: never assert on live values

A test that calls the real API and checks a number is not a test. It is a coin toss with a delay. Split the work in two:

Inject a rate provider

The easiest code to test never calls fetch from business logic. It depends on an interface, and the HTTP client is one implementation of it.

// rates.ts
export interface RateProvider {
  getRate(source: string, target: string): Promise<number>;
}

export class RateError extends Error {
  constructor(public code: string, public status: number, public resetsAt?: string | null) {
    super(code);
  }
}

export class AllRatesProvider implements RateProvider {
  constructor(private apiKey: string, private fetchFn: typeof fetch = fetch) {}

  async getRate(source: string, target: string): Promise<number> {
    const url = `https://allratestoday.com/api/v1/rates?source=${source}&target=${target}`;
    const res = await this.fetchFn(url, {
      headers: { Authorization: `Bearer ${this.apiKey}` },
    });
    if (res.status === 401) throw new RateError('unauthorized', 401);
    if (res.status === 429) {
      throw new RateError('rate_limited', 429, res.headers.get('X-Monthly-Resets'));
    }
    if (!res.ok) throw new RateError('http_error', res.status);
    const data = await res.json();
    // The endpoint returns an array, even for one pair.
    if (!Array.isArray(data) || data.length === 0) throw new RateError('bad_response', res.status);
    return data[0].rate;
  }
}

export class FixedRateProvider implements RateProvider {
  constructor(private rates: Record<string, number>) {}
  async getRate(source: string, target: string): Promise<number> {
    const rate = this.rates[`${source}/${target}`];
    if (rate === undefined) throw new RateError('unknown_pair', 0);
    return rate;
  }
}

Business logic takes a RateProvider and never knows which one it got:

// pricing.ts
import type { RateProvider } from './rates';

export async function priceInMinorUnits(cents: number, currency: string, rates: RateProvider) {
  if (currency === 'USD') return cents;
  const rate = await rates.getRate('USD', currency);
  return Math.round(cents * rate);
}

Its tests need no network and no mocks, just frozen numbers:

// pricing.test.ts
import { describe, it, expect } from 'vitest';
import { FixedRateProvider } from './rates';
import { priceInMinorUnits } from './pricing';

const rates = new FixedRateProvider({ 'USD/EUR': 0.9215, 'USD/JPY': 151.37 });

describe('priceInMinorUnits', () => {
  it('converts and rounds', async () => {
    expect(await priceInMinorUnits(1999, 'EUR', rates)).toBe(1842);
  });
  it('leaves USD alone', async () => {
    expect(await priceInMinorUnits(1999, 'USD', rates)).toBe(1999);
  });
});

Fixtures with the real response shape

The HTTP client itself still needs tests, and those need fixtures that match the API exactly. The most common bug in exchange rate code is reading res.rate when the response is an array. A fixture with the real shape catches it.

[
  { "rate": 0.9215, "source": "USD", "target": "EUR", "time": "2026-04-03T12:00:00Z" }
]

Keep one fixture per case: a single pair, several targets (target=EUR,GBP,JPY returns one array with three objects), the 401 body and a 429. The 401 body is:

{ "error": "A valid API key is required", "hint": "Get a free API key at https://allratestoday.com/register" }

Mocking fetch with msw in Vitest or Jest

msw intercepts requests at the network layer, so your real client code runs unchanged. This works in Vitest and in Jest.

// rates.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { AllRatesProvider, RateError } from './rates';

const API = 'https://allratestoday.com/api/v1/rates';

const server = setupServer(
  http.get(API, ({ request }) => {
    const url = new URL(request.url);
    if (request.headers.get('Authorization') !== 'Bearer test-key') {
      return HttpResponse.json({ error: 'A valid API key is required' }, { status: 401 });
    }
    return HttpResponse.json([
      { rate: 0.9215, source: url.searchParams.get('source'),
        target: url.searchParams.get('target'), time: '2026-04-03T12:00:00Z' },
    ]);
  }),
);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('AllRatesProvider', () => {
  it('reads the rate from the first array element', async () => {
    const p = new AllRatesProvider('test-key');
    expect(await p.getRate('USD', 'EUR')).toBe(0.9215);
  });

  it('throws unauthorized on 401', async () => {
    const p = new AllRatesProvider('wrong-key');
    await expect(p.getRate('USD', 'EUR')).rejects.toMatchObject({ code: 'unauthorized' });
  });

  it('surfaces the reset time on 429', async () => {
    server.use(http.get(API, () => new HttpResponse(null, {
      status: 429,
      headers: { 'X-Monthly-Limit': '5000', 'X-Monthly-Used': '5000',
                 'X-Monthly-Resets': '2026-12-01T00:00:00Z' },
    })));
    const err = await new AllRatesProvider('test-key').getRate('USD', 'EUR').catch((e) => e);
    expect(err).toBeInstanceOf(RateError);
    expect(err.resetsAt).toBe('2026-12-01T00:00:00Z');
  });
});

onUnhandledRequest: 'error' makes any request you did not mock fail the test, so a stray call to the real API never slips into a unit run. The quota headers only appear on 429 responses; a 200 carries no rate-limit headers, so do not write code that expects them there.

Python with responses and pytest

The same pattern in Python. The client:

# rates.py
import requests

API = "https://allratestoday.com/api/v1/rates"

class RateLimited(Exception):
    def __init__(self, resets_at):
        super().__init__("rate limited")
        self.resets_at = resets_at

def get_rate(source, target, key, session=requests):
    r = session.get(API, params={"source": source, "target": target},
                    headers={"Authorization": f"Bearer {key}"}, timeout=10)
    if r.status_code == 429:
        raise RateLimited(r.headers.get("X-Monthly-Resets"))
    r.raise_for_status()
    return r.json()[0]["rate"]

The tests, with responses matching both the query string and the header:

# test_rates.py
import pytest, requests, responses
from responses import matchers
from rates import API, get_rate, RateLimited

@responses.activate
def test_reads_first_element():
    responses.get(API,
        json=[{"rate": 0.9215, "source": "USD", "target": "EUR", "time": "2026-04-03T12:00:00Z"}],
        match=[matchers.query_param_matcher({"source": "USD", "target": "EUR"}),
               matchers.header_matcher({"Authorization": "Bearer test-key"})])
    assert get_rate("USD", "EUR", key="test-key") == 0.9215

@responses.activate
def test_401_raises():
    responses.get(API, status=401, json={"error": "A valid API key is required"})
    with pytest.raises(requests.HTTPError):
        get_rate("USD", "EUR", key="bad")

@responses.activate
def test_429_exposes_reset():
    responses.get(API, status=429, headers={"X-Monthly-Resets": "2026-12-01T00:00:00Z"})
    with pytest.raises(RateLimited) as exc:
        get_rate("USD", "EUR", key="test-key")
    assert exc.value.resets_at == "2026-12-01T00:00:00Z"

For logic above the client, pass a fake in place of get_rate with monkeypatch, exactly as with FixedRateProvider.

Freeze time as well as rates

If you cache rates, the cache has a TTL, and TTLs need a clock. Use vi.useFakeTimers() in Vitest, jest.useFakeTimers() in Jest or freezegun in Python, then advance the clock past 60 seconds and assert that the second call goes to the provider. Rates refresh every 60 seconds, so a 60-second cache is a sensible default; the caching guide covers the options.

A nightly contract test against the real API

Mocks only prove your code matches your fixtures. A contract test proves the fixtures still match the API. Keep it small, run it on a schedule, and read the key from CI secrets.

// tests/contract/rates.contract.test.ts
import { describe, it, expect } from 'vitest';

const KEY = process.env.ALLRATES_API_KEY;

describe.skipIf(!KEY)('AllRatesToday contract', () => {
  it('returns an array of rate objects', async () => {
    const res = await fetch('https://allratestoday.com/api/v1/rates?source=USD&target=EUR,GBP', {
      headers: { Authorization: `Bearer ${KEY}` },
    });
    expect(res.status).toBe(200);
    const data = await res.json();
    expect(Array.isArray(data)).toBe(true);
    expect(data.map((d: any) => d.target).sort()).toEqual(['EUR', 'GBP']);
    for (const d of data) {
      expect(d.source).toBe('USD');
      expect(typeof d.rate).toBe('number');
      expect(d.rate).toBeGreaterThan(0);
      expect(Date.now() - Date.parse(d.time)).toBeLessThan(24 * 3600 * 1000);
    }
  });

  it('rejects a bad key with 401', async () => {
    const res = await fetch('https://allratestoday.com/api/v1/rates?source=USD&target=EUR', {
      headers: { Authorization: 'Bearer not-a-real-key' },
    });
    expect(res.status).toBe(401);
  });
});

Schedule it in GitHub Actions:

name: rates-contract
on:
  schedule:
    - cron: '17 3 * * *'
  workflow_dispatch:
jobs:
  contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx vitest run tests/contract
        env:
          ALLRATES_API_KEY: ${{ secrets.ALLRATES_API_KEY }}

The first test is one request, because several targets cost one request. The 401 test is free, since rejected 401, 403 and 429 requests are not charged. Use a dedicated key for CI so you can rotate it without touching production. The free plan is a one-off allowance meant for evaluation, so a job that runs every night belongs on a paid plan's key; see pricing.

What to assert, and what not to

AssertDo not assert
Response is an array; [0].rate is a positive numberA specific rate value
source and target echo the requestThat two calls return the same rate
time parses and is recentRate-limit headers on a 200
401 on a bad key, 429 handling in mocksThat the live API is reachable in unit tests

The API docs list every response shape for your fixtures. If you are still picking a provider, the free currency API comparison sets out the options.

FAQ

Should unit tests call the real exchange rate API?

No. Unit tests should run offline and give the same result every time. Inject a fixed rate provider or mock the HTTP layer. Call the real API only from a separate contract test that checks the response shape on a schedule.

What should a contract test assert if rates change every minute?

The shape, never the value. Check that the response is an array, that rate is a positive number, that source and target echo the request, and that time parses as a recent timestamp.

How do I test the rate limit path?

Mock it. Return a 429 with the X-Monthly-Limit, X-Monthly-Used and X-Monthly-Resets headers and assert that your code backs off or falls back to a cached rate. Those headers appear only on 429 responses, so do not expect them on a 200.

Does testing a bad key against the real API use up my quota?

No. Rejected 401, 403 and 429 requests are not charged, so a contract test can check the 401 path with a deliberately wrong key at no cost.

Can I use msw with Jest as well as Vitest?

Yes. msw intercepts requests at the network level in Node, so the same handlers work in Vitest and Jest. Only the import of describe, it and expect differs.

A stable API to test against

One documented response shape, Bearer auth, clear 401 and 429 errors, and rejected requests are not charged. Mid-market rates for 160+ currencies, refreshed every 60 seconds.

Get a free API key

Related Articles