02.03.07 — match_page / when#
Fichier source :
src/ocarina/dsl/testing_with_railway/match_page.pyAjouté après coup au framework. Le Holy Book précise : «
match_pageetwhenont été ajoutés après coup, l’Igoristan était tellement aléatoire que le cas d’usage s’est imposé de lui-même. Leur implémentation a été simple, preuve de la flexibilité de la grammaire : d’autres structures analogues pourraient très bien suivre. »
Le problème#
Certaines pages d’une application peuvent être rendues différemment :
- Bannière de cookies présente ou non.
- A/B test renvoyant deux variantes d’UI.
- Mode dégradé (page de maintenance) au lieu de la page normale.
- Captcha anti-bot.
- …
Le scénario doit pouvoir se brancher sans casser la chaîne ROP.
API utilisateur#
match_page(
branches=[
when(check_that_page.has_cookies_banner,
name="Has cookies banner",
then=[drive_page(act(on_homepage, confirm_cookie_banner)
.failure(...)
.success(...))]),
when(check_that_page.has_not_cookies_banner,
name="Has NOT cookies banner",
then=[]),
],
)when(condition, then=..., name=...): déclare une branche.conditionest unThunk[bool](une fonction sans argument qui renvoieTrue/False). Évaluée au runtime.thenest uneTestChain(i.e. une liste deChainRunner).nameest utilisé pour le logging.
Bonne pratique : ne pas utiliser une lambda
not has_X(). On déclare deux matchers séparés (has_cookies_banner/has_not_cookies_banner) pour optimiser les délais : ce sont deux cas distincts, avec potentiellement deux timeouts différents.
Code : When#
@final
@dataclass(frozen=True)
class When:
condition: Thunk[bool]
then: TestChain
name: str = ""
when = When # alias d'appelLe when = When est une astuce élégante : le constructeur de dataclass devient une « fonction » qu’on peut appeler en when(cond, then=[...], name="..."), c’est When(...), mais c’est aussi when(...).
Politique d’exceptions#
- Exceptions listed in
raise_exceptionsare re-raised immediately.- All other exceptions raised by
branch.condition()are interpreted as a non-matching condition (False).
Mécanique : si condition() lève une WebDriverException (par exemple parce que la page est gelée), on peut vouloir :
- L’interpréter comme « cette branche ne matche pas » → on essaie la suivante (politique permissive).
- La faire remonter pour que le rejeu prenne le relais (politique stricte).
Le choix le plus rationnel est de passer raise_exception=transient_errors à create_match_page.
Code : _match_page_builder#
def _match_page_builder(*, raised_exceptions: tuple[type[BaseException], ...] = ()):
def _match_page(logger: ILogger, branches: Sequence[When]) -> ChainRunner[Any]:
def _thunk() -> ActionChain[Any]:
for index, branch in enumerate(branches):
label = branch.name or f"branch[{index}]"
try:
matched = branch.condition()
logger.debug(f"match_page: '{label}' -> {matched}")
except raised_exceptions as exc:
logger.exception("match_page: raising exception...", exc=exc)
raise
except Exception as exc:
logger.exception(f"match_page: '{label}' raised", exc=exc)
matched = False
if matched:
logger.info(f"match_page: '{label}' matched.")
return _run_branch(branch.then)
return ActionChain(
has_failed=True,
result=Fail(error=NoMatchingBranchError(
f"No when() branch matched out of {len(branches)} candidate(s)."
)),
)
return ChainRunner(thunk=_thunk)
return _match_page- Évaluation séquentielle, first-match wins : la première branche
Trueest exécutée, les autres sont ignorées. - Logging à trois niveaux :
debugpour le check,infopour le match,exceptionpour le raise. - Le
try/exceptest ordonné : on attrape d’abord les exceptions explicitement re-raised. Python évalue lesexceptdans l’ordre. - Pas de match →
Fail(NoMatchingBranchError(...)). Le scénario passe sur le rail d’échec. C’est cohérent avec la grammaire ROP.
_run_branch#
def _run_branch(runners: TestChain) -> ActionChain[Any]:
last: ActionChain[Any] | None = None
for runner in runners:
chain = runner.run()
last = chain
if chain.has_failed():
return chain
if last is None:
return ActionChain(has_failed=False, result=Ok(value=None))
return last- Si aucun runner : on retourne
Ok(None)(branche vide → succès trivial). - Si un runner fail : court-circuit immédiat, on retourne la chain.
- Sinon : on retourne le dernier
ActionChain(succès).
create_match_page#
def create_match_page(*, raised_exceptions: tuple[type[Exception], ...] = ()):
def _match_page(*, logger: ILogger | None = None, branches: Sequence[When]):
_logger = MutedLogger() if logger is None else logger
return _match_page_builder(raised_exceptions=raised_exceptions)(
logger=_logger, branches=branches,
)
return _match_pagePattern : factory de factory (woop woop!). Le projet utilisateur appelle create_match_page(raised_exceptions=transient_errors) une seule fois et stocke le résultat (match_page). Toutes les utilisations dans les scénarios partagent la même politique d’exceptions.
Détail : si logger n’est pas fourni, on utilise un MutedLogger. Null Object Pattern, pas de bruit dans la sortie.
Convention#
# lib/ext/ocarina/adapters/agnostic/match_page.py
from ocarina.dsl.testing_with_railway.match_page import create_match_page
from constants.sys.transient_errors import transient_errors
match_page = create_match_page(raised_exceptions=transient_errors)→ Toute exception « transitoire » (page d’erreur HTTP, page de vérification absente, etc.) remonte plutôt que d’être avalée comme « non match ». Sans ce câblage, un test qui devrait se rejouer ne se rejouerait pas.
Schéma d’exécution complet#
match_page(branches=[
when(cond_A, name="a", then=[runner_A1, runner_A2]),
when(cond_B, name="b", then=[runner_B1]),
])
▼
ChainRunner(thunk=_thunk)
└─ .run()
│
▼
for branch in branches :
┌─ try : matched = branch.condition()
│ log.debug(f"match_page: 'a' -> True/False")
├─ except raised_exceptions : log + RE-RAISE (transient_error → test rejoué)
├─ except Exception : log + matched = False
│
└─ if matched :
log.info(f"match_page: 'a' matched.")
return _run_branch(branch.then)
│
▼
for runner in then :
chain = runner.run()
if chain.has_failed() : return chain (court-circuit)
return last_chain or Ok(None)
(aucune branche matched)
return ActionChain(has_failed=True,
result=Fail(NoMatchingBranchError(...)))La spec du Holy Book#
Extrait de la page Premiers scénarios :
match_pagese pose au même niveau quedrive_pageet est chaînable. Sa commandethenattend à nouveau une chaîne dedrive_pageoumatch_page. Les branches sont définies parwhen.
Et :
match_pageetwhenont été ajoutés après coup, l’Igoristan était tellement aléatoire que le cas d’usage s’est imposé de lui-même. Leur implémentation a été simple, preuve de la flexibilité de la grammaire : d’autres structures analogues pourraient très bien suivre.
La preuve de la flexibilité est importante : c’est l’argument méta du framework. Le DSL est extensible par composition (« retourner un ChainRunner ») sans toucher au framework. Voir ../../11-independence/01-sovereign-grammar.md
Recommandations issues du Holy Book#
Un matcher vérifie de manière minimale si quelque chose est vrai, en allant au plus vite.
Et :
⚠️ Il vaut mieux éviter un
.find_element(s)brut : c’est la voie rapide vers la flakiness.Le délai maximal de 5 secondes n’aura aucun impact dans une batterie scalée horizontalement, ce n’est donc pas une pratique à craindre ici. Il n’est pas non plus recommandé de déguiser un
verifyen matcher : ce sont deux outils différents.
| Outil | But | Timeout typique |
|---|---|---|
verify(...) | Garantir qu’on est sur la bonne page | global --wait-timeout |
matcher (has_cookies_banner, …) | Identifier la bonne branche | court (1-5 s) |