← All guides

Mock APIs in Selenium tests β€” no selenium-wire required

Selenium is in an odd spot for network mocking. Unlike Puppeteer or Playwright, its classic API has no request interception at all β€” a Selenium test drives a real browser making real requests to real URLs. For years the community answer was selenium-wire, which spliced a proxy into the driver; that project was archived in January 2024 (last release: October 2022) and no longer tracks current Selenium. The other classic answer is running your own mock server next to the test β€” which is exactly the chore a hosted mock URL removes.

The good news, in order of how much machinery you need:

Every snippet below was run verbatim before publishing (Selenium 4.48.0 Python bindings, Chrome for Testing 152 β€” the parallel two-driver run in Β§6 twice).

1. Get an API (10 seconds, no signup)

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"selenium-demo","preset":"ecommerce"}'

That returns a project id and admin key, with products, orders, customers and reviews already seeded with realistic data β€” full CRUD, filtering, pagination, CORS on. Or create it in one click. The snippets below use the public demo project so you can paste-and-run them with nothing created at all.

2. The zero-interception version (any binding, any Selenium version)

If your app reads its API base from config/env, you need nothing beyond vanilla Selenium β€” this also covers SSR and backend fetches that browser-side interception can never see:

from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait

MOCK = "https://mockbird.mockbird.workers.dev/m/demo"

driver = webdriver.Chrome()
driver.get("http://localhost:8141/?api=" + MOCK)
WebDriverWait(driver, 15).until(
    lambda d: d.find_element("id", "state").text.startswith(("loaded", "error")))
items = driver.find_elements("css selector", "#list li")
assert len(items) == 5
driver.quit()

(The app under test here is a plain page that fetches {base}/products?limit=5 and renders an <li> per product β€” swap in your own app and selector.)

3. The app hardcodes its API host? Rewrite it with BiDi

When you can't change the base URL, the BiDi network API swaps it below the page β€” the page still believes it called the production host, and the response comes from the mock (Mockbird sends Access-Control-Allow-Origin: *, so the cross-origin check passes):

from selenium.webdriver.chrome.options import Options

MOCK = "https://mockbird.mockbird.workers.dev/m/demo"

opts = Options()
opts.enable_bidi = True                       # BiDi on: driver.network appears
driver = webdriver.Chrome(options=opts)

def to_mock(request):
    request.set_url(request.url.replace("https://api.example.com", MOCK))

driver.network.add_request_handler(callback=to_mock,
    url_patterns=["https://api.example.com/*"])
driver.get("http://localhost:8141/")          # app uses its normal "production" base

Call request.set_url() (or set_headers/set_method) inside the handler and Selenium continues the request with your mutations β€” don't call driver.network.continue_request() yourself with the wrapped request object; that's the lower-level command and it doesn't accept the handler's Request wrapper.

4. Loading and error states: inject a flag per test

Every Mockbird endpoint honors simulation query params. A scoped request handler is a clean per-test injection point β€” the app code stays untouched:

# spinner/skeleton spec: every API call takes a real 2 seconds
def slow(request):
    u = request.url
    request.set_url(u + ("&" if "?" in u else "?") + "mock_delay=2000")

h = driver.network.add_request_handler(callback=slow,
    url_patterns=["https://mockbird.mockbird.workers.dev/**"])   # ** β€” see Β§7!

driver.get(APP)
time.sleep(1)
assert driver.find_element("id", "state").text == "loading..."   # skeleton still up at 1s
WebDriverWait(driver, 15).until(
    lambda d: d.find_element("id", "state").text.startswith("loaded"))
driver.network.remove_request_handler(h)

Swap the param for the error-matrix spec: mock_status=500 returns a real 500 with a JSON body (any 400–599 works), and your error UI renders exactly as it would in production β€” our verification run asserted both. On older Selenium without BiDi, point the base URL at the mock (Β§2) and put the flags on a per-scenario project instead. Full recipes: loading & error states guide.

5. Retry logic: an API that fails once, then recovers

mock_seq scripts an exact status sequence β€” deterministic, unlike random chaos. First call 500, every call after that 200:

key = f"run-{int(time.time()*1000)}"          # fresh counter per test run

def flaky(request):
    u = request.url
    sep = "&" if "?" in u else "?"
    request.set_url(f"{u}{sep}mock_seq=500,200&mock_seq_key={key}")

driver.network.add_request_handler(callback=flaky,
    url_patterns=["https://mockbird.mockbird.workers.dev/**"])

Our verification run's retry loop observed exactly [500, 200] and got the data on attempt two. The counter is server-side per key β€” a stale key from a previous run would replay from wherever it left off, hence the timestamp. For random flakiness use ?mock_chaos=0.4 (chaos-failed writes are not applied, so pointing retry logic at it is safe).

6. Parallel drivers, different data states β€” no restore races

Selenium parallelism is one driver per worker (pytest-xdist, Grid nodes) β€” which maps perfectly onto snapshot pinning: save each state (empty list, edge cases, bug repro) as a named snapshot once, then each driver pins itself with one header. Live data is untouched; two drivers read different states of the same project at the same time. Setup (once):

ADMIN='x-admin-key: YOUR_ADMIN_KEY'
B=https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID
curl -X POST $B/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"baseline"}'
curl -X POST $B/resources/products -H "$ADMIN" -H 'content-type: application/json' -d '{"seed":0}'
curl -X POST $B/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"empty"}'
curl -X POST $B/snapshots/baseline/restore -H "$ADMIN"

Then each worker injects the header with a scoped handler:

def make_worker_driver(snapshot=None):
    opts = Options(); opts.enable_bidi = True
    d = webdriver.Chrome(options=opts)
    if snapshot:
        def pin(request):
            request.set_headers({**request.headers,
                                 "x-mockbird-snapshot": snapshot})
        d.network.add_request_handler(callback=pin,
            url_patterns=["https://mockbird.mockbird.workers.dev/**"])
    return d

live_driver  = make_worker_driver()          # sees live data
empty_driver = make_worker_driver("empty")   # pinned to the "empty" snapshot
# both run against the SAME project at the same moment:
# live renders 5 products; empty renders the empty-state UI

That exact parallel run (two drivers in threads) is part of our pre-publish verification. Writes while pinned return 405 with a restore hint, so a test can't corrupt a scenario by accident. If a query param is easier than a header in your setup, ?mock_snapshot=empty does the same thing.

7. Four gotchas we hit while verifying this guide

Honest comparison

selenium-wireBiDi provide_response stubsMockbird (hosted)Both (this guide)
Maintained✘ archived Jan 2024βœ” but new, still stabilizingβœ”βœ”
Works on old Selenium / all bindingsPython only, old versions✘ needs recent BiDi supportβœ” any version, any languagepartial
Works offline / airgapped CIβœ”βœ”βœ˜ needs network✘
Seeded, realistic, stateful CRUD datahand-writtenhand-written, statelessβœ”βœ”
SSR / backend fetches✘ browser only✘ browser onlyβœ” any processβœ” via base URL
Same data for curl / teammates / QA linksβœ˜βœ˜βœ” it's a URLβœ”
Parallel data scenariosDIYDIY state machineβœ” snapshot pinning, one headerβœ”

If your suite is browser-only, airgapped, and you need a handful of static stubs, BiDi's request.provide_response() can answer requests without any server β€” that's a fair choice, and it composes with everything above.

Try it now

pip install selenium
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"selenium-demo","preset":"ecommerce"}'

…or skip the terminal: create the same seeded project in one click. Free, no signup required β€” the project is anonymous and claimable later. Full docs.

Using Puppeteer or Playwright instead? The Puppeteer guide and Playwright guide cover the same patterns with their native interception. Running in CI? Ephemeral project per GitHub Actions run. Testing loading/error UI specifically? Loading & error states.

Environment used for verification: Selenium 4.48.0 (Python), Chrome for Testing 152, run against the live demo project and a scratch project on 2 Sep 2026 β€” including the Β§6 parallel two-driver run (twice) and all four Β§7 gotchas reproduced, not read about. If a snippet here doesn't work, that's a bug: tell us.