Genin - Page Objects
Genin - the Page Object pivot - Levels G1-G6You 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.
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+).
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.
member = {"username": "naruto", "password": "ramen"}. What does member["password"] return?
member["password"] looks up the value stored under "password", which is "ramen". The key picks the value; it does not return the key or the whole bag.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
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".
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.
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.
When you write login = LoginPage(page), what runs?
__init__ immediately, passing in the argument you gave (page). That is where setup happens. You do not call __init__ by name; Python calls it for you when you build the object.__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?
page. Python fills in self for you - it is the object being built. That is why __init__(self, page) is called as LoginPage(page), not LoginPage(self, page). Forgetting the page parameter is the classic first-class bug. (Note: self shows up here only as that first __init__ parameter. Storing things ON self - self.x - and why every method takes self is the next level, G3.)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
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
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.
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.
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.
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?
self.submit, so every method reaches it the same way: self.submit. A bare submit is not defined inside the method, and page.submit is not a thing. The self. prefix is what connects the method back to what __init__ stored.If __init__ did self.submit = None instead of storing the real locator, what happens when login runs self.submit.click()?
None has no .click(), so Python errors the moment the method tries to use it. The error names the missing attribute, pointing straight at the self. assignment that was never made. A missing self.x does not fail silently; it fails where the method tries to reuse it.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
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
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.
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.
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.
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__:
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.
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.
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?
self.cart_count. The add button depends on bowl_name, which changes per call, so it is built inside the method from self.page. Storing the page on self is what lets every method build new locators on demand.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?
LoginPage and MenuPage; the test only calls their methods and reads their attributes. If IchiRamen renames the add button, you fix MenuPage once and every test keeps working.You edit two page-object files (Additions 1 and 2); the capstone test is given and read-only.
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")
# 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
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
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.
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()
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.
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.
The decorator lists three rows. How many times does pytest run the test function?
test_login_rows[naruto-wrong-False]). One failing row is named and isolated; the others still pass. That is why parametrize beats copying the test three times.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?
expect_member. Here it is False, so the else branch asserts the member bar is hidden - correct, because a wrong password does not log you in. The row's data drives which assertion is right.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
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.
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.
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.
The rows are (code, message) pairs. For the row ("RAMEN10", "10% discount applied!"), what does the test assert?
"RAMEN10", clicks Apply, and asserts the promo message reads "10% discount applied!". The row's second value is the expected output for its first value.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
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!".
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.