Skip to main content

Performing Hand Seals...

SCROLLS ▸ CHUNIN - FIXTURES, AUTH & COMPONENTS (C1-C4)
04

Chunin - Fixtures, Auth & Components

Chunin Exam → Chunin - Levels C1-C4

You learn by doing, not reading. You already write tests, page objects, and data-driven runs. Chunin is the plumbing rank: it makes your suite faster and cleaner without new app skills. You will name the fixtures pytest already gives you, log in once and reuse that session everywhere, write your own fixture so tests stop repeating setup, and wrap a sub-region of the page (the cart) in its own small object. Four short levels, no new corner of IchiRamen to learn. Each level below 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 page.goto("/dojo/app/index.html") takes a relative path. Chunin tests live in tests/chunin/ and reuse the settled Genin page objects in pages/ (login_page.py, menu_page.py) unchanged. Pinned toolchain: playwright 1.60.0, pytest 8.4.2 (needs Python 3.11+).

C1

Name the fixtures you already use

One concept: the built-in fixtures - page, context, and browser are things pytest-playwright hands you by name; conftest.py is where shared fixtures live. One skill: none new on the Playwright side; you only ask for two more built-in fixtures and see how they relate.

Every test so far asked for page in its parentheses. page is a fixture: a ready-made value pytest-playwright builds and hands you. There are two more you can ask for the same way. context is the browser context the page lives in (its own cookies and storage). browser is the whole browser the context runs in. The nesting is simple: a browser holds contexts, and a context holds pages. You never build these; you name them. conftest.py is the shared file where your own fixtures live so every test can use them, which is exactly what the next levels do.

PREDICT - C1.1

A test is declared def test_x(page, context, browser):. Where do these three values come from?

PREDICT - C1.2

Which relationship is true?

Open this file. It ships RED with one assertion blanked to None. Restore it and run.

tests/chunin/test_c1_builtins.py
✗ EDIT - tests/chunin/test_c1_builtins.py: restore the blanked assertion
from playwright.sync_api import Page, BrowserContext, Browser, expect


def test_builtin_fixtures_are_related(page: Page, context: BrowserContext, browser: Browser):
    page.goto("/dojo/app/index.html")
    expect(page.get_by_role("heading", name="Tonight's Bowls")).to_be_visible()
    # TODO: the page lives inside the context. Replace None so this checks that
    #       page.context is the same context fixture you were handed.
    #       assert page.context is context
    assert None is context
    assert context.browser is browser

With assert None is context, the check fails immediately: None is not the context object. The one missing piece is assert page.context is context, which confirms the page really does belong to the context fixture you asked for. The failure is a plain assert with no wait, which is the recognition point: these are ordinary Python objects you can inspect.

✓ Reference - tests/chunin/test_c1_builtins.py
from playwright.sync_api import Page, BrowserContext, Browser, expect


def test_builtin_fixtures_are_related(page: Page, context: BrowserContext, browser: Browser):
    page.goto("/dojo/app/index.html")
    expect(page.get_by_role("heading", name="Tonight's Bowls")).to_be_visible()
    assert page.context is context
    assert context.browser is browser

Real locators used: the menu heading <h1 class="ry-title">Tonight's Bowls</h1> at app/index.html:115 (a heading role). The context/browser checks are pure pytest-playwright objects, not page elements.

pytest tests/chunin/test_c1_builtins.py

Expected result: 1 passed.

C2

Log in once, reuse it everywhere (storageState)

One concept: storageState - capture a logged-in session to a file once, then start later contexts already logged in instead of repeating the login flow. One skill: page.context.storage_state(path=...) to save, and a browser_context_args override with "storage_state": ... to reuse. This is the headline Chunin skill.

Logging in on every test is slow and repetitive. Do it ONCE. A setup test logs in, then calls page.context.storage_state(path="auth/state.json"), which writes the session cookie to a file. Other tests then start from that file: override the browser_context_args fixture to return {"storage_state": "auth/state.json"}, and every context begins already logged in. IchiRamen stores its session in a cookie scoped to /dojo/app, and on load it reads that cookie and shows the member bar. So a reused context shows the member bar with no login steps at all. That is real auth reuse, the way large suites avoid logging in a thousand times.

PREDICT - C2.1

After the capture test writes auth/state.json, what is in that file?

PREDICT - C2.2

A test overrides browser_context_args to add "storage_state": "auth/state.json" and then does page.goto(...) with NO login steps. What shows?

