Jonin - API Testing & Network Mocking
Special Jonin & Jonin → Levels SJ1-SJ2, J1You learn by doing, not reading. You already drive the browser, wrap pages and components, reuse a logged-in session, and parametrize. Jonin steps off the page and onto the network. First you hit a real endpoint and assert its real body and status codes (Special Jonin, rank-06). Then you fake a response with page.route so the UI renders data you control (Jonin, rank-07). Same idea framed both ways: hit the real thing for truth, mock for control. Three short levels. Each is one small step: read the concept, predict the answer, then switch to your IDE and make a real failing pytest pass against the live IchiRamen app. When your terminal shows the expected pass count, come back and tick the level to unlock the next one.
You already cloned the practice repo and installed the toolchain back in Academy A1. If you are on a fresh machine, here it is again - same repo, same toolchain.
git clone https://github.com/tebrex1995/ichiramen-dojo-py
pip install -r requirements.txt && playwright install chromium
Tests run against the live app via base_url in pytest.ini, so both page.request.get("/dojo/app/api/specials.json") and page.goto("/dojo/app/index.html?async=on") take a relative path. Jonin tests live in tests/jonin/. Pinned toolchain: playwright 1.60.0, pytest 8.4.2 (needs Python 3.11+).
Hit a real endpoint and assert its body
One concept: an HTTP response is data you can check - a status code and a body - the same way a page is. One skill: page.request.get(url), then .ok / .status and parsing .json() to assert on the body.
Until now every test drove the browser. An API test skips the page and talks to the server directly. Ask for the page fixture you already know and call page.request.get(...) with a path. You get back a response. Check response.ok (True for a 2xx) and response.status (the number, like 200). Then call response.json() to turn the body into a Python dict and assert on it, like the restaurant name or how many specials came back. This is the complement to mocking: in the mocking lesson you FAKE a response; here you hit a REAL endpoint and assert the REAL body. Mock when you want control; hit the real thing when you want truth.
page.request is the no-browser way in: it reuses the page fixture you already ask for in every test, so there is nothing new to import and no page to load. It inherits base_url too, so the path is relative just like page.goto.
You call response = page.request.get("/dojo/app/api/specials.json") and the file is served with HTTP 200. What is response.ok?
.ok is a boolean that is True for any 2xx status. 200 is success, so response.ok is True. The numeric code lives on response.status (that is 200 here); .ok is the quick yes/no. Loading in a browser is irrelevant - request talks straight to the server.After body = response.json(), what kind of Python value is body for this endpoint?
.json() parses the JSON body into native Python. This endpoint returns a JSON object, so body is a dict; body["specials"] is a list of dicts. (If you only wanted the raw text you would use response.text().) It is plain data you assert on with assert, not expect - there is no page element and nothing to wait for.Open this file. It ships RED: the GET points at a WRONG path (/dojo/app/api/menu.json, which does not exist), so the response is a 404 and assert response.ok fails first. Replace the path with the real one and run.
tests/jonin/test_sj1_specials.py
def test_specials_endpoint_returns_the_menu(page):
# TODO: send a GET to the specials endpoint. Replace this WRONG path
# with the real one: "/dojo/app/api/specials.json"
response = page.request.get("/dojo/app/api/menu.json")
assert response.ok
assert response.status == 200
body = response.json()
assert body["restaurant"] == "IchiRamen"
names = [special["name"] for special in body["specials"]]
assert "Miso Ramen" in names
assert len(body["specials"]) == 4
The wrong path /dojo/app/api/menu.json does not exist, so the static host returns 404, response.ok is False, and assert response.ok fails immediately with the wrong URL and status shown in the message. The one missing piece is the real path /dojo/app/api/specials.json.
{
"restaurant": "IchiRamen",
"currency": "USD",
"specials": [
{ "id": "miso-ramen", "name": "Miso Ramen", "price": 13.50, "available": true },
{ "id": "tonkotsu", "name": "Tonkotsu", "price": 15.00, "available": true },
{ "id": "shoyu", "name": "Shoyu", "price": 12.50, "available": true },
{ "id": "shio", "name": "Shio", "price": 12.00, "available": false }
]
}
def test_specials_endpoint_returns_the_menu(page):
response = page.request.get("/dojo/app/api/specials.json")
assert response.ok
assert response.status == 200
body = response.json()
assert body["restaurant"] == "IchiRamen"
# The list of bowl names that came back from the API.
names = [special["name"] for special in body["specials"]]
assert "Miso Ramen" in names
assert len(body["specials"]) == 4
If you have not met list comprehensions yet, the names = [...] line is optional sugar. This plain for form is exactly equivalent and uses only the tools you met in A8 - the ONE fix in this level is the URL, never the comprehension:
names = []
for special in body["specials"]:
names.append(special["name"])
Why assert, not expect. expect is for on-page elements that may take time to appear (auto-waiting). An API response is data you already hold the moment the call returns; there is nothing to wait for, so plain assert (met in A7) is the right tool. UI state uses expect; a value in hand uses assert.
Real values used: the endpoint /dojo/app/api/specials.json, whose restaurant is "IchiRamen" and whose four specials names/prices mirror the SUT menu cards - Miso Ramen $13.50 (src/dojo/app/index.html:187), Tonkotsu $15.00 (:249), Shoyu $12.50 (:309), Shio $12.00 (:371) - the visible <span class="ry-ramen-price"> price elements. No page element is touched; this is a pure HTTP check.
pytest tests/jonin/test_sj1_specials.py
Expected result: 1 passed.
Assert the negative case (a missing path is 404)
One concept: none brand new - a wrong request is still a real response you assert on; a GET to a path that does not exist returns 404, and a test should prove that on purpose. One skill: the same page.request.get(...), now asserting response.ok is False and response.status == 404.
A good API test suite checks failure on purpose, not just success. If you ask for a path that does not exist, the server answers 404 Not Found - and that is a real, correct response you can assert. Call page.request.get(...) with a path you KNOW is missing, then assert response.ok is False and response.status == 404. This proves the endpoint is specific: the right path returns the menu, the wrong path returns 404, and your test pins both. Checking the unhappy path is what separates a real test from a demo.
You GET /dojo/app/api/specials-does-not-exist.json, which is not a real file. What do you get back?
page.request.get(...) returns that response; .status is 404 and .ok is False. You assert on it exactly like a 200. (A connection error to a dead HOST would raise; a 404 from a live host does not.)Open this file. It ships RED: it correctly checks response.ok is False, but the status assertion expects 200. Replace 200 with the real status for a missing resource and run.
tests/jonin/test_sj2_missing.py
def test_wrong_path_returns_404(page):
response = page.request.get("/dojo/app/api/specials-does-not-exist.json")
assert response.ok is False
# TODO: a missing file returns Not Found. Replace 200 with the real
# status code for a missing resource.
assert response.status == 200
The missing path really returns 404, so assert response.status == 200 fails with assert 404 == 200. The one missing piece is the number 404.
def test_wrong_path_returns_404(page):
# A path that does not exist on the static host.
response = page.request.get("/dojo/app/api/specials-does-not-exist.json")
assert response.ok is False
assert response.status == 404
Real values used: the same endpoint folder /dojo/app/api/; the missing path /dojo/app/api/specials-does-not-exist.json returns 404 from the static host. Nothing on the page is touched.
When to mock vs hit the real endpoint. Hit the REAL endpoint (this rank) when you want truth: confirm the live API actually returns the right data and the right status codes, including 404. MOCK the endpoint (the next level, Jonin, with page.route) when you want CONTROL: force a slow response, a 500, or an empty list to test how the UI reacts, without depending on a live server. Real checks catch "the API changed"; mocks let you test states the real API will not produce on demand. A mature suite uses both - real API tests for the contract, mocked routes for UI edge cases.
pytest tests/jonin/test_sj2_missing.py
Expected result: 1 passed. Ticking SJ2 completes rank-06 "Special Jonin".
Mock the flash-deal seam (page.route / route.fulfill)
One concept: page.route(url, handler) intercepts a network request and route.fulfill(json=...) answers it with data you choose. One skill: mock the flash-deal seam so the banner renders the item and price you control.
page.route(url_glob, handler) registers an interceptor. Whenever the browser requests a URL that matches url_glob, Playwright hands the request to your handler instead of letting it hit the network. The handler calls route.fulfill(...) to answer with a fake response; route.fulfill(json={...}) sends back a 200 with that dict as the JSON body. That is the whole skill: match a URL, answer it yourself. Two things bite beginners. First, register the route BEFORE you navigate - the flash-deal fetch fires on load, so a route added after page.goto is too late. Second, the glob must actually match: the SUT fetches the bare path /api/flash-deal, so **/api/flash-deal matches, but a typo like **/api/flashdeal matches nothing and the banner quietly falls back to the live default.
page.route(
"**/api/flash-deal",
lambda route: route.fulfill(
json={"item": "Miso Ramen", "price": 9.99, "delay": 0}
),
)
page.goto("/dojo/app/index.html?async=on")
# The fetch is intercepted; the banner renders your item and price.
page.goto("/dojo/app/index.html?async=on")
page.route(
"**/api/flash-deal",
lambda route: route.fulfill(
json={"item": "Miso Ramen", "price": 9.99, "delay": 0}
),
)
# The fetch already fired on load. The banner shows the live fallback.
1. Navigate with ?async=on - the flash-deal fetch only fires on the async path. A plain goto never triggers it, so your route never matches.
2. Register the route BEFORE page.goto(...) - the fetch runs on load; a route added after navigation intercepts nothing.
3. The glob must match the real path **/api/flash-deal (with the hyphen). A near-miss silently does nothing and the banner falls back.
You register page.route("**/api/flash-deal", lambda route: route.fulfill(json={"item": "Miso Ramen", "price": 9.99, "delay": 0})) and then page.goto(".../index.html?async=on"). What does the flash-deal banner show?
/api/flash-deal fetch before it reaches the network and answers with your dict. The SUT reads item and price from that body and renders Miso Ramen now $9.99. The live 404 never happens because your handler answered first. delay: 0 makes the banner appear immediately instead of after the 2s default.You write the same test but register the route AFTER page.goto(...). What happens?
page.route only affects requests made AFTER it is registered. The flash-deal fetch runs during load, so by the time you register the route the request is already gone and the banner has fallen back to the live default. The call does not error - it just intercepts nothing. This is the number-one mocking mistake: route first, navigate second.Open this file. It ships RED for the one mistake that bites everyone: the glob has a typo (**/api/flashdeal, missing the hyphen), so the route never matches, the banner falls back, and to_contain_text("Miso Ramen") times out on the live Tonkotsu text. Fix the glob and run.
tests/jonin/test_flash_deal_mock_starter.py
from playwright.sync_api import expect
def test_flash_deal_shows_mocked_data(page):
page.route(
# TODO: this glob has a typo and never matches /api/flash-deal.
# Fix it to "**/api/flash-deal" so the route intercepts.
"**/api/flashdeal", # wrong: missing the hyphen
lambda route: route.fulfill(
json={"item": "Miso Ramen", "price": 9.99, "delay": 0}
),
)
page.goto("/dojo/app/index.html?async=on")
banner = page.get_by_test_id("flash-deal")
expect(banner).to_be_visible(timeout=5000)
expect(banner).to_contain_text("Miso Ramen")
expect(banner).to_contain_text("$9.99")
The wrong glob **/api/flashdeal matches nothing. The fetch goes to the network, 404s, and the SUT falls back to Tonkotsu now $10.00. So expect(banner).to_contain_text("Miso Ramen") keeps auto-waiting and times out, with the actual banner text shown in the failure message. The one missing piece is the correct glob **/api/flash-deal.
from playwright.sync_api import expect
def test_flash_deal_shows_mocked_data(page):
# Register the route BEFORE navigating, so the fetch is intercepted.
page.route(
"**/api/flash-deal",
lambda route: route.fulfill(
json={"item": "Miso Ramen", "price": 9.99, "delay": 0}
),
)
# ?async=on triggers initAsyncFeatures(), which fetch()es /api/flash-deal.
page.goto("/dojo/app/index.html?async=on")
banner = page.get_by_test_id("flash-deal")
expect(banner).to_be_visible()
expect(banner).to_contain_text("Miso Ramen")
expect(banner).to_contain_text("$9.99")
The starter repo ships two files: test_flash_deal_mock_starter.py is the one you edit and run (the RED starter above), and test_flash_deal_mock.py is this canonical reference to compare against. They are the same test; only the glob differs.
If you have not met lambda yet, the one-line handler is just shorthand for "a tiny function that takes route and calls route.fulfill(...)". This named-function form is exactly equivalent and uses only A5 tools - the ONE fix in this level is the glob, never the lambda:
def fake_flash_deal(route):
route.fulfill(json={"item": "Miso Ramen", "price": 9.99, "delay": 0})
def test_flash_deal_shows_mocked_data(page):
page.route("**/api/flash-deal", fake_flash_deal)
page.goto("/dojo/app/index.html?async=on")
banner = page.get_by_test_id("flash-deal")
expect(banner).to_be_visible()
expect(banner).to_contain_text("Miso Ramen")
expect(banner).to_contain_text("$9.99")
Why expect, not assert. The banner appears after the intercepted fetch resolves, so it is on-page state that takes a moment to render. expect(banner).to_contain_text(...) auto-waits for it. This is the opposite of SJ1, where the API response was a value in hand and plain assert was right. Mocking still drives the UI, so UI rules apply: expect for what is on the page.
Real locators / facts used: the banner <div data-testid="flash-deal"> at src/dojo/app/index.html:129; the banner text FLASH DEAL: Miso Ramen now $9.99 is built from the mocked item and price in showFlashDeal() (:1171-1184). The seam /api/flash-deal is fetched bare on the async path (:1236), which is why **/api/flash-deal matches and ?async=on is required. The seam is deliberately unbacked on the live site (it 404s), so your mock is what answers.
Proof it really intercepts - read only, not a task. A mock only means something if the outcome changes when it is present. This control test registers NO route and asserts the LIVE fallback instead - it is a teaching aid, not a starter you fix:
from playwright.sync_api import expect
def test_flash_deal_falls_back_without_mock(page):
# No page.route. The seam 404s, the SUT falls back to Tonkotsu $10.00.
page.goto("/dojo/app/index.html?async=on")
banner = page.get_by_test_id("flash-deal")
expect(banner).to_be_visible(timeout=5000)
expect(banner).to_contain_text("Tonkotsu")
expect(banner).to_contain_text("$10.00")
expect(banner).not_to_contain_text("Miso Ramen")
Mocked: Miso Ramen now $9.99. Unmocked: Tonkotsu now $10.00. The only difference is the page.route call - that is the interception, proven. (The harder rep is /api/order-status, which needs a full add-to-cart, checkout, and place-order flow before the tracker even appears; flash-deal is the clean teaching seam.)
pytest tests/jonin/test_flash_deal_mock_starter.py
Expected result once you fix the glob: 1 passed. Ticking J1 completes rank-07 "Jonin" and ends the API and mocking pair.