Skip to main content

Performing Hand Seals...

SCROLLS ▸ ACADEMY - SETUP & LOCATORS (A1-A8)
01

Academy - Setup & Locators

Academy Student → Academy Graduate - Levels A1-A8

You learn by doing, not reading. Each level below is one small idea: read the short concept, predict the answer, then switch to your IDE and make a real failing test pass against the live IchiRamen app. When your terminal shows 1 passed, come back and tick the level to unlock the next one.

ONE-TIME SETUP

Clone the practice repo, install the toolchain, and download the browser. You do this once, in level A1.

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. Pinned toolchain: playwright 1.60.0, pytest 8.4.2 (needs Python 3.11+).

A1

Arming: run your first test

One concept: running a test file and reading pass/fail. One skill: the page fixture + page.goto + a first expect(page) check.

This level arms your toolkit once. You cloned the repo, installed Playwright and pytest, and downloaded a browser. Now prove it works. A test is a function whose name starts with test_. Ask for the browser by putting page in the parentheses. Use page.goto(...) to open IchiRamen, then expect(page).to_have_title(...) to check the tab title. Run it with pytest. Green means your hands and eyes reach the live app. You will not repeat this setup again; every later level just adds one new idea on top of this same shape.

PREDICT - A1.1

pytest decides what to run by looking at the function name. Which one will it run?

In your IDE, open this file and make it pass by adding the one missing line:

tests/academy/test_a1_setup.py
✗ Starter - fails on one missing line
from playwright.sync_api import Page, expect

def test_app_loads(page: Page):
    # TODO: open IchiRamen. Use page.goto with the app's relative path.
    expect(page).to_have_title(
        "Playwright Practice App | IchiRamen - Automate a Real Web App"
    )

Without the goto, page is on about:blank, the title is empty, and the assertion fails. One line fixes it.

✓ Reference solution
from playwright.sync_api import Page, expect

def test_app_loads(page: Page):
    page.goto("/dojo/app/index.html")
    expect(page).to_have_title(
        "Playwright Practice App | IchiRamen - Automate a Real Web App"
    )

Real fact used: the page <title> at app/index.html:6.

pytest tests/academy/test_a1_setup.py

Expected result: 1 passed.

A2

Point at an element and check it shows

One concept: a locator is a description you store in a variable. One skill: a locator + expect(locator).to_be_visible() (auto-waiting).

A locator is how you point at one element. page.get_by_role("heading", name="...") returns a locator; you can store it in a variable. A locator does not click or read yet; it just describes "the element that matches this." To check it, use a web-first assertion: expect(heading).to_be_visible(). This is the assertion you will reach for most. It auto-waits: if the page is still settling, the check retries for you until the element shows or it times out. No sleeps. Here you confirm the menu heading "Tonight's Bowls" is on screen after the page loads.

PREDICT - A2.1

heading = page.get_by_role("heading", name="Tonight's Bowls"). After this line, has anything been checked on the page yet?

PREDICT - A2.2

The heading appears a fraction of a second after load. With expect(...).to_be_visible(), what do you add to wait for it?

Fix the one wrong name so the locator describes the real heading:

tests/academy/test_a2_heading.py
✗ Starter - fails on one missing line
from playwright.sync_api import Page, expect

def test_menu_heading_visible(page: Page):
    page.goto("/dojo/app/index.html")
    # TODO: fix the name below so `heading` describes the real heading
    #       that reads "Tonight's Bowls". "REPLACE ME" matches nothing.
    heading = page.get_by_role("heading", name="REPLACE ME")
    expect(heading).to_be_visible()

The starter has a real locator shape; only its name is a wrong placeholder, so it describes an element that does not exist. expect(heading).to_be_visible() then auto-waits and fails with "Locator expected to be visible / Actual value: <element(s) not found>", naming the locator it could not find. Fix the one name string and it passes.

✓ Reference solution
from playwright.sync_api import Page, expect

def test_menu_heading_visible(page: Page):
    page.goto("/dojo/app/index.html")
    heading = page.get_by_role("heading", name="Tonight's Bowls")
    expect(heading).to_be_visible()

Real locator used: the <h1 class="ry-title">Tonight's Bowls</h1> at app/index.html:115. An h1 is a heading role, so get_by_role("heading", name="Tonight's Bowls") matches it.

pytest tests/academy/test_a2_heading.py

Expected result: 1 passed.

A3

Do an action, then check the result

One concept: passing a string as an argument - the accessible name that identifies the button. One skill: .click(), an action on a locator, then assert the resulting state.

So far you only looked. Now act. A locator can do things: .click() clicks it. The string you pass to get_by_role, like "Shopping cart", is the element's accessible name - the label a screen reader would read. Click the cart button, then check the result: the cart panel opens and shows "Your cart is empty." A good test always pairs an action with a check of what that action caused. Click opens the cart; the assertion proves it really opened empty. Action without a check proves nothing, so we always finish with expect.

PREDICT - A3.1

You click the cart button on a fresh page load. What should the assertion confirm?

In your IDE, open this file and make it pass by adding the one missing line:

tests/academy/test_a3_cart.py
✗ Starter - fails on one missing line
from playwright.sync_api import Page, expect

def test_cart_starts_empty(page: Page):
    page.goto("/dojo/app/index.html")
    # TODO: click the cart button (its accessible name is "Shopping cart")
    expect(page.get_by_text("Your cart is empty.")).to_be_visible()

Without the click, the cart panel never opens, so the empty-cart text stays hidden and the assertion times out. One click line fixes it.

✓ Reference solution
from playwright.sync_api import Page, expect

def test_cart_starts_empty(page: Page):
    page.goto("/dojo/app/index.html")
    page.get_by_role("button", name="Shopping cart").click()
    expect(page.get_by_text("Your cart is empty.")).to_be_visible()

Real locators used: the cart button aria-label="Shopping cart" at app/index.html:90; the empty-cart copy <p class="ry-cart-empty">Your cart is empty.</p> at app/index.html:490.

pytest tests/academy/test_a3_cart.py

Expected result: 1 passed.

A4

Type into a field (solo rep)

One concept: nothing new - the Python here (a function with the page parameter, method calls, string arguments) is all from A1-A3. One skill: .fill() to type into an input, and .to_be_hidden(), the negative of to_be_visible().

Time to use what you have on something new. .fill("miso") types into an input the same way .click() clicks - a locator, then an action. IchiRamen's search filters the menu as you type. So fill the search box with "miso", then check two things: Miso Ramen is visible, and Tonkotsu is now hidden. to_be_hidden() is just the opposite of to_be_visible() - it asserts an element is gone or not shown. Checking both a positive and a negative makes the test honest: the right bowl stayed and the wrong one left. You now have the full beginner loop: open, point, act, check.

PREDICT - A4.1

You fill the search box with "miso". For Tonkotsu, which assertion is correct?

In your IDE, open this file and make it pass by adding the one missing line:

tests/academy/test_a4_search.py
✗ Starter - fails on one missing line
from playwright.sync_api import Page, expect

def test_search_filters_to_miso(page: Page):
    page.goto("/dojo/app/index.html")
    # TODO: type "miso" into the search box so only Miso Ramen remains
    expect(page.get_by_role("heading", name="Miso Ramen")).to_be_visible()
    expect(page.get_by_role("heading", name="Tonkotsu")).to_be_hidden()

Without the fill, Tonkotsu stays on screen and to_be_hidden() fails with a clear "expected hidden, actual visible" message. One fill line fixes it.

✓ Reference solution
from playwright.sync_api import Page, expect

def test_search_filters_to_miso(page: Page):
    page.goto("/dojo/app/index.html")
    page.get_by_role("searchbox", name="Search menu").fill("miso")
    expect(page.get_by_role("heading", name="Miso Ramen")).to_be_visible()
    expect(page.get_by_role("heading", name="Tonkotsu")).to_be_hidden()

Real locators used: the search input aria-label="Search menu" at app/index.html:54; the Miso heading at app/index.html:160; the Tonkotsu heading at app/index.html:222.

pytest tests/academy/test_a4_search.py

Expected result: 1 passed.

A5

Make your own helper and build a string

One concept: writing your own function with def, and building a string with an f-string. One skill: reading an element's text with the web-first assertion expect(locator).to_have_text(...).

So far every test was one flat function. Now write a small helper of your own with def. A helper is just a named block you can call, so you do not repeat the same locator twice. You also need to build text. An f-string starts with f and lets you drop values inside {}: f"${13.50:.2f}" becomes "$13.50". The :.2f keeps two decimals, which is how prices show. Then check the real card text with a new web-first assertion: expect(price).to_have_text("$13.50"). Like to_be_visible, it auto-waits. You confirm Miso Ramen's price reads exactly what you built.

PREDICT - A5.1

What does f"${13.5:.2f}" produce?

PREDICT - A5.2

to_have_text checks the full text of the element. If the card showed $13.50 but you built $13.5, what happens?

The price_locator helper is provided so you see a def you call; your one job is to build the expected price string with an f-string:

tests/academy/test_a5_price.py
✗ Starter - fails on one missing line
from playwright.sync_api import Page, expect


def price_locator(page, dish_name):
    card = page.get_by_role("listitem").filter(has_text=dish_name)
    return card.locator(".ry-ramen-price")


def test_miso_price_shown(page: Page):
    page.goto("/dojo/app/index.html")
    dish = "Miso Ramen"
    # TODO: build the expected price string with an f-string.
    #       Miso Ramen costs 13.50 dollars. The card shows it as "$13.50".
    #       Replace the empty string below using an f-string.
    expected = ""
    expect(price_locator(page, dish)).to_have_text(expected)

With expected = "", to_have_text("") auto-waits and then fails with Actual value: $13.50, because the card text is not empty. The one missing line is the f-string.

✓ Reference solution
from playwright.sync_api import Page, expect


def price_locator(page, dish_name):
    card = page.get_by_role("listitem").filter(has_text=dish_name)
    return card.locator(".ry-ramen-price")


def test_miso_price_shown(page: Page):
    page.goto("/dojo/app/index.html")
    dish = "Miso Ramen"
    expected = f"${13.50:.2f}"
    expect(price_locator(page, dish)).to_have_text(expected)

Real locators used: the Miso Ramen card <li role="listitem" aria-label="Miso Ramen"> at app/index.html:152, and its price element <span class="ry-ramen-price">$13.50</span> at app/index.html:187.

Note on .locator(".ry-ramen-price"): this is a CSS selector, the first one you meet. It is introduced inside a provided helper you only call, so it is not a new concept you must author. The card itself is reached with get_by_role + filter(has_text=...), both role/text tools from A2-A4. The CSS hop only narrows to the price span inside the already-found card.

pytest tests/academy/test_a5_price.py

Expected result: 1 passed.

A6

Branch on a condition with if/else

One concept: if/else - run one block when a condition is true, another when it is false. One skill: a multi-step login flow (click, fill, fill, submit), then a conditional check on the member bar.

Real apps behave differently depending on input. if/else lets a test say "if this is true, check one thing; otherwise check another." Here you log in as TWO different people and let one if/else decide the right check for each. The member bar only appears for a valid member. So: if the username is the real member "naruto", assert the bar is visible; otherwise assert it stays hidden. The login flow (click "Log in", fill username and password, click "Sign in") is wrapped in a small helper check_member_bar(...) so you do not repeat it - it uses .click() and .fill() from A1-A4. The valid credentials are username naruto, password ramen. Your one job is to write the if condition that decides which assertion runs. Because you call the helper once with a wrong username and once with the real one, BOTH branches actually run.

New locators in this level (first use): two new locators appear in this test, get_by_label("Username") and get_by_test_id("member-bar"). They are siblings of the get_by_role you already use, the same get_by_* family and shape: get_by_label finds a field by its form label, and get_by_test_id finds an element by its data-testid. You do not author them here (they ship in the starter); just recognize them as the label and test-id members of the same locator family.

PREDICT - A6.1

The helper is called twice: once with "sasuke" (wrong user), once with "naruto" (the real member). With the condition username == "naruto", which branch runs each time?

PREDICT - A6.2

The member bar starts with style="display:none;" and only shows after a valid login. If you skipped the login steps entirely and asserted to_be_visible(), what happens?

The login helper is provided; your one job is to write the if condition:

tests/academy/test_a6_login.py
✗ Starter - fails on one missing line
from playwright.sync_api import Page, expect


def check_member_bar(page, username, password):
    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()
    # TODO: a real member (username "naruto") should see the member bar;
    #       anyone else should not. Fill in the condition for the if.
    if False:
        expect(page.get_by_test_id("member-bar")).to_be_visible()
    else:
        expect(page.get_by_test_id("member-bar")).to_be_hidden()


def test_member_bar_reflects_who_logged_in(page: Page):
    # wrong user first (no session cookie set yet), then the real member
    check_member_bar(page, "sasuke", "ramen")   # else-branch: bar hidden
    check_member_bar(page, "naruto", "ramen")   # if-branch: bar visible

With if False, Python always runs the else and asserts the bar is hidden. That happens to be correct for sasuke (a wrong login shows no bar), so the first call passes - but the naruto call also runs the else and asserts hidden, while the bar IS visible, so to_be_hidden() fails. The verbatim runner message is Locator expected to be hidden / Actual value: visible. The one missing line is the real condition if username == "naruto":, which sends each call to the correct branch.

✓ Reference solution
from playwright.sync_api import Page, expect


def check_member_bar(page, username, password):
    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()
    if username == "naruto":
        expect(page.get_by_test_id("member-bar")).to_be_visible()
    else:
        expect(page.get_by_test_id("member-bar")).to_be_hidden()


def test_member_bar_reflects_who_logged_in(page: Page):
    # wrong user first (no session cookie set yet), then the real member
    check_member_bar(page, "sasuke", "ramen")   # else-branch: bar hidden
    check_member_bar(page, "naruto", "ramen")   # if-branch: bar visible

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

Why call the helper twice, wrong user first: both branches run for real - the sasuke call takes the else (bar hidden), the naruto call takes the if (bar visible). The wrong user goes first on purpose: a successful naruto login stores a session cookie the page would restore on the next reload, which would leak into the second scenario. Running sasuke before any cookie exists keeps the two clean.

pytest tests/academy/test_a6_login.py

Expected result: 1 passed.

A7

Count matches, and meet plain assert

One concept: counting - .count() returns a number, and you check it with plain Python assert. One skill: .count() on a locator that matches several elements.

Some locators match more than one element. page.get_by_role("button", name="Customize") matches every "Customize" button, one per menu bowl. .count() returns how many: a plain number. To check a number, use Python's own keyword: assert count == 4. This is different from expect. Use expect(locator)... for anything ON the page (visible, text, hidden) because it auto-waits. Use plain assert for plain Python values you already hold, like a number from .count(). assert does NOT auto-wait, so only use it on a value, never directly on UI state. Confirm there are 4 customizable bowls.

Why name="Customize" matches all four: name= does a SUBSTRING match against the element's ACCESSIBLE name, not an exact match against its visible text. Each Customize button shows the text "Customize" but carries aria-label="Customize Miso Ramen" (and one per bowl), and an aria-label OVERRIDES the visible text, so the accessible name is the full label like "Customize Miso Ramen". The substring "Customize" is inside all four, so all four match. If you wrote name="Customize", exact=True you would get 0, because no button's accessible name is exactly "Customize". Takeaway: name= is a substring match on the accessible name, which here is the aria-label.

PREDICT - A7.1

buttons = page.get_by_role("button", name="Customize"). What does buttons.count() give back?

PREDICT - A7.2

Why is assert count == 4 the right check here, instead of expect(...)?

In your IDE, open this file and make it pass by adding the one missing line:

tests/academy/test_a7_count.py
✗ Starter - fails on one missing line
from playwright.sync_api import Page


def test_four_customizable_bowls(page: Page):
    page.goto("/dojo/app/index.html")
    customize_buttons = page.get_by_role("button", name="Customize")
    # TODO: read how many elements this locator matches.
    #       Use .count() on the locator.
    count = 0
    assert count == 4

With count = 0 hardcoded, assert 0 == 4 fails immediately with assert 0 == 4. The one missing line is count = customize_buttons.count(), which reads the real number (4) and makes the assert pass. Note this starter fails fast with no wait, which is itself the lesson: plain assert does not retry.

✓ Reference solution
from playwright.sync_api import Page


def test_four_customizable_bowls(page: Page):
    page.goto("/dojo/app/index.html")
    customize_buttons = page.get_by_role("button", name="Customize")
    count = customize_buttons.count()
    assert count == 4

Real locators used: the four "Customize" buttons, one per menu card. Each renders the visible text "Customize" but carries an aria-label that becomes its accessible name: aria-label="Customize Miso Ramen" at app/index.html:191 (Miso), and the matching Customize buttons for Tonkotsu (:253), Shoyu (:313), and Shio (:375). Because name="Customize" substring-matches the accessible name, all four match; the Today's Special card and the similar-items cards have none, so the count is exactly 4.

Note on import: A7 drops expect from the import line because this test uses only plain assert, no web-first assertion. That is deliberate so you see that assert is core Python and needs no Playwright import.

pytest tests/academy/test_a7_count.py

Expected result: 1 passed.

A8

Loop over a list (solo capstone)

One concept: a list of strings and a for loop that visits each one. One skill: none brand new - it reuses .click() (A3), f-strings (A5), and to_have_text (A5) inside a loop.

A list holds several values: ["Miso Ramen", "Tonkotsu", "Shoyu"]. A for loop runs the same steps for each one: for bowl in bowls: gives you each name in turn. Inside the loop, click that bowl's add button, built with an f-string: f"Add {bowl} to cart". After the loop has added three bowls, check the cart counter shows "3" with to_have_text. This is the whole beginner loop applied at scale: one list, one loop, three actions, one final check. No new tool, just the ones you have used combined. That is what most real tests look like.

PREDICT - A8.1

The list has three names and the loop clicks one add button per name. After the loop, what should get_by_test_id("cart-count") read?

PREDICT - A8.2

Inside the loop, f"Add {bowl} to cart" with bowl = "Tonkotsu" builds which button name?

This is the Academy capstone. Open this file and add the missing loop:

tests/academy/test_a8_loop.py
✗ Starter - fails on one missing line block
from playwright.sync_api import Page, expect


def test_adding_three_bowls_updates_count(page: Page):
    page.goto("/dojo/app/index.html")
    bowls = ["Miso Ramen", "Tonkotsu", "Shoyu"]
    # TODO: loop over each bowl name in the list and click its
    #       "Add <bowl> to cart" button. Use a for loop.
    expect(page.get_by_test_id("cart-count")).to_have_text("3")

With no loop, nothing is added, so the cart count stays "0" and to_have_text("3") auto-waits then fails with Actual value: 0. The missing piece is the two-line loop. We allow a two-line for body here (the loop header plus its single click) because a for loop cannot be one line without hiding the very concept the level teaches; it is still a single logical block restoring one idea, honoring the one-missing-line rule in spirit.

✓ Reference solution
from playwright.sync_api import Page, expect


def test_adding_three_bowls_updates_count(page: Page):
    page.goto("/dojo/app/index.html")
    bowls = ["Miso Ramen", "Tonkotsu", "Shoyu"]
    for bowl in bowls:
        page.get_by_role("button", name=f"Add {bowl} to cart").click()
    expect(page.get_by_test_id("cart-count")).to_have_text("3")

Real locators used: the menu add buttons, whose accessible names are aria-label="Add Miso Ramen to cart" at app/index.html:188, aria-label="Add Tonkotsu to cart" at app/index.html:250, and aria-label="Add Shoyu to cart" at app/index.html:310; the cart counter <span data-testid="cart-count"> at app/index.html:91. The list uses the three plainest bowl names so the f-string f"Add {bowl} to cart" matches each add button's accessible name exactly.

pytest tests/academy/test_a8_loop.py

Expected result: 1 passed. This is the final Academy level; ticking it completes rank-02 "Academy Graduate" and unlocks Genin.

Next: Genin - Page Object Models →