Step 1 - capture (run this ONCE first). tests/chunin/test_c2_capture_state.py logs in and saves the session. It ships GREEN; run it once to produce auth/state.json, then move to Step 2. This file is not a fix - do not edit it, just run it.

tests/chunin/test_c2_capture_state.py
RUN ONCE (ships green) - writes auth/state.json
from playwright.sync_api import Page, expect


def test_capture_logged_in_state(page: Page):
    page.goto("/dojo/app/index.html")
    page.get_by_role("button", name="Log in").click()
    page.get_by_label("Username").fill("naruto")
    page.get_by_label("Password").fill("ramen")
    page.get_by_role("button", name="Sign in").click()
    expect(page.get_by_test_id("member-bar")).to_be_visible()
    page.context.storage_state(path="auth/state.json")
pytest tests/chunin/test_c2_capture_state.py

The file it writes is just the session cookie (verbatim, captured live). auth/state.json is generated and gitignored, never committed.

auth/state.json - read only (generated by the capture run)
{"cookies": [{"name": "ichiramen_session", "value": "naruto", "domain": "localhost",
"path": "/dojo/app", "expires": 1782648044.375293, "httpOnly": false, "secure": false,
"sameSite": "Lax"}], "origins": []}

Step 2 - reuse (the RED piece). Now open tests/chunin/test_c2_reuse_state.py. It ships RED because the browser_context_args override is missing the storage_state key, so the context starts logged out. Add the key and run.

tests/chunin/test_c2_reuse_state.py
✗ EDIT - tests/chunin/test_c2_reuse_state.py: add the storage_state key
import pytest
from playwright.sync_api import Page, expect


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    # TODO: reuse the captured session. Add "storage_state": "auth/state.json"
    #       to the returned dict so this context starts already logged in.
    return {**browser_context_args}


def test_member_bar_without_logging_in(page: Page):
    page.goto("/dojo/app/index.html")
    expect(page.get_by_test_id("member-bar")).to_be_visible()

Without the storage_state key, the context has no session cookie, so the page loads logged out and the member bar stays hidden. The web-first assertion auto-waits then fails with Actual value: hidden. The one missing piece is "storage_state": "auth/state.json" in the returned dict.

✓ Reference - tests/chunin/test_c2_reuse_state.py
import pytest
from playwright.sync_api import Page, expect


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    return {**browser_context_args, "storage_state": "auth/state.json"}


def test_member_bar_without_logging_in(page: Page):
    page.goto("/dojo/app/index.html")
    # No login flow here. The restored cookie shows the member bar on load.
    expect(page.get_by_test_id("member-bar")).to_be_visible()


def test_member_name_is_restored(page: Page):
    page.goto("/dojo/app/index.html")
    expect(page.get_by_test_id("member-bar")).to_contain_text("naruto")

New assertion in this level (first use). The second test uses to_contain_text("naruto"). It is the substring sibling of the already-taught exact-match to_have_text: to_have_text requires the element's full text to equal the string, while to_contain_text passes as long as the string appears anywhere inside it. The member bar reads "Welcome back, naruto", so the substring check fits. Both auto-wait the same way.

Real locators / facts used: the login flow locators ("Log in" app/index.html:89, "Username" :473, "Password" :477, "Sign in" :479); the member bar <div id="memberBar" data-testid="member-bar"> at app/index.html:98. The cookie is set by the login handler at app/index.html:940-941 (ichiramen_session, path /dojo/app), and read back on page load at app/index.html:1354-1355, which shows the bar. That on-load restore is exactly what makes storageState work here with no backend.

Honest note. This SUT cookie is a DEMO cookie: it holds the plaintext username, unsigned and forgeable (the app says so at app/index.html:938-939). Real auth uses an HttpOnly, server-signed token. It exists purely so storageState has a real session to capture and reuse in this lesson, backendless. The storageState mechanism you learn here is identical for real tokens; only the cookie's contents differ. browser_context_args is session-scoped to match pytest-playwright's built-in, so the override applies to every context in the file.

pytest tests/chunin/test_c2_reuse_state.py

Expected result: 2 passed (both reuse tests). Run the capture in Step 1 first so auth/state.json exists. Ticking C2 completes rank-04.

C3

Write your own fixture

One concept: writing your own @pytest.fixture - a named setup that hands a ready value to any test that asks for it by name. This is the only custom decorator Chunin teaches. One skill: a fixture that returns a ready, logged-in page object so the test body is one line.

