Skip to main content

The Discovery

Documentation says an API returns { subscriber_count: integer }. Real live API returns { subscriber_count: number, last_updated_at: ISO8601_string }. You write types based on docs. You deploy. Users see NaN for last_updated_at. You debug by reading undocumented source code. You lose a day. Better way: Before writing a single type, call the real API (or use mocked responses from real API calls) and inspect the JSON. Let TypeScript infer the shape. Commit the JSON to git so future developers see exactly what the API returned on day 1.

The Workflow

  1. Call real API — Use curl or Postman, save response to JSON file
  2. Commit responsegit add fixtures/buttondown-emails-response.json
  3. Infer types from response — Use as const + TypeScript’s typeof inference
  4. Write unit tests — Every test uses the mocked JSON, not hypotheticals
  5. Document deviations — If API schema later drifts, the test failure surface is immediately obvious

Code Pattern

Step 1: Save real API response
// fixtures/buttondown-api/emails-response.json (captured 2026-04-01)
{
  "count": 3,
  "next": "https://api.buttondown.email/v1/emails?page=1&page_size=10",
  "previous": null,
  "results": [
    {
      "id": "email-001",
      "subject": "Welcome to Downlink",
      "body": "...",
      "status": "sent",
      "created_at": "2026-03-15T10:30:00Z",
      "send_at": "2026-03-15T11:00:00Z"
    },
    {
      "id": "email-002",
      "subject": "Update: Q1 2026",
      "status": "draft",
      "created_at": "2026-04-01T09:00:00Z",
      "send_at": null
    }
  ]
}
Step 2: Infer types from fixture
// src/lib/federated/buttondown-client.ts

// Import the fixture as const for perfect type inference
import emailsFixture from '../../../fixtures/buttondown-api/emails-response.json' assert { type: 'json' };

// Infer types from the fixture itself
type ButtondownEmailResponse = typeof emailsFixture.results[0];

// But usually you want to make it explicit for documentation
export interface ButtondownEmail {
  id: string;
  subject: string;
  body?: string; // Optional — draft emails sometimes omit body
  status: 'draft' | 'scheduled' | 'sent';
  created_at: string; // ISO8601
  send_at: string | null; // null for drafts
}

// Verify the fixture matches the interface (runtime safety)
const _: ButtondownEmail = emailsFixture.results[0];
Step 3: Write tests using fixture
// src/lib/federated/buttondown-client.test.ts

import emailsFixture from '../../../fixtures/buttondown-api/emails-response.json' assert { type: 'json' };

describe('buttondown-client', () => {
  beforeEach(() => {
    // Mock fetch to return the fixture
    global.fetch = jest.fn((url) => {
      if (url.includes('/v1/emails')) {
        return Promise.resolve(new Response(JSON.stringify(emailsFixture)));
      }
      return Promise.reject(new Error('Unknown URL: ' + url));
    });
  });

  test('parses email list from real API response', async () => {
    const result = await fetchEmailList('test-key');
    expect(result).toHaveLength(3);
    expect(result[0].id).toBe('email-001');
  });

  test('computes next send from scheduled status', async () => {
    const result = await fetchEmailList('test-key');
    const next = computeNextSend(result);
    expect(next.source).toBe('scheduled');
  });

  test('handles draft status (no send_at)', async () => {
    const result = await fetchEmailList('test-key');
    const draft = result.find((e) => e.status === 'draft');
    expect(draft?.send_at).toBeNull();
  });
});

Why This Matters

Documentation Drift Is Real

DateDocumentation SaysReal API ReturnsImpact
2026-01-01open_rate: number (0–100)✓ MatchesNone
2026-02-15(unchanged)open_rate: number (0–1) (schema changed)Code computes 100 * open_rate, gets NaN for new emails
2026-03-01(still says 0–100)Adds analytics_version: stringCode ignores it, works fine (but wastes API bandwidth)
Discovery method: If the test uses the mocked fixture, you immediately see when you update the fixture (because the type no longer matches). If you write code against hypothetical types, you only discover mismatches in production.

