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]] | None

Lifecycle (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 in test_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=[...],
    )
  1. No coupling between infrastructure and POMs. A DB setup has no business knowing about Selenium.
  2. Symmetry with Watcher.callback: same closure-based context injection (the Watcher injects driver, logger, take_screenshot at start() time).
  3. 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]] | None

Watchers 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 see setup or teardown.
  • One thread per watcher.
  • One scoped logger per watcher: (*taxonomy[:-1], "<test_name> - <watcher_name>"). Produces a .log sibling 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):

PropertyConsequence
frozen=TrueCan’t mutate scenario.test_chain = [...] after construction
@finalCan’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 (concatenation pre + scenario.test_chain + post),
  • setup (from scenario.setup),
  • teardown (from scenario.teardown),
  • watchers (from scenario.watchers),
  • skipped (from Test._skipped).

So what’s inside the Scenario is strictly the scenario. The pre/post fragments get bolted around it by spawn.

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:
    ...

See ../04-internal-tests/03-pytest-scenarios.md.