Skip to main content

Performing Hand Seals...

SCROLLS ▸ GENIN - PAGE OBJECTS (G1-G6)
03

Genin - Page Objects

Genin - the Page Object pivot - Levels G1-G6

You learn by doing, not reading. Your Academy tests work, but every test re-types the same locators - change IchiRamen's login button and you would fix it in ten files. The Page Object Model puts each page's locators in one class, so tests read like plain sentences and you fix a locator in one place. Genin builds one page object slowly, one Python idea at a time (dict, class, self, two page objects, parametrize). 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. Genin adds a pages/ folder for your page objects and tests/genin/ for these tests. Pinned toolchain: playwright 1.60.0, pytest 8.4.2 (needs Python 3.11+).

G1

Test data in a dict

One concept: a dict - a labelled bag of values you look up by key. One skill: drive a .fill() from a dict value instead of a bare string literal.

So far your test values were loose strings scattered through the test. A dict groups related values under names: member = {"username": "naruto", "password": "ramen"}. You read a value with its key in square brackets: member["username"] gives "naruto". This is your first step toward clean test data - the credentials live in one labelled place, not spread across the file. Use them to log in: fill the username field with member["username"] and the password field with member["password"], then submit. The login flow is the same one you wrote in A6; only where the values come from has changed.

PREDICT - G1.1

member = {"username": "naruto", "password": "ramen"}. What does member["password"] return?

PREDICT - G1.2

The password field is filled with member["password"]. If the dict held "password": "" (an empty string), what happens at the end?

Open this file and make it pass by fixing the one blanked dict value:

tests/genin/test_g1_login_data.py
✗ EDIT - tests/genin/test_g1_login_data.py: fix the blanked dict value
from playwright.sync_api import Page, expect


def test_login_from_dict(page: Page):
    page.goto("/dojo/app/index.html")
    # TODO: store the member's password in this dict. The valid member is
    #       username "naruto", password "ramen". Replace the empty string.
    member = {"username": "naruto", "password": ""}
    page.get_by_role("button", name="Log in").click()
    page.get_by_label("Username").fill(member["username"])
    page.get_by_label("Password").fill(member["password"])
    page.get_by_role("button", name="Sign in").click()
    expect(page.get_by_test_id("member-bar")).to_be_visible()

With "password": "", the login submits a blank password, IchiRamen rejects it, and the member bar stays hidden. The web-first assertion auto-waits then fails with Actual value: hidden. The one missing piece is the dict value "ramen".

✓ Reference - tests/genin/test_g1_login_data.py
from playwright.sync_api import Page, expect


def test_login_from_dict(page: Page):
    page.goto("/dojo/app/index.html")
    member = {"username": "naruto", "password": "ramen"}
    page.get_by_role("button", name="Log in").click()
    page.get_by_label("Username").fill(member["username"])
    page.get_by_label("Password").fill(member["password"])
    page.get_by_role("button", name="Sign in").click()
    expect(page.get_by_test_id("member-bar")).to_be_visible()

Real locators used: the "Log in" button <button id="loginBtn"> at app/index.html:89; the username field labelled "Username" at app/index.html:473; the password field labelled "Password" at app/index.html:477; the "Sign in" submit at app/index.html:479; the member bar <div id="memberBar" data-testid="member-bar"> at app/index.html:98.

pytest tests/genin/test_g1_login_data.py

Expected result: 1 passed.

G2

Wrap one page's locators in a class

One concept: a class with __init__(self, page) - a blueprint you build with the page handed in. One skill: none brand new on the Playwright side; the locators are the same A6 ones, now grouped inside a class.

A class is a blueprint. class LoginPage: defines one; LoginPage(page) builds one. When you build it, Python runs __init__, the setup method. Its first parameter is always self (the object being built, covered next level); its second here is page, the browser handed in so the class can build its locators. For now, just gather the login page's four locators inside __init__. The win is grouping: every login locator lives in one file, pages/login_page_g2.py, instead of being retyped in each test. This level only builds the class. Making the locators reusable from methods is the very next step.

PREDICT - G2.1

When you write login = LoginPage(page), what runs?

PREDICT - G2.2

__init__ is written as def __init__(self, page):. When you call LoginPage(page), how many arguments did you pass, and where does self come from?