Three Benefits

  1. Catch schema drift at test time, not runtime — Fixture outdated? Type mismatch fails the test.
  2. Future developers have ground truth — “What does the API actually return?” → Look at the fixture. No ambiguity.
  3. Onboarding speed — New eng wants to add a Buttondown field. They look at the fixture, see all available fields, add the type. No guessing.

Real Example: Buttondown Integration

Session: CASA Downlink integration, 2026-04-01

Before (Documentation-based types)

// ✗ Based on Buttondown docs (which said "open_rate is number 0–100")
interface ButtondownEmailAnalytics {
  open_rate: number;
  click_rate: number;
}

// Compute average
const avgRate = emails.reduce((sum, e) => sum + e.open_rate, 0) / emails.length;

// Deploy to production
// Real API returns { open_rate: 15.3, ... }
// Math works. Good.

// But what about subscriber history?
// Docs don't mention it. Assume it exists on subscriber object.
// Make API call. Response: { count: 500, email: "...", date: "..." }
// No "history" field.
// Dig through Buttondown source. Realize history must be accumulated via Redis.
// Rewrite whole flow. 3-day delay.

After (Empirical fixture-based types)

// ✓ Based on real API response
import analyticsFixture from '../../../fixtures/buttondown-api/analytics-response.json';

// Fixture shows: { open_rate: 15.3, click_rate: 4.2, ... }
// Types inferred from fixture
interface ButtondownEmailAnalytics {
  open_rate: number;
  click_rate: number;
}

// Compute average (same as before, but confident)
const avgRate = emails.reduce((sum, e) => sum + e.open_rate, 0) / emails.length;

// Check: does fixture have subscriber history?
// Fixture inspection: NO "history" field on subscriber object
// Conclusion: must build own via Redis accumulation
// Decision made on day 1, Redis pattern in place before coding
// No surprises.

Testing Maturity

LevelApproachRisk
Level 1 (Hypothetical)Write types from docs. No tests.High. Schema drift, missing fields, wrong types in production.
Level 2 (Integration tests)Write types from docs. Test against live API.Medium. Catches drift, but expensive (live API calls in CI).
Level 3 (Empirical fixture)Write types from fixture JSON. Test against fixture. Verify fixture periodically against live API.Low. Fast tests, ground truth in git, periodic verification catches slow drift.
devarno-cloud standard: Level 3 (empirical fixture).

Periodic Verification

Once per quarter, run a script that:
  1. Calls live API
  2. Compares response to committed fixture
  3. Flags schema differences
  4. Updates fixture if needed + commits to git
// scripts/verify-buttondown-schema.ts
async function verifySchema() {
  const liveResponse = await fetch('https://api.buttondown.email/v1/emails', {
    headers: { Authorization: `Token ${process.env.BUTTONDOWN_API_KEY}` },
  }).then((r) => r.json());

  const fixtureResponse = await import('../fixtures/buttondown-api/emails-response.json');

  const diffs = findSchemaDifferences(liveResponse.results[0], fixtureResponse.results[0]);
  if (diffs.length > 0) {
    console.warn('Schema drift detected:');
    diffs.forEach((d) => console.warn(`  ${d}`));
    // Optionally update fixture and commit
  }
}

Recommendation

For every third-party API integration in devarno-cloud:
  1. Day 1: Call real API, save response to fixtures/[service]-[endpoint].json
  2. Day 1: Infer types from fixture using TypeScript’s typeof inference
  3. Day 1: Write tests using mocked fixture responses
  4. Monthly: Run verification script to detect schema drift
  5. Quarterly: Review fixture changes and update docs if needed
This shifts the risk curve: catch mismatches early via tests, not late via production bugs.
Verified in production: CASA Buttondown integration, 19 unit tests, all passing. 2 schema changes detected during integration (analytics endpoint location, subscriber history unavailability) — caught during fixture-based testing, fixed before code deploy.