02 โ Playwright for Angular + Streaming & Approval Queues (learn from scratch โ interview-ready)¶
The JD: "Playwright automation for Angular-based UI, covering interaction flows, approval queues, and streaming response handling." That last part is the differentiator โ most candidates can click buttons; few can test a token-by-token streaming agent response and a human-in-the-loop approval queue. This file uses Playwright-Python (matches the Pytest stack); concepts map 1:1 to the TS API.
How to use this file: read top-to-bottom the first time (it builds every concept from zero). Later, jump to Rapid-fire recall to revise. You need basic pytest (01) and to know a web page is HTML the browser draws.
0. What is Playwright, and why does UI testing feel different?¶
Testing a function is easy: call it, check the return value. Testing a web UI is harder โ there's no return value, there's a browser showing a page, and "did it work?" means "does the right thing appear on screen after I click?"
Playwright drives a real browser from code. Your test says "go to this page, type here, click that, now check this text appeared" โ in an actual Chromium/Firefox/WebKit browser.
from playwright.sync_api import expect
def test_homepage_has_title(page): # `page` is a browser tab Playwright gives you
page.goto("http://localhost:4200") # open the app
expect(page).to_have_title("AcmeBank Assistant") # check what's on screen
page is a fixture (see 01 ยง3) from the pytest-playwright plugin โ a fresh browser tab per test.
Install and run¶
pip install pytest-playwright
playwright install chromium # downloads the actual browser
pytest # headless (no visible window) by default
pytest --headed --browser chromium # watch it happen โ great for learning
--headed the first few times so you see the browser do what your code says.
Interview line: "Playwright auto-waits on actionability, which kills the classic Selenium sleep/stale-element flakiness, and its web-first assertions retry until a timeout โ so my Angular tests are stable without manual waits."
1. The one problem that dominates UI testing: timing¶
Web pages are asynchronous: you click, the app calls a server, and the result appears a moment later โ 50ms or 2s. If your test checks immediately, it's not there yet, and the test fails even though the app works.
The naive fix people reach for:
Terrible: 3s is too long when the app is fast (slow suite) and too short when it's slow (flaky). You're guessing.Playwright's headline feature solves this: auto-waiting. Before it clicks/types/reads, it waits for the element to be ready โ attached, visible, stable (not animating), enabled โ and its assertions retry until they pass or time out. So you almost never write a wait yourself.
button.click() # waits until clickable first
expect(page.get_by_text("Success")).to_be_visible() # retries until it appears
sleep. Wait for the thing you expect, and let Playwright retry.
The Angular nuance¶
Angular updates the DOM asynchronously (change detection via Zone.js), and data arrives over HTTP/WebSocket. Auto-wait handles element actionability, but it can't know your app's business readiness (e.g. "the pricing call finished"). So you wait in two layers: element-level is automatic; app-level you wait for the effect (ยง4).
2. Locators โ how you point at things¶
To click a button you must find it. A locator describes an element. Key insight: a locator is lazy โ it doesn't grab the element when created, it re-finds it at action time. That's why it survives re-renders (common in Angular).
Prefer the ways a human/screen-reader would identify things โ they're stable when styling changes:
page.get_by_role("button", name="Approve") # BEST: what it IS + its label
page.get_by_label("Amount") # a form field by its <label>
page.get_by_placeholder("Search transactions") # an input by placeholder
page.get_by_text("Pending review") # by visible text
page.get_by_test_id("approval-row-42") # by data-testid โ rock solid for Angular
.mat-focus-indicator._ngcontent-abc (Angular generates those; they change on recompile and break tests for no real reason). The professional move: push the team to add data-testid and locate by those.
Analogy: role/label/test-id is finding a person by name and job title. CSS class is "third person from the left in yesterday's photo" โ true until anyone moves.
Interview line: "I locate by role/label/test-id, never by Angular's compiled CSS. Locators resolve lazily at action time, so component re-renders don't produce stale references."
3. Assertions: expect(...) retries, plain assert does not¶
A subtle but crucial distinction:
from playwright.sync_api import expect
# โ
web-first assertion: RETRIES for a few seconds until true (or times out)
expect(page.get_by_test_id("quote-amount")).to_have_text("USD 100.00")
# โ plain assert: checks ONCE, right now. If still loading, it fails.
assert page.get_by_test_id("quote-amount").text_content() == "USD 100.00"
expect(locator).to_be_visible()
expect(locator).to_have_text("Approved")
expect(locator).to_contain_text("balance")
expect(locator).to_be_enabled()
expect(locator).to_have_attribute("data-streaming", "false")
assert only for already-settled values (a number you computed), not things still rendering.
4. Waiting for the right thing (element vs app readiness)¶
Auto-wait handles whether an element is clickable. Sometimes you need a business event โ "the pricing API call finished." Two clean ways, both better than sleeping:
Wait on the visible outcome (usually best โ test what the user sees):
page.get_by_role("button", name="Get quote").click()
expect(page.get_by_test_id("quote-amount")).to_have_text("USD 100.00") # the effect
with page.expect_response("**/api/quote") as resp_info: # start listening BEFORE the click
page.get_by_role("button", name="Get quote").click()
assert resp_info.value.ok # response arrived, was 200
expect_response as "run this block, don't continue until a matching response comes back." Never page.wait_for_timeout(3000).
5. Mocking the backend with page.route (deterministic UI tests)¶
Like mocking an LLM in unit tests (01 ยง6), in UI tests you often fake the server's response so the test is fast, predictable, and needs no live backend. page.route intercepts a request and returns what you want:
def test_quote_display(page):
def fake_quote(route):
route.fulfill(status=200, json={"price": 100.0, "currency": "USD"}) # canned
page.route("**/api/quote", fake_quote) # intercept any URL ending /api/quote
page.goto("/pricing")
page.get_by_role("button", name="Get quote").click()
expect(page.get_by_test_id("quote-amount")).to_have_text("USD 100.00")
route.fulfill(status=500) or route.abort() โ assert the UI shows a graceful error, retry, and doesn't hang.
6. Streaming responses (SSE / chunked / WebSocket) โ the hard part¶
An agent UI streams the answer token by token. The naive test reads text once, catches it mid-sentence, and fails. Beginners test three things; the strategies below go from simplest to most deterministic.
a) Assert on the final settled state¶
The response grows then stops. Wait for a completion signal the UI exposes (a "done" state, a disabled stop button, data-streaming="false"), then check content:
page.get_by_role("button", name="Send").click()
msg = page.get_by_test_id("assistant-message-latest")
expect(msg).to_have_attribute("data-streaming", "false", timeout=30_000) # wait until done
expect(msg).to_contain_text("Your balance is") # now check content
b) Assert streaming actually happened (progressive rendering)¶
Sometimes you must prove it streamed, not dumped. Poll for growth:
msg = page.get_by_test_id("assistant-message-latest")
expect(msg).not_to_be_empty(timeout=5_000) # first token arrived quickly (latency)
first = msg.inner_text()
expect(msg).to_contain_text("done marker", timeout=30_000)
assert len(msg.inner_text()) > len(first) # it grew = streamed incrementally
c) Intercept the stream at the network layer¶
For deterministic checks, capture the raw SSE/chunked events:
def handle(response):
if "/api/chat/stream" in response.url:
body = response.text() # SSE lines like: data: {"token": "Hel"}
assert "data:" in body
assert "[DONE]" in body
page.on("response", handle)
frames = []
def on_ws(ws):
ws.on("framereceived", lambda payload: frames.append(payload))
page.on("websocket", on_ws)
# ... trigger, then assert frames contain expected tokens / final event
d) Mock the stream for deterministic UI tests¶
Serve a canned SSE body with page.route so the UI test doesn't depend on a live LLM:
def fake_stream(route):
body = "data: {\"token\":\"Your \"}\n\ndata: {\"token\":\"balance\"}\n\ndata: [DONE]\n\n"
route.fulfill(status=200, headers={"content-type": "text/event-stream"}, body=body)
page.route("**/api/chat/stream", fake_stream)
Interview line: "For streaming I test three things: the first token arrives within a latency budget, content renders progressively (grows over time), and the final settled text is correct โ waiting on a completion signal the UI exposes, never a fixed sleep. For determinism I mock the SSE body with page.route."
7. Approval-queue / human-in-the-loop flows¶
Finance agent platforms pause for a human to approve an action ("release payment"). Test the state machine, not just the click:
def test_approval_queue_flow(page, seed_pending_action):
action_id = seed_pending_action(type="payment_release", amount=5000)
page.goto("/approvals")
row = page.get_by_test_id(f"approval-row-{action_id}")
expect(row.get_by_test_id("status")).to_have_text("Pending")
row.get_by_role("button", name="Approve").click()
page.get_by_role("dialog").get_by_role("button", name="Confirm").click()
# UI transition
expect(row.get_by_test_id("status")).to_have_text("Approved")
# Senior move: assert BEYOND the UI โ the backend + audit actually changed (file 12)
assert get_action(action_id)["state"] == "approved"
assert audit_has_event(action_id, "APPROVAL_GRANTED", actor="qa_user")
Two contexts for concurrency:
def test_double_approval(browser, seed_pending_action):
a = browser.new_context(storage_state="approver1.json")
b = browser.new_context(storage_state="approver2.json")
pa, pb = a.new_page(), b.new_page()
# both open; a approves; b's attempt should be blocked/stale
8. Auth once, reuse everywhere (storage_state)¶
Logging in through the UI before every test is slow. Log in once, save the session, reuse it:
# global setup: log in, save cookies/localStorage
context.storage_state(path="auth.json")
@pytest.fixture
def logged_in(browser):
ctx = browser.new_context(storage_state="auth.json") # start already logged in
yield ctx.new_page()
ctx.close()
approver.json, viewer.json) to test permission differences fast.
9. Page Object Model (structure)¶
Put a page's locators/actions in a class so tests read like sentences:
class ApprovalsPage:
def __init__(self, page): self.page = page
def goto(self): self.page.goto("/approvals"); return self
def row(self, id): return self.page.get_by_test_id(f"approval-row-{id}")
def approve(self, id):
self.row(id).get_by_role("button", name="Approve").click()
self.page.get_by_role("dialog").get_by_role("button", name="Confirm").click()
10. When a test fails: debugging artefacts¶
UI failures are hard to read from a log line. Record a trace โ a full timeline with DOM snapshot, screenshot, network and console per step โ and open it after a failure:
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--tracing=retain-on-failure --screenshot=only-on-failure --video=retain-on-failure"
--headed --debug opens the Inspector, and page.pause() freezes the browser to poke around.
11. Try it yourself (cements the core loop)¶
Point this at a page with a search box (or your app's login):
from playwright.sync_api import expect
def test_search_flow(page):
page.goto("http://localhost:4200")
page.get_by_placeholder("Search transactions").fill("coffee") # find by role/label, not CSS
page.get_by_role("button", name="Search").click()
expect(page.get_by_test_id("results")).to_contain_text("coffee") # wait on OUTCOME, no sleep
# empty-state handling too
page.get_by_placeholder("Search transactions").fill("zzzznomatch")
page.get_by_role("button", name="Search").click()
expect(page.get_by_test_id("results")).to_contain_text("No results")
pytest --headed -v and watch it. You just used locators, auto-wait, and web-first assertions โ the everyday core.
Rapid-fire recall¶
- Playwright drives a real browser;
pageis a fixture giving you a tab. - Never
sleepโ auto-wait + retryingexpect(...)handle timing (Angular's Zone.js makes this essential). - Locate by role/label/test-id, never Angular's generated CSS; locators are lazy (re-render safe).
expect(...).to_...retries; plainassertchecks once.- Wait on the outcome or the network (
expect_response), not a timer. page.route= deterministic mocking, including the SSE body and error UX.- Streaming: first-token latency + progressive growth + final settled text; wait on a completion signal.
- Approval queue = test the state machine + audit + authz + concurrency, not just the click.
storage_state= log in once (per role); Trace Viewer = debug failures like a video.