In C2 you overrode a built-in fixture. Now write your own. A @pytest.fixture is a function that prepares something and hands it back with return. Any test that names it in its parameters receives that value. Here, write logged_in_menu: it opens IchiRamen, logs in through your Genin LoginPage, and returns a ready MenuPage. A test that asks for logged_in_menu skips all the setup and goes straight to the action. This is how real suites stop repeating themselves: setup lives in one fixture, and tests read as pure intent. The fixture is the setup; the test is the check.

PREDICT - C3.1

A test is def test_x(logged_in_menu): and logged_in_menu is your fixture. What does the test receive as logged_in_menu?

PREDICT - C3.2

If the fixture does all the setup but forgets to return the MenuPage, what is logged_in_menu inside the test?

You edit the fixture file; the two page objects are given and read-only (you already own them from Genin). The fixture ships RED with its return line missing. Add the return and run.

tests/chunin/test_c3_fixture.py
✗ EDIT - tests/chunin/test_c3_fixture.py: add the missing return
import pytest
from playwright.sync_api import Page, expect
from pages.login_page import LoginPage
from pages.menu_page import MenuPage


@pytest.fixture
def logged_in_menu(page: Page):
    page.goto("/dojo/app/index.html")
    LoginPage(page).login("naruto", "ramen")
    # TODO: a fixture hands back a value with `return`. Return a MenuPage
    #       built on this page so the test receives a ready page object.
    #       return MenuPage(page)


def test_member_adds_a_bowl(logged_in_menu):
    logged_in_menu.add_bowl("Miso Ramen")
    expect(logged_in_menu.cart_count).to_have_text("1")
pages/login_page.py
GIVEN - do not edit - pages/login_page.py
class LoginPage:
    def __init__(self, page):
        self.page = page
        self.open_button = page.get_by_role("button", name="Log in")
        self.username = page.get_by_label("Username")
        self.password = page.get_by_label("Password")
        self.submit = page.get_by_role("button", name="Sign in")
        self.member_bar = page.get_by_test_id("member-bar")

    def login(self, username, password):
        self.open_button.click()
        self.username.fill(username)
        self.password.fill(password)
        self.submit.click()
pages/menu_page.py
GIVEN - do not edit - pages/menu_page.py
class MenuPage:
    def __init__(self, page):
        self.page = page
        self.cart_count = page.get_by_test_id("cart-count")

    def add_bowl(self, bowl_name):
        self.page.get_by_role("button", name=f"Add {bowl_name} to cart").click()

With no return, the fixture yields None, so logged_in_menu.add_bowl(...) raises AttributeError: 'NoneType' object has no attribute 'add_bowl'. The one missing piece is return MenuPage(page).

✓ Reference - tests/chunin/test_c3_fixture.py
import pytest
from playwright.sync_api import Page, expect
from pages.login_page import LoginPage
from pages.menu_page import MenuPage


@pytest.fixture
def logged_in_menu(page: Page):
    page.goto("/dojo/app/index.html")
    LoginPage(page).login("naruto", "ramen")
    return MenuPage(page)


def test_member_adds_a_bowl(logged_in_menu):
    logged_in_menu.add_bowl("Miso Ramen")
    expect(logged_in_menu.cart_count).to_have_text("1")

Real locators used: the Genin LoginPage locators (login at app/index.html:89, Username :473, Password :477, Sign in :479); the Genin MenuPage Miso add button aria-label="Add Miso Ramen to cart" at app/index.html:188 and the cart counter <span data-testid="cart-count"> at app/index.html:91. No new app element; C3 reuses the Genin page objects and wraps their setup in a fixture.

Note. The fixture uses a return, not a yield. yield (for teardown after the test) is not needed here: pytest-playwright disposes the context for you, so a plain return is the right, simplest tool. Teardown with yield is left out on purpose to keep this level to one idea.

pytest tests/chunin/test_c3_fixture.py

Expected result: 1 passed.

C4

A component object for the cart (capstone)

One concept: none brand new; you reuse class + self to wrap a SUB-REGION of the page (a component) instead of a whole page. One skill: scoping a component's locators to its region with self.panel.get_by_role(...), so the component only ever matches inside the cart.

