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"))
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
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}
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]
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)