02.06 — Scenario[Driver]#
Source file:
src/ocarina/custom_types/scenario.py
Dataclass#
@final
@dataclass(frozen=True)
class Scenario[Driver]:
test_chain: TestChain
setup: TestSetup = field(default=None)
teardown: TestTeardown = field(default=None)
watchers: TestWatchers[Driver] | None = field(default=None)# src/ocarina/custom_types/test_components.py
type TestChain = Sequence[ChainRunner[Any]]
type TestSetup = Effect | None
type TestTeardown = Effect | None
type TestWatchers[Driver] = Sequence[Watcher[Driver]] | NoneLifecycle (per attempt)#
1. setup() — optional, free-form Effect (DB, API, …)
→ raises : test_chain skipped, jump to teardown,
return Outcome(setup_failed=True, should_retry=True)
→ ok : continue to test_chain
2. test_chain — the actual chain (Sequence[ChainRunner])
(watchers run during this time)
3. teardown() — optional, always executed
→ raises : log warning + ignore (does not affect the verdict)
If EVERY attempt raises during setup:
→ test marked SKIPPED (not FAILED)
→ warning log « setup keeps failing »setup and teardown#
setup and teardown are driver-free and injection-free by design.
Documented in the module’s docstring:
They are plain
Effects —() -> None. They are meant for infrastructure concerns: seeding a database, calling an API, cleaning up state. Selenium belongs intest_chain. Whatever context they need (logger, driver, config) must be captured in the closure at scenario construction time.
def my_scenario(driver: WebDriver, logger: ILogger) -> Scenario[WebDriver]:
return Scenario(
setup=lambda: seed_test_user(logger=logger), # logger captured
teardown=lambda: delete_test_user(logger=logger), # logger captured
test_chain=[...],
)- No coupling between infrastructure and POMs. A DB setup has no business knowing about Selenium.
- Symmetry with
Watcher.callback: same closure-based context injection (theWatcherinjectsdriver,logger,take_screenshotatstart()time). - Testability: setup tests run in isolation, no Selenium wrapper needed.
test_chain#
type TestChain = Sequence[ChainRunner[Any]]Exactly what you build by returning [drive_page(...), drive_page(...), match_page(...), drive_page(...)]. Each element runs sequentially via _run_chain (see 05-orchestration/02-test-executor.md).
Subtle point: ChainRunner[Any] (not ChainRunner[TPOM]). A test_chain can mix drive_page calls on different pages (HomePage, LoginPage, DashboardPage…) and match_page calls (which return ChainRunner[Any]). Any is the catch-all that makes a heterogeneous sequence typable.
watchers#
type TestWatchers[Driver] = Sequence[Watcher[Driver]] | NoneWatchers are daemon threads watching the browser in parallel with the chain and reporting frictions via watcher.report(...). See 07-watcher.md.
- Started just before the chain, stopped just after. Strictly scoped to
test_chain— they never seesetuporteardown. - One thread per watcher.
- One scoped logger per watcher:
(*taxonomy[:-1], "<test_name> - <watcher_name>"). Produces a.logsibling to the test’s.
Example#
from ocarina.custom_types.scenario import Scenario
def my_scenario(driver: WebDriver, logger: ILogger) -> Scenario[WebDriver]:
page = MyPage(driver=driver)
return Scenario(
setup=lambda: seed_test_user(logger=logger),
test_chain=[
drive_page(
act(page, open_page)
.failure(log_error("Failed to open..."))
.success(log_success("Opened!")),
),
drive_page(
act(page, verify_page)
.failure(log_error("Failed to verify..."))
.success(log_success("Verified!")),
),
],
teardown=lambda: delete_test_user(logger=logger),
watchers=[
MyWatcher(...),
],
)Why frozen=True and @final#
@final + @dataclass(frozen=True):
| Property | Consequence |
|---|---|
frozen=True | Can’t mutate scenario.test_chain = [...] after construction |
@final | Can’t subclass (class MyScenario(Scenario[WebDriver])) |
| Implicit kwargs-based construction (dataclass) | Stable API |
A Scenario is a value.
TestRunner#
Test.spawn(driver, logger) returns a TestRunner that aggregates:
chain_runners(concatenationpre + scenario.test_chain + post),setup(fromscenario.setup),teardown(fromscenario.teardown),watchers(fromscenario.watchers),skipped(fromTest._skipped).
So what’s inside the Scenario is strictly the scenario. The pre/post fragments get bolted around it by spawn.
Related test#
tests/scenarios/test_test_suite.py contains a test that goes through a scenario with setup + teardown:
@allure.title("setup that fails on first attempt, succeeds on retry, then test passes")
def test_setup_retries_then_test_passes() -> None:
...