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:
- Unit tests run offline with frozen rates. They check your logic: rounding, cross rates, caching, error handling.
- A contract test runs on a schedule against the real API. It checks that the response still has the shape your code expects.
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
| Assert | Do not assert |
|---|---|
Response is an array; [0].rate is a positive number | A specific rate value |
source and target echo the request | That two calls return the same rate |
time parses and is recent | Rate-limit headers on a 200 |
| 401 on a bad key, 429 handling in mocks | That 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.
Originally published at allratestoday.com.

