← Blog

Mock a REST API in pytest without a backend

External HTTP calls are the most common source of flaky integration tests. The API is slow, rate-limited, or just unreachable in CI — and suddenly half your test suite is red for reasons unrelated to your code.

The standard fix is responses or httpretty: hand-written stubs that you maintain forever. The moment the real API adds a field, your stubs drift silently.

APIGhost reads your OpenAPI spec and starts a real HTTP server with realistic fake data. The stubs are generated from the spec, so they update when the spec updates. No maintained JSON blobs.

Install

# Documented pip install path (APIGhost is not on public PyPI;
# its self-hosted index is currently unavailable)
pip install git+https://github.com/Coding-Dev-Tools/apighost.git

# macOS Homebrew
brew tap Coding-Dev-Tools/homebrew-tap
brew install apighost

APIGhost is not on public PyPI. Use one of the commands above.

The simplest pytest fixture

Start the mock server as a session-scoped fixture so it launches once and all tests share it.

import subprocess
import time
import pytest
import requests

@pytest.fixture(scope="session", autouse=True)
def mock_api(tmp_path_factory):
    port = 18080
    proc = subprocess.Popen(
        ["apighost", "serve", "openapi.yaml", "--port", str(port)],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    # Wait for the server to accept connections
    for _ in range(20):
        try:
            requests.get(f"http://localhost:{port}/", timeout=0.5)
            break
        except Exception:
            time.sleep(0.25)
    yield f"http://localhost:{port}"
    proc.terminate()


def test_users_returns_list(mock_api):
    resp = requests.get(f"{mock_api}/users")
    assert resp.status_code == 200
    data = resp.json()
    assert isinstance(data, list)
    assert "email" in data[0]  # faker fills property-name hints automatically

No stubs. No patching. Real HTTP, real JSON, zero external dependencies.

Deterministic responses with VCR

Faker randomness is great for smoke tests but breaks assertions on exact values. Record once, replay forever.

@pytest.fixture(scope="session", autouse=True)
def mock_api(tmp_path_factory):
    cassette = "tests/fixtures/cassette.json"
    port = 18080
    proc = subprocess.Popen(
        ["apighost", "replay", cassette, "--port", str(port)],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    # ... same wait loop as above ...
    yield f"http://localhost:{port}"
    proc.terminate()

Generate the cassette once against a real (or faker-based) server:

apighost record openapi.yaml --output tests/fixtures/cassette.json

Check the cassette into source control. Tests now run identically on every machine and every CI run.

Testing error paths with scenarios

APIGhost ships named scenario presets. To test what your code does when the API returns 500s, pass a fixture parameter instead of editing JSON:

@pytest.fixture
def mock_api_error():
    port = 18081
    proc = subprocess.Popen(
        ["apighost", "serve", "openapi.yaml", "--port", str(port), "--scenario", "error"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    for _ in range(20):
        try:
            requests.get(f"http://localhost:{port}/", timeout=0.5)
            break
        except Exception:
            time.sleep(0.25)
    yield f"http://localhost:{port}"
    proc.terminate()


def test_client_handles_500_gracefully(mock_api_error):
    resp = requests.get(f"{mock_api_error}/orders")
    assert resp.status_code == 500
    # Assert your retry / fallback logic kicks in

No fixture editing. Flip --scenario error on and your entire error-path test suite runs against server-level 500 responses.

GitHub Actions in four lines

APIGhost runs anywhere Python 3.10+ is available. Add it to your CI workflow alongside your test runner:

- name: Install test deps
  run: |
    pip install git+https://github.com/Coding-Dev-Tools/apighost.git
    pip install pytest requests

- name: Run tests (mock server starts inside pytest fixture)
  run: pytest tests/

No extra services to spin up in GitHub Actions. The mock_api fixture handles the lifecycle.

When to use VCR vs live faker

SituationRecommendation
Smoke tests / schema coverageLive faker (apighost serve) — always reflects spec shape
Assertions on specific valuesVCR cassette (apighost replay) — deterministic
Error path testing--scenario error against live faker or cassette
CI on every PRVCR cassette committed to repo — no network dependency

Pricing

The Free tier covers local and dev use — the fixture above runs entirely free. Pro ($12/mo or $119/yr) includes unlimited requests, VCR cassettes, and CI/CD integration. Suite ($49/mo) bundles all Coding-Dev-Tools products under one license.

What about responses, httpretty, or pytest-httpserver?

Those tools are fine for unit tests where you control every response. APIGhost is the better fit when you have an OpenAPI spec and want generated coverage without maintaining stubs. When the spec changes, regenerate the cassette — no JSON hunting.