A page object covers a page; a component object covers one repeating region of it. The cart panel is perfect: it has its own items, quantity buttons, and a remove button per line. Wrap it in a CartComponent. Store the panel itself as self.panel = page.get_by_label("Cart panel"), then build every other locator INSIDE that panel: self.panel.get_by_role("button", name=f"Remove {bowl_name} from cart"). Scoping to the panel keeps the component honest, it cannot accidentally match a button elsewhere on the page. Tests then read like sentences: cart.remove("Miso Ramen"). Components are how real suites stay tidy when one region shows up across many tests.

PREDICT - C4.1

Why does CartComponent build its buttons from self.panel.get_by_role(...) instead of page.get_by_role(...)?

PREDICT - C4.2

The test adds two bowls, opens the cart, then calls cart.remove("Miso Ramen"). What should the cart count read after the removal?

You edit the component; the test is given and read-only. pages/cart_component.py ships RED with the remove body missing (it is pass). Fill in the one scoped click and run.

pages/cart_component.py
✗ EDIT - pages/cart_component.py: fill in the remove body
class CartComponent:
    def __init__(self, page):
        self.page = page
        self.panel = page.get_by_label("Cart panel")
        self.open_button = page.get_by_role("button", name="Shopping cart")
        self.count = page.get_by_test_id("cart-count")

    def open(self):
        self.open_button.click()

    def remove(self, bowl_name):
        # TODO: click the "Remove <bowl_name> from cart" button INSIDE the
        #       panel. Scope it to self.panel so it does not match elsewhere.
        #       self.panel.get_by_role("button", name=f"Remove {bowl_name} from cart").click()
        pass

    def line_item(self, bowl_name):
        return self.panel.get_by_text(bowl_name, exact=True)
tests/chunin/test_c4_cart_component.py
GIVEN - do not edit - tests/chunin/test_c4_cart_component.py
from playwright.sync_api import Page, expect
from pages.menu_page import MenuPage
from pages.cart_component import CartComponent


def test_remove_one_bowl_from_cart(page: Page):
    page.goto("/dojo/app/index.html")
    menu = MenuPage(page)
    cart = CartComponent(page)

    menu.add_bowl("Miso Ramen")
    menu.add_bowl("Tonkotsu")
    cart.open()

    # Both bowls are in the cart, count reads 2.
    expect(cart.count).to_have_text("2")
    expect(cart.line_item("Miso Ramen")).to_be_visible()

    # Remove one through the component; the panel updates.
    cart.remove("Miso Ramen")
    expect(cart.count).to_have_text("1")
    expect(cart.line_item("Miso Ramen")).to_be_hidden()

With pass, remove does nothing, so no line is removed and the cart count stays "2". The web-first assertion to_have_text("1") auto-waits then fails with Actual value: 2. The one missing piece is the scoped click inside remove.

✓ Reference - pages/cart_component.py
class CartComponent:
    def __init__(self, page):
        self.page = page
        # Everything below is scoped to the cart panel region, not the whole page.
        self.panel = page.get_by_label("Cart panel")
        self.open_button = page.get_by_role("button", name="Shopping cart")
        self.count = page.get_by_test_id("cart-count")

    def open(self):
        self.open_button.click()

    def remove(self, bowl_name):
        self.panel.get_by_role("button", name=f"Remove {bowl_name} from cart").click()

    def line_item(self, bowl_name):
        return self.panel.get_by_text(bowl_name, exact=True)

Real locators used: the cart panel <aside id="cartPanel" aria-label="Cart panel"> at app/index.html:486 (get_by_label("Cart panel")); the cart open button aria-label="Shopping cart" at app/index.html:90; the cart counter <span data-testid="cart-count"> at app/index.html:91; the per-line remove button rendered as aria-label="Remove <name> from cart" in renderCart at app/index.html:867; the per-line name span .ry-cart-item-name at app/index.html:861. The Genin MenuPage supplies the add button at app/index.html:188/250.

Note on line_item. It uses self.panel.get_by_text(bowl_name, exact=True) so it matches the item name inside the cart panel. exact=True avoids a partial match (for example, the similar-items section names) leaking in; scoping to self.panel already excludes the menu cards, and exact=True keeps the bowl name from matching a longer label. Both are tools the learner already has (get_by_text, exact).

pytest tests/chunin/test_c4_cart_component.py

Expected result: 1 passed. Ticking C4 completes rank-05 "Chunin" and ends the light fixtures/auth/components interlude.

← Genin - Page Objects Next: Jonin - Network & Data (Sealed)