You edit the page object; the test file is given and read-only. The class ships RED with __init__ missing its page parameter. Add the parameter and run the test.

pages/login_page_g2.py
✗ EDIT - pages/login_page_g2.py: add the missing piece
class LoginPage:
    # TODO: __init__ needs the page handed to it so it can build locators.
    #       Add the page parameter:  def __init__(self, page):
    def __init__(self):
        open_button = page.get_by_role("button", name="Log in")
        username = page.get_by_label("Username")
        password = page.get_by_label("Password")
        submit = page.get_by_role("button", name="Sign in")
tests/genin/test_g2_login_page.py
GIVEN - do not edit - tests/genin/test_g2_login_page.py
from playwright.sync_api import Page, expect
from pages.login_page_g2 import LoginPage


def test_login_page_object_builds(page: Page):
    page.goto("/dojo/app/index.html")
    login = LoginPage(page)
    # The class built without error: the four login locators now live in one place.
    assert isinstance(login, LoginPage)
    # The page still works the plain way; the class did not break anything.
    expect(page.get_by_role("button", name="Log in")).to_be_visible()

The test calls LoginPage(page). Because __init__ only declares self, passing page is one argument too many, so Python raises TypeError: LoginPage.__init__() takes 1 positional argument but 2 were given before any locator is built. That is precisely the concept: __init__ must accept the page you hand it. Add the page parameter and it builds.

✓ Reference - pages/login_page_g2.py
class LoginPage:
    def __init__(self, page):
        open_button = page.get_by_role("button", name="Log in")
        username = page.get_by_label("Username")
        password = page.get_by_label("Password")
        submit = page.get_by_role("button", name="Sign in")

Real locators used: the same four login locators as G1 - "Log in" at app/index.html:89, "Username" at app/index.html:473, "Password" at app/index.html:477, "Sign in" at app/index.html:479.

Honest note: the locators are built as plain local variables inside __init__, so nothing outside __init__ can see them yet - the class only groups them. This is intentional. Making them reachable from methods is exactly what self does, which is the next level. We do not introduce self.x here because that would be two new ideas (class AND instance attributes) in one level. The starter's failure is therefore about the page parameter only - the one thing a first class most often gets wrong.

pytest tests/genin/test_g2_login_page.py

Expected result: 1 passed.

G3

self: make locators reusable from methods

One concept: self and instance attributes - store values on self.x so every method on the object can reuse them. This is the Python beginner cliff, so it is its own level by design. One skill: none brand new; you reuse .click() and .fill() inside a method.

Last level the locators vanished when __init__ finished, because they were plain local variables. To keep them, attach them to the object with self: self.username = .... self is the object being built; anything you put on self survives and is reachable from every method. That is why every method takes self as its first parameter - it is how the method gets back to the same object's stored locators. Store the four login locators on self, then write one method, login(self, username, password), that reuses self.open_button, self.username, self.password, and self.submit. The test then reads as one line: login.login("naruto", "ramen").

Why every method takes self (read this slowly). self is not magic and not a keyword - it is just the name of the first parameter, and by convention we always call it self. When you write login.login("naruto", "ramen"), Python turns it into LoginPage.login(login, "naruto", "ramen") behind the scenes - it passes the object itself in as the first argument. So inside the method, self IS that object, and self.username is the locator you stored in __init__. Forget the self. prefix and the value is a throwaway local again, gone the moment the method ends. That is the single idea this level exists to drill.

PREDICT - G3.1

Inside __init__ you write self.submit = page.get_by_role("button", name="Sign in"). Later, inside the login method, how do you click that same button?

PREDICT - G3.2

If __init__ did self.submit = None instead of storing the real locator, what happens when login runs self.submit.click()?

You edit the page object; the test file is given and read-only. The class ships RED with one self.x blanked to None. Restore that one line and run the test.

pages/login_page_g3.py
✗ EDIT - pages/login_page_g3.py: add the missing piece
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")
        # TODO: store the "Sign in" submit button on self so login() can click it.
        #       self.submit = page.get_by_role("button", name="Sign in")
        self.submit = None

    def login(self, username, password):
        self.open_button.click()
        self.username.fill(username)
        self.password.fill(password)
        self.submit.click()
