← All guides

Mock a REST API for PyQt / PySide β€” no frozen UIs, every UI state, pytest-qt on a real wire

You're building a PyQt6 (or PySide6) desktop app that talks to a REST backend that doesn't exist yet β€” or exists somewhere you'd rather not hammer while iterating on a list view. Desktop Qt adds two problems web frameworks don't have:

This guide proves both with runnable code, then builds one widget with all four UI branches (loading / error / empty / data) against a hosted mock API, flips every branch with query params instead of code edits, and finishes with pytest-qt tests that exercise the real fetch path. Every snippet below was run verbatim with PyQt6 6.6 + pytest-qt 4.3 against the live endpoints on this page before publishing β€” the printed outputs shown are from those runs.

1. Try it in 10 seconds (no signup)

A shared, self-resetting demo project is live right now:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"

30 seeded products with id, name, price, category, inStock, rating β€” plus orders, customers and reviews collections, full CRUD, filtering, sorting, pagination, and permissive CORS (irrelevant for desktop Qt, handy when your app grows a web build).

⚑ Want your own instead of the shared demo? This link creates a live, seeded e-commerce backend in the dashboard β€” one click, no signup. Or one curl: curl -X POST https://mockbird.mockbird.workers.dev/api/projects -H "content-type: application/json" -d '{"preset":"ecommerce"}'

2. First, the trap: requests.get() in a slot freezes your UI

Everyone writes this once. Here's a measurement instead of a lecture β€” a timer that should tick every 100 ms while we call a deliberately slow backend (?mock_delay=3000 makes the mock take 3 seconds, which is exactly what your real API will do on hotel wi-fi):

import sys, requests
from PyQt6.QtWidgets import QApplication, QWidget
from PyQt6.QtCore import QTimer

app = QApplication(sys.argv)
w = QWidget()
ticks = []
clock = QTimer(w); clock.setInterval(100)
clock.timeout.connect(lambda: ticks.append(1)); clock.start()

def fetch_blocking():
    before = len(ticks)
    r = requests.get("https://mockbird.mockbird.workers.dev/m/demo/products",
                     params={"limit": 3, "mock_delay": 3000})   # 3s slow backend
    print(f"HTTP {r.status_code}; timer ticks while waiting: {len(ticks) - before}")
    app.quit()

QTimer.singleShot(500, fetch_blocking)
app.exec()
HTTP 200; timer ticks while waiting: 0

Three seconds should be ~30 ticks. It was zero: no repaints, no clicks, no timers β€” on Windows the title bar gains a "(Not Responding)". The two real fixes are Qt's own async networking (Β§4) or moving requests to a worker thread (Β§7). Either way you need a backend that can be slow on command to prove you fixed it β€” that's the mock_delay param.

3. The silent killer: your QNetworkAccessManager gets garbage-collected

The number-one PyQt networking bug on Stack Overflow. Make the manager a local variable and the request dies quietly β€” Python frees it when the function returns, before the reply arrives:

def broken_fetch():
    nam = QNetworkAccessManager()          # local variable β€” BUG
    nam.finished.connect(on_reply)
    nam.get(QNetworkRequest(QUrl(
        "https://mockbird.mockbird.workers.dev/m/demo/products?limit=1")))
    # function returns, Python GC frees nam, on_reply NEVER fires

We ran exactly this and waited 6 seconds: the callback never fired. No exception, no log line β€” the request simply evaporates. The fix is ownership: create the manager once with a parent (QNetworkAccessManager(self)) or store it on an attribute, and reuse it for every request (it pools connections; one per window is the intended pattern). The same GC rule applies to QThread workers in Β§7 β€” keep a reference.

4. One widget, all four UI branches

The Qt-native fix: QNetworkAccessManager is asynchronous by design β€” get() returns immediately and finished fires on the GUI thread, so you can touch widgets in the handler with no locks and no threads. Here's a complete list view with loading, error, empty and data branches, plus two signals that make it testable in Β§10:

# productlist.py
import json
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QListWidget
from PyQt6.QtCore import QUrl, pyqtSignal
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest

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

class ProductList(QWidget):
    loaded = pyqtSignal(int)      # emits record count   (handy in tests)
    failed = pyqtSignal(int)      # emits HTTP status

    def __init__(self, parent=None):
        super().__init__(parent)
        self.nam = QNetworkAccessManager(self)   # instance attribute = kept alive
        self.nam.finished.connect(self.on_reply)
        self.status = QLabel("Ready")
        self.items = QListWidget()
        lay = QVBoxLayout(self)
        lay.addWidget(self.status); lay.addWidget(self.items)

    def fetch(self, query="limit=10"):
        self.status.setText("Loading…")          # loading branch
        self.nam.get(QNetworkRequest(QUrl(f"{BASE}/products?{query}")))

    def on_reply(self, reply):
        code = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
        total = bytes(reply.rawHeader(b"X-Total-Count")).decode() or "?"
        body = bytes(reply.readAll())
        reply.deleteLater()
        if code != 200:                           # error branch
            self.status.setText(f"Error {code} β€” retry?")
            self.failed.emit(code or 0)
            return
        products = json.loads(body)
        self.items.clear()
        for p in products:
            self.items.addItem(f'{p["name"]} β€” ${p["price"]}')
        if not products:                          # empty branch
            self.status.setText("No products match.")
        else:                                     # data branch
            self.status.setText(f"{len(products)} of {total} products")
        self.loaded.emit(len(products))

Details that bite people: read the HTTP status via reply.attribute(...HttpStatusCodeAttribute) (it's None on transport failures like DNS errors β€” hence the code or 0), read headers before the body, and call reply.deleteLater() β€” replies are never freed automatically. X-Total-Count is the total across all pages, so ?page=2&limit=10 plus that header is a complete pagination UI (we verified page 2 returns ids 11–20 of 30).

5. Flip every branch with query params β€” zero code changes

Because failure injection lives in the URL, you drive all four branches by changing the query string, not your widget:

Branchw.fetch(...) argumentVerified result
data"limit=10"10 of 30 products
empty"category=no-such-category"No products match.
error"mock_status=500"Error 500 β€” retry?
loading"mock_delay=2000&limit=3"label reads Loading… for 2s, then data

That's a skeleton screen you can actually look at, an error banner you can actually screenshot, and an empty state your designer can actually review β€” in a running desktop app, before the backend exists. The full simulation-params guide lists everything (mock_status takes any 100–599, mock_delay up to 5000 ms, and they compose).

6. Retry logic against a deterministically flaky backend

Random chaos makes flaky tests; mock_seq makes a backend that fails exactly twice, then recovers. Subclass the widget with a retry policy and point it at 503,503,200:

class RetryingList(ProductList):
    MAX_RETRIES = 3
    def fetch(self, query):
        self._query = query
        self._attempt = getattr(self, "_attempt", 0)
        super().fetch(query)
    def on_reply(self, reply):
        code = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
        if code and code >= 500 and self._attempt < self.MAX_RETRIES:
            self._attempt += 1
            reply.deleteLater()
            print(f"got {code}, retry #{self._attempt}")
            QTimer.singleShot(250 * self._attempt,      # backoff
                lambda: super(RetryingList, self).fetch(self._query))
            return
        super().on_reply(reply)

w = RetryingList()
key = uuid.uuid4().hex[:8]          # isolated counter for this run
w.fetch(f"limit=5&mock_seq=503,503,200&mock_seq_key={key}")
got 503, retry #1
got 503, retry #2
recovered: 5 of 30 products

The sequence counter is per project + path (+ per visitor on the shared demo); the fresh mock_seq_key gives each run its own counter, so this passes every time β€” no sleeps, no dice.

7. Prefer requests? Move it to a QThread

If your data layer already speaks requests (sessions, adapters, existing code), keep it β€” off the GUI thread, talking back through a signal (signals are the thread-safe way to touch widgets):

import requests
from PyQt6.QtCore import QThread, pyqtSignal

class FetchWorker(QThread):
    done = pyqtSignal(object)      # list of records
    error = pyqtSignal(str)
    def __init__(self, query, parent=None):
        super().__init__(parent); self.query = query
    def run(self):                 # runs in the worker thread
        try:
            r = requests.get(f"{BASE}/products", params=self.query, timeout=10)
            r.raise_for_status()
            self.done.emit(r.json())
        except requests.RequestException as e:
            self.error.emit(str(e))

self.worker = FetchWorker({"limit": 5, "mock_delay": 3000})  # attribute β€” not local!
self.worker.done.connect(self.show_items)
self.worker.error.connect(self.show_error)
self.worker.start()

Re-running the Β§2 measurement with this worker and the same 3-second backend: 39 timer ticks while waiting (vs 0 blocking). Same slow API, responsive UI β€” and the Β§3 rule applies here too: store the worker on self, or GC kills the thread mid-flight.

8. Writes are real

POST with QNetworkAccessManager persists β€” the record you create is there when you GET it back, so optimistic-update and undo flows are testable:

req = QNetworkRequest(QUrl(f"{BASE}/products"))
req.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json")
nam.post(req, json.dumps({"name": "PyQt6 test kettle", "price": 49.5,
                          "category": "kitchen", "inStock": True,
                          "rating": 4.7}).encode())
POST -> 201 id 31
GET back -> 200 PyQt6 test kettle 49.5
DELETE -> 200

PUT/PATCH/DELETE work the same (nam.put, nam.sendCustomRequest for PATCH, nam.deleteResource). Validation is real too: required/typed fields return 422s if you want your form-error branch exercised.

9. Login flows without an auth server

Every project ships JWT auth endpoints: any email/password pair returns a real signed token, and /auth/me echoes the logged-in user β€” enough to build a whole login dialog + session flow:

req = QNetworkRequest(QUrl(f"{BASE}/auth/login"))
req.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json")
nam.post(req, json.dumps({"email": "qa@example.com",
                          "password": "anything"}).encode())
# β†’ 200 {"token": "eyJhbGciOiJIUzI1NiIs..."}

req = QNetworkRequest(QUrl(f"{BASE}/auth/me"))
req.setRawHeader(b"Authorization", f"Bearer {token}".encode())
nam.get(req)   # β†’ 200 {"user": {"email": "qa@example.com", ...}}

Tokens expire on a schedule you pick (expiresIn β€” set it to 5 seconds to test your refresh logic), and protected mode makes every endpoint demand the Bearer token so your 401-handling branch runs for real.

10. pytest-qt on the real wire

The Β§4 widget emits loaded/failed, which is exactly what qtbot.waitSignal wants. These four tests drive the real widget through the real network path β€” no stubbing, and the error test forces a 500 instead of hoping for one:

# test_productlist.py   (run: QT_QPA_PLATFORM=offscreen pytest -q)
import pytest
from productlist import ProductList

@pytest.fixture
def widget(qtbot):
    w = ProductList()
    qtbot.addWidget(w)
    return w

def test_data_branch(widget, qtbot):
    with qtbot.waitSignal(widget.loaded, timeout=10000) as sig:
        widget.fetch("limit=10")
    assert sig.args == [10]
    assert widget.items.count() == 10

def test_empty_branch(widget, qtbot):
    with qtbot.waitSignal(widget.loaded, timeout=10000):
        widget.fetch("category=no-such-category")
    assert widget.status.text() == "No products match."

def test_error_branch(widget, qtbot):
    with qtbot.waitSignal(widget.failed, timeout=10000) as sig:
        widget.fetch("mock_status=500")
    assert sig.args == [500]

def test_loading_label_shows_immediately(widget, qtbot):
    widget.fetch("mock_delay=2000&limit=3")
    assert widget.status.text() == "Loading…"      # before the reply lands
    with qtbot.waitSignal(widget.loaded, timeout=10000):
        pass
4 passed in 5.19s

QT_QPA_PLATFORM=offscreen makes the whole suite run headless β€” these exact tests passed on a display-less CI-style Linux box. For data that must not shift under assertions, snapshots pin a dataset per run (?mock_snapshot=name).

11. Honest comparison: the Python-native options

OptionGood atWhere it falls short for Qt apps
responses / requests-mockfast, offline unit tests of requests-based data layerspatches Python internals β€” nothing crosses the wire, QNetworkAccessManager is untouched, and it can't help you click around the running app
vcrpy cassettesreplaying a real API you recorded onceneeds the real API first; editing YAML to fabricate a 503 is miserable; same "requests-only" limit
local Flask/json-server stubfull control, offlinea second process to write, run and keep in sync on every dev machine and CI job; no shared URL for teammates or the designer reviewing your empty state
staging backendtruest datacan't make it slow or return 500 on command; test writes pollute it; often VPN-only
Mockbirdhosted https URL, failure injection via query params, persistent CRUD, JWT auth, works from any machineit's mock data, not your production schema (import your OpenAPI spec or a CSV to close that gap); free tier is capped at 10k requests/project/day

The stubbing libraries and this page aren't rivals: use responses for pure-logic unit tests, and a hosted mock for everything that involves a running window, a worker thread, or another human looking at your app.

12. Useful extras

Related: mock APIs for Tkinter (same traps, stdlib GUI), mock APIs for Python, mock APIs for R, mock APIs for Streamlit, testing loading and error states, mock JWT auth, deterministic test data with snapshots, and free mock-API tools compared.

Verification: every Python snippet on this page was run verbatim on 22 Sep 2026 with Python 3.12 + PyQt6 6.6.1 (Qt 6.4) + requests 2.31 + pytest-qt 4.3.1 on a headless Linux box (QT_QPA_PLATFORM=offscreen) against production β€” the printed outputs shown are from those runs, including the 0-ticks freeze measurement, the never-fires GC repro, the 503,503,200 retry recovery, and all four pytest-qt tests in one passing run. The write-test record was deleted afterwards. Demo data reseeds daily, so names, prices and per-category counts will differ when you run it. If a snippet here doesn't work in your app, that's a bug: tell us.