Test API integrations against real APIs

Learn to test API integrations in layers: unit tests for parsing, recorded fixtures, live contract tests on a scoped key, and CI smoke tests with a credit budget.

  • 40 min
  • Advanced

Every integration test suite eventually faces the same dilemma. Tests against the live API are slow, cost credits, and fail when the network does. Tests against hand-written mocks are fast and free, and they pass happily while production breaks, because the mock encodes what you believed the API returns, not what it actually returns. The usual outcome is a suite that is either expensive and flaky or cheap and unconvincing.

The way out is to stop treating "the tests" as one thing. This lesson builds four layers, each answering a different question at a different cost: pure-function units, recorded fixtures, live contract tests, and post-deploy smoke tests. Then it bounds what the live layers can spend.

The integration test pyramid

LayerQuestion it answersTalks to the API?RunsCost
UnitsDoes my logic handle every shape correctly?NoEvery save, every commitFree, milliseconds
Recorded fixturesDoes my code handle real responses, including failures?No (replays recordings)Every commitFree, milliseconds
Contract testsDoes the API still return what my fixtures say?Yes, with a test keyNightly, and before releasesA few credits per run
Smoke testsDoes production reach the API with the production config?Yes, with the production keyAfter every deployOne to three calls

The shape matters: many units, a good number of fixture tests, a handful of contract tests, one or two smoke calls. Each layer up is slower and costs more, so it should only test what the layers below cannot. Units cannot tell you the API renamed a field; contract tests can. Contract tests cannot tell you production's key was mistyped in the deploy; a smoke test can.

Units: make the logic pure

The most valuable tests in an integration never touch the network, because most of the bugs are not in the HTTP call. They are in what you do with the response: reading nested fields, classifying failures, mapping API data into your own model. Pull that logic into pure functions (input in, output out, no I/O) and it becomes trivially testable. You already have three from this course: the key loader, verifyWebhook and redactUrl. The fourth is the one almost every integration needs.

Reading fields that may be null

APIVerve responses mark some fields premium. On a plan that does not include them, the field is present with the value null, not missing. So is any field the source simply has no value for. That has a consequence people miss: null can appear at any level of a path. If data.countries[0].name were null, then data.countries[0].name.common throws a TypeError in naive code, and optional chaining (?.) is the start of the fix but not the end, because the next line usually does value || 'Unknown' and turns a real false or 0 into the fallback.

Country Lookup shows both kinds of field in one response. cca3 is available on every plan; landlocked, latlng and independent are premium:

Try it liveCountry Lookup2 credits
GEThttps://api.apiverve.com/v1/countrylookup
curl "https://api.apiverve.com/v1/countrylookup?country=France" \
  -H "x-api-key: YOUR_API_KEY"
Sign in or create a free account to run this with your own key.

landlocked is false here, a real value. On a plan without premium fields it would be null. Your code must tell those apart.

landlocked: false is an answer: France has a coastline. landlocked: null is the absence of an answer. Code that writes if (!country.landlocked) reports France as coastal on every plan and reports every country as coastal on a plan without the field. Here is a reader that walks a dotted path, treats null and undefined at any step as "no value", and returns real falsy values untouched:

read-field.mjs
// readField(body, 'data.countries.0.cca3', 'n/a')
// Returns the fallback when any step is null, undefined, missing or not an object.
// Returns real values unchanged, including 0, false and ''.
export function readField(body, path, fallback = null) {
  let cur = body;
  for (const step of String(path).split('.')) {
    if (cur === null || cur === undefined || typeof cur !== 'object') return fallback;
    if (!Object.prototype.hasOwnProperty.call(cur, step)) return fallback;
    cur = cur[step];
  }
  return cur === null || cur === undefined ? fallback : cur;
}

Array indexes work as path steps because '0' is an own property of an array. The own-property check means a path like data.constructor returns the fallback rather than a built-in function. And the final line is the whole point: only null and undefined become the fallback.

The tests write themselves, and they are the ones worth having, because they pin down exactly the cases that break in production:

read-field.test.mjs
// node --test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readField } from './read-field.mjs';

const body = {
  status: 'ok',
  data: { countries: [{ cca3: 'FRA', landlocked: false, ccn3: null, capital: ['Paris'], name: null, population: 0, motto: '' }] },
};

test('walks objects and array indexes', () => assert.equal(readField(body, 'data.countries.0.cca3', 'n/a'), 'FRA'));
test('keeps false', () => assert.equal(readField(body, 'data.countries.0.landlocked', 'n/a'), false));
test('keeps 0', () => assert.equal(readField(body, 'data.countries.0.population', -1), 0));
test('keeps empty string', () => assert.equal(readField(body, 'data.countries.0.motto', 'n/a'), ''));
test('null leaf gives fallback', () => assert.equal(readField(body, 'data.countries.0.ccn3', 'n/a'), 'n/a'));
test('null in the middle gives fallback', () => assert.equal(readField(body, 'data.countries.0.name.common', 'n/a'), 'n/a'));
test('missing index gives fallback', () => assert.equal(readField(body, 'data.countries.5.cca3', 'n/a'), 'n/a'));
test('walking into a string gives fallback', () => assert.equal(readField(body, 'data.countries.0.cca3.length', 'n/a'), 'n/a'));
test('null body gives fallback', () => assert.equal(readField(null, 'data.x', 'n/a'), 'n/a'));

The same discipline applies to everything downstream. When a premium field feeds a decision, decide explicitly what null means: "unknown, send to review" is usually right, and "treat as the safe value" almost never is.

Check yourself

Code reads const coastal = !data.countries[0].landlocked;. The account moves to a plan without premium fields. What happens for a landlocked country such as Austria?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales