Skip to main content

Performing Hand Seals...

SCROLLS ▸ JONIN - API TESTING & MOCKING
06

Jonin - API Testing & Network Mocking

Special Jonin & Jonin → Levels SJ1-SJ2, J1

You 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.

ONE-TIME SETUP

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+).

SJ1

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.

PREDICT - SJ1.1

You call response = page.request.get("/dojo/app/api/specials.json") and the file is served with HTTP 200. What is response.ok?

PREDICT - SJ1.2

After body = response.json(), what kind of Python value is body for this endpoint?

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
✗ EDIT - tests/jonin/test_sj1_specials.py: point the GET at the real path
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.

the endpoint this hits - read only, not a task - src/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 }
  ]
}
✓ Reference - tests/jonin/test_sj1_specials.py
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:

equal alternative - plain for loop instead of 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.

SJ2

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.

PREDICT - SJ2.1

You GET /dojo/app/api/specials-does-not-exist.json, which is not a real file. What do you get back?

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
✗ EDIT - tests/jonin/test_sj2_missing.py: expect the real missing-resource status
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.

✓ Reference - tests/jonin/test_sj2_missing.py
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".

J1

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.

✓ GOOD - route first, then navigate
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.
✗ BAD - route registered too late
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.
THREE GOTCHAS

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.

PREDICT - J1

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?

PREDICT - J2

You write the same test but register the route AFTER page.goto(...). What happens?

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
✗ EDIT - tests/jonin/test_flash_deal_mock_starter.py: fix the glob typo
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.

✓ Reference - tests/jonin/test_flash_deal_mock.py
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:

equal alternative - a named function instead of 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:

proof it intercepts - read only, not a task - tests/jonin/test_flash_deal_control.py
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.

← Chunin - Fixtures & Components Next: ANBU - Hostile DOM (Sealed)