DEV Community

dodou
dodou

Posted on

Mocking a SERP API in Tests: Fixtures Over Live Calls

Tests that call a live SERP API are slow, cost credits, and fail whenever rankings move. The fix is the usual one: record a real response to a JSON fixture once, replay it through a fake transport in your tests, and keep a single opt-in live test that watches for schema drift. No vendor SDK required — the boundary is just one POST.

The boundary you're faking

POST https://api.serpbase.dev/google/search with an X-API-Key header and a JSON body (q required; hl, gl, page, device optional). A successful request costs 1 credit and returns an envelope with status (0 means success), error, request_id, elapsed_ms, credits_charged, plus an organic array whose items carry rank (1-based position in the response), title, link and an optional snippet.

Step 1: record a fixture

import json
import os
import pathlib

import requests

API_URL = "https://api.serpbase.dev/google/search"


def record(query: str, out: pathlib.Path) -> None:
    resp = requests.post(
        API_URL,
        headers={"X-API-Key": os.environ["SERPBASE_API_KEY"],
                 "Content-Type": "application/json"},
        json={"q": query, "hl": "en", "gl": "us", "page": 1},
        timeout=30,
    )
    resp.raise_for_status()
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(json.dumps(resp.json(), ensure_ascii=False, indent=2),
                   encoding="utf-8")


if __name__ == "__main__":
    record("python asyncio tutorial", pathlib.Path("tests/fixtures/search_python.json"))
Enter fullscreen mode Exit fullscreen mode

Every fixture costs 1 credit, so recording five representative queries costs five credits — then your test suite runs forever for free. Field names and the status convention above come from the SerpBase API docs.

Step 2: a client with an injectable session

import requests


class SerpClient:
    def __init__(self, api_key: str, session: requests.Session | None = None,
                 base_url: str = "https://api.serpbase.dev"):
        self.api_key = api_key
        self.session = session or requests.Session()
        self.base_url = base_url

    def search(self, query: str, page: int = 1) -> list[dict]:
        resp = self.session.post(
            f"{self.base_url}/google/search",
            headers={"X-API-Key": self.api_key},
            json={"q": query, "page": page},
            timeout=30,
        )
        resp.raise_for_status()
        data = resp.json()
        if data.get("status") != 0:
            raise RuntimeError(f"status={data.get('status')} error={data.get('error')}")
        return data.get("organic", [])

    @staticmethod
    def position_of(results: list[dict], domain: str) -> int | None:
        for item in results:
            if domain in item.get("link", ""):
                return item.get("rank", item.get("position"))
        return None
Enter fullscreen mode Exit fullscreen mode

Two design choices make this testable: the session is a parameter (so tests pass a fake with the same post signature), and position_of is a pure function — no HTTP, trivially testable.

Step 3: replay the fixture in pytest

import json
import pathlib

import pytest

from client import SerpClient

FIXTURE = json.loads(
    pathlib.Path("tests/fixtures/search_python.json").read_text(encoding="utf-8")
)


class FakeResponse:
    status_code = 200

    def __init__(self, payload):
        self._payload = payload

    def raise_for_status(self):
        pass

    def json(self):
        return self._payload


class FakeSession:
    def __init__(self, payload):
        self.payload = payload
        self.calls = []

    def post(self, url, headers=None, json=None, timeout=None):
        self.calls.append({"url": url, "json": json})
        return FakeResponse(self.payload)


def make_client(payload):
    fake = FakeSession(payload)
    return SerpClient("test-key", session=fake), fake


def test_position_is_found():
    client, _ = make_client(FIXTURE)
    results = client.search("python asyncio tutorial")
    assert SerpClient.position_of(results, "docs.python.org") is not None


def test_missing_domain_returns_none():
    client, _ = make_client(FIXTURE)
    results = client.search("python asyncio tutorial")
    assert SerpClient.position_of(results, "not-in-results.example") is None


def test_api_error_raises():
    client, _ = make_client({"status": 1020, "error": "INSUFFICIENT_CREDITS",
                             "request_id": "req_x"})
    with pytest.raises(RuntimeError, match="1020"):
        client.search("anything")


def test_request_shape():
    client, fake = make_client(FIXTURE)
    client.search("python asyncio tutorial")
    assert fake.calls[0]["json"] == {"q": "python asyncio tutorial", "page": 1}
Enter fullscreen mode Exit fullscreen mode

That last test is the quiet win: the request your code sends is asserted against the documented shape, so a refactor that drops page or renames q fails in CI instead of in production.

Step 4: one live test, opt-in

Fixtures are snapshots; the API can evolve. Keep a single test that hits the real endpoint, marked so it never runs by default:

@pytest.mark.live
def test_live_contract():
    """Run with: pytest -m live   (costs 1 credit)"""
    client = SerpClient(os.environ["SERPBASE_API_KEY"])
    data = client.search("python asyncio tutorial")
    assert data and "rank" in data[0]
Enter fullscreen mode Exit fullscreen mode

Run pytest -m "not live" in CI and pytest -m live weekly, or when you touch parsing code. Register the marker in pytest.ini (markers = live: hits the real API) so the suite stays warning-free.

What it costs

Recording fixtures: 1 credit per query. Everything above then runs at 0 credits — tests, CI, refactors. New accounts start with 100 free searches, which is plenty to record a fixture set and validate the client once against the live API.

FAQ

Why not VCR.py or a record/replay library? For a single endpoint, one JSON file and a twenty-line fake session are easier to read and have no matching rules to debug. Reach for a library when you're recording many hosts.

How often should fixtures be refreshed? Re-record when the API docs change, when you add fields to your parser, or quarterly — a stale fixture still tests your parsing, just not the current payload.

Should I mock the API or run against a sandbox? Mock the HTTP boundary in unit tests; keep one live test as the contract check. That combination catches both your bugs and their schema changes.

Record one fixture for a query you actually use, wire the fake session into your suite, and your SERP tests stop being flaky overnight.

Top comments (0)