tests/genin/test_g3_login_method.py
GIVEN - do not edit - tests/genin/test_g3_login_method.py
from playwright.sync_api import Page, expect
from pages.login_page_g3 import LoginPage


def test_login_method(page: Page):
    page.goto("/dojo/app/index.html")
    login = LoginPage(page)
    login.login("naruto", "ramen")
    expect(page.get_by_test_id("member-bar")).to_be_visible()

With self.submit = None, the login method opens the form and fills both fields, then hits self.submit.click() on None and raises AttributeError: 'NoneType' object has no attribute 'click'. The error points exactly at the missing instance attribute. Restore the one self.submit = ... line and the method submits the form.

✓ Reference - pages/login_page_g3.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")

    def login(self, username, password):
        self.open_button.click()
        self.username.fill(username)
        self.password.fill(password)
        self.submit.click()

Real locators used: the same four login locators (app/index.html:89, :473, :477, :479) and the member bar at app/index.html:98. The login handler is at app/index.html:926-945.

pytest tests/genin/test_g3_login_method.py

Expected result: 1 passed.

G4

Two page objects in one test (POM capstone)

One concept: none brand new; you combine the class + self ideas by writing and using a SECOND page object alongside the first. One skill: a capstone test that touches NO raw locator - it reads only through page objects.

One page object is a tool; the point of POM is that a whole test reads through them. Add a second class, MenuPage, the same way you built LoginPage: locators on self in __init__, and an add_bowl(self, bowl_name) method that clicks that bowl's add button (the f-string name from A8, now inside a method). The capstone test builds both page objects, logs in through LoginPage, adds a bowl through MenuPage, and checks the cart count - and it never writes a raw get_by_* of its own. That is the payoff: tests read like sentences, and every locator has exactly one home.

This capstone has TWO small additions across two files, both stated up front - no hidden pieces. Do them in order.

ADDITION 1 - pages/login_page_g4.py (do this FIRST)

The capstone reads login state through the page object with expect(login.member_bar).to_be_visible(). Your G3 LoginPage never stored the member bar, so open pages/login_page_g4.py and add this one line to __init__:

pages/login_page_g4.py - add to __init__
        self.member_bar = page.get_by_test_id("member-bar")

Skip it and the test stops at login.member_bar with AttributeError: 'LoginPage' object has no attribute 'member_bar' before it ever reaches the bowl. Adding one attribute as the test asks more of the object is exactly how a real page object grows.

ADDITION 2 - pages/menu_page_g4.py (the RED piece)

The new pages/menu_page_g4.py ships with add_bowl empty (pass). Fill in the one line that clicks the f-string-named add button - the A8 pattern, now inside a method. The full starter is below.

PREDICT - G4.1

add_bowl builds its button name with f"Add {bowl_name} to cart". Why does this method store self.page in __init__ but reach the add button through self.page.get_by_role each call instead of storing it on self?

PREDICT - G4.2

The capstone test calls login.login(...), menu.add_bowl(...), and checks menu.cart_count. How many raw get_by_* calls does the test file itself contain?

You edit two page-object files (Additions 1 and 2); the capstone test is given and read-only.

pages/login_page_g4.py
✗ EDIT - pages/login_page_g4.py: add Addition 1 (self.member_bar)
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")
        # TODO (Addition 1): store the member bar so the capstone can read
        #       login state through the page object:
        #       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_g4.py
✗ EDIT - pages/menu_page_g4.py: add Addition 2 (fill add_bowl)
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):
        # TODO: click the "Add <bowl_name> to cart" button for this bowl.
        #       Build the name with an f-string, the way you did in Academy A8.
        pass
tests/genin/test_g4_capstone.py
GIVEN - do not edit - tests/genin/test_g4_capstone.py
from playwright.sync_api import Page, expect
from pages.login_page_g4 import LoginPage
from pages.menu_page_g4 import MenuPage


def test_member_adds_a_bowl(page: Page):
    page.goto("/dojo/app/index.html")
    login = LoginPage(page)
    menu = MenuPage(page)

    login.login("naruto", "ramen")
    expect(login.member_bar).to_be_visible()

    menu.add_bowl("Miso Ramen")
    expect(menu.cart_count).to_have_text("1")

With pass, add_bowl does nothing, so no bowl is added and the cart count stays "0". The web-first assertion to_have_text("1") auto-waits then fails with Actual value: 0. The RED piece is the method body that clicks the f-string-named add button - after Addition 1 (self.member_bar) is in place. That keeps one missing piece per file: the member_bar line in login_page_g4.py, the method body in menu_page_g4.py.

✓ Reference - pages/login_page_g4.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()
✓ Reference - pages/menu_page_g4.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()

Real locators used: the login locators (app/index.html:89, :473, :477, :479) and the member bar at app/index.html:98, now stored on LoginPage; the Miso Ramen add button aria-label="Add Miso Ramen to cart" at app/index.html:188; the cart counter <span data-testid="cart-count"> at app/index.html:91. The cart count is updated by cartCountEl.textContent = totalQty in renderCart at app/index.html:847.

pytest tests/genin/test_g4_capstone.py

Expected result: 1 passed. Ticking it completes the Genin POM pivot (G1-G4). Two more levels (G5-G6, the parametrize tail) finish the rank.

G5

Run one test over many rows (parametrize)

One concept: @pytest.mark.parametrize - run the SAME test once per row of input data, so different inputs are checked without copying the test. One skill: the parametrize decorator plus the matching test arguments.

In A6 your if/else branched, but the input was fixed, so only one branch ever ran. Real suites need to check many inputs. @pytest.mark.parametrize does that: you list the parameter names and a list of rows, and pytest runs the test once per row, passing each row's values in as arguments. Here you test login with three rows: the valid member, a wrong password, and a wrong username. Each row carries what to expect (member bar visible or hidden), so the data itself decides the assertion. One test body, three real runs. If a fourth case matters tomorrow, you add a row, not a test.

PREDICT - G5.1

The decorator lists three rows. How many times does pytest run the test function?

PREDICT - G5.2

A row is ("naruto", "wrong", False) with parameters (username, password, expect_member). Inside the test, what is expect_member for this row, and which assertion runs?

Open this file. It ships RED on ONE row: a row's expected value is wrong. Fix that one value and all three rows pass.

tests/genin/test_g5_login_rows.py
✗ EDIT - tests/genin/test_g5_login_rows.py: fix the one wrong row
import pytest
from playwright.sync_api import Page, expect


@pytest.mark.parametrize("username, password, expect_member", [
    ("naruto", "ramen", True),
    # TODO: a wrong password must NOT show the member bar. Fix the expected
    #       value below from True to the correct boolean for this row.
    ("naruto", "wrong", True),
    ("sasuke", "ramen", False),
])
def test_login_rows(page: Page, username, password, expect_member):
    page.goto("/dojo/app/index.html")
    page.get_by_role("button", name="Log in").click()
    page.get_by_label("Username").fill(username)
    page.get_by_label("Password").fill(password)
    page.get_by_role("button", name="Sign in").click()
    member_bar = page.get_by_test_id("member-bar")
    if expect_member:
        expect(member_bar).to_be_visible()
    else:
        expect(member_bar).to_be_hidden()

The ("naruto", "wrong", True) row says a wrong password should show the member bar. It does not, so that ONE row fails with to_be_visible -> Actual value: hidden; the other two rows pass. The one missing piece is changing that row's True to False.

✓ Reference - tests/genin/test_g5_login_rows.py
import pytest
from playwright.sync_api import Page, expect


@pytest.mark.parametrize("username, password, expect_member", [
    ("naruto", "ramen", True),
    ("naruto", "wrong", False),
    ("sasuke", "ramen", False),
])
def test_login_rows(page: Page, username, password, expect_member):
    page.goto("/dojo/app/index.html")
    page.get_by_role("button", name="Log in").click()
    page.get_by_label("Username").fill(username)
    page.get_by_label("Password").fill(password)
    page.get_by_role("button", name="Sign in").click()
    member_bar = page.get_by_test_id("member-bar")
    if expect_member:
        expect(member_bar).to_be_visible()
    else:
        expect(member_bar).to_be_hidden()

Real locators used: the login flow locators ("Log in" app/index.html:89, "Username" :473, "Password" :477, "Sign in" :479) and the member bar at app/index.html:98. The handler that accepts only naruto/ramen is at app/index.html:926-945, which is why the two wrong rows correctly stay hidden.

This is the A6 if/else finally made data-driven: the branch is the same, but now three different inputs really exercise both branches. The if/else is still inside the test; parametrize feeds it real variety. That is the honest payoff promised back in A6.

pytest tests/genin/test_g5_login_rows.py

Expected result: 3 passed.

G6

Parametrize over promo codes (solo rep)

One concept: none brand new; a second parametrize rep over a different data shape (a code in, an exact message out). One skill: none brand new; you reuse the cart -> checkout flow and to_have_text, driven by parametrize rows.

One parametrize was the lesson; this one is the rep, on different data. IchiRamen's checkout form has a promo box. Three codes each show their own success message: RAMEN10, FREESHIP, and NARUTO. Parametrize one test over (code, message) rows: for each code, add a bowl, open the cart, go to checkout, type the code, click Apply, and assert the promo message reads exactly that row's text. Same body, three codes, three checks. This is the everyday use of parametrize: a table of input and expected output, one test that walks it.

PREDICT - G6.1

The rows are (code, message) pairs. For the row ("RAMEN10", "10% discount applied!"), what does the test assert?

Open this file. It ships RED on ONE row: the expected message for FREESHIP is blank. Fill in the exact text and all three rows pass.

tests/genin/test_g6_promo_codes.py
✗ EDIT - tests/genin/test_g6_promo_codes.py: fill the blank row
import pytest
from playwright.sync_api import Page, expect


@pytest.mark.parametrize("code, message", [
    ("RAMEN10", "10% discount applied!"),
    # TODO: FREESHIP shows a different success message. Fill in the exact
    #       text the app shows for FREESHIP (replace the empty string).
    ("FREESHIP", ""),
    ("NARUTO", "$5 discount applied!"),
])
def test_promo_code_message(page: Page, code, message):
    page.goto("/dojo/app/index.html")
    page.get_by_role("button", name="Add Miso Ramen to cart").click()
    page.get_by_role("button", name="Shopping cart").click()
    page.get_by_role("button", name="Checkout").click()
    page.get_by_label("Promo code").fill(code)
    page.get_by_role("button", name="Apply").click()
    expect(page.get_by_test_id("promo-message")).to_have_text(message)

With the FREESHIP row's message blank (""), that ONE row asserts the message is empty, but the app shows Free shipping applied!, so it fails with Actual value: Free shipping applied!; the other two rows pass. The one missing piece is the exact string "Free shipping applied!".

✓ Reference - tests/genin/test_g6_promo_codes.py
import pytest
from playwright.sync_api import Page, expect


@pytest.mark.parametrize("code, message", [
    ("RAMEN10", "10% discount applied!"),
    ("FREESHIP", "Free shipping applied!"),
    ("NARUTO", "$5 discount applied!"),
])
def test_promo_code_message(page: Page, code, message):
    page.goto("/dojo/app/index.html")
    page.get_by_role("button", name="Add Miso Ramen to cart").click()
    page.get_by_role("button", name="Shopping cart").click()
    page.get_by_role("button", name="Checkout").click()
    page.get_by_label("Promo code").fill(code)
    page.get_by_role("button", name="Apply").click()
    expect(page.get_by_test_id("promo-message")).to_have_text(message)

Real locators used: the Miso add button aria-label="Add Miso Ramen to cart" at app/index.html:188; the cart open button aria-label="Shopping cart" at app/index.html:90; the Checkout button <button id="checkoutBtn"> at app/index.html:496 (shown only once the cart has an item); the promo input <input id="promoCode" aria-label="Promo code"> at app/index.html:516; the Apply button <button id="applyPromo"> at app/index.html:517; the promo message <div id="promoMessage" data-testid="promo-message"> at app/index.html:519. The three codes and their exact messages are set by the apply handler at app/index.html:1023-1034.

Why the cart -> checkout steps are needed: the promo box lives inside the checkout form, which is hidden until you open the cart and click Checkout, and Checkout only appears once the cart has an item. So each row adds a bowl, opens the cart, and clicks Checkout before typing the code. These are all actions you already have; parametrize just runs them per row.

pytest tests/genin/test_g6_promo_codes.py

Expected result: 3 passed. This is the final Genin level; ticking it completes the parametrize tail (G5-G6) and the Genin rank.

← Academy - Setup & Locators Next: Chunin Exam (Sealed)