02.12 — Custom types & custom errors#

Couche shape d’Ocarina. Aucune logique : juste des types, des alias, des exceptions, des protocols. C’est ce qui rend le DSL typé sans logique runtime additionnelle.

custom_types/#

FichierContenu
effect.pytype Effect = Callable[[], None] + type Effects = tuple[Effect, ...]
thunk.pytype Thunk[T] = Callable[[], T]
tpom.pyTypeVar TPOM bound POMBase
supports_write.pyProtocol SupportsWrite[T] (write(s: T) -> Any)
built_web_driver.pytype BuiltWebDriver[Driver] = tuple[Driver, Effect]
scenario.pyScenario[Driver] (frozen dataclass)
test_components.pyTestChain, TestSetup, TestTeardown, TestWatchers[Driver]
test_runner.pyTestRunner[Driver] (frozen dataclass)
oc_test.pyTestName, TestScenario[Driver], TestScenarioFragment[Driver]
oc_test_layers.pyTestId, TestResult, TestSuiteResult, TestSuiteResults, TestCampaignResults, TestCycleResults
selenium/built_web_driver.pyBuiltSeleniumWebDriver = BuiltWebDriver[WebDriver]
selenium/oc_test_scenario.pySeleniumTestScenario = TestScenario[WebDriver]
selenium/supported_browsers.pytype SupportedSeleniumBrowser = Literal["chrome", "firefox", "edge", "safari"]
selenium/web_drivers_pool.pytype SeleniumWebDriversPool = WebDriversPool[WebDriver]

Tous ces fichiers sont excluded de la couverture (pyproject.toml#tool.coverage) :

"src/ocarina/custom_types/*",

Raison : aucune logique runtime à tester ; ce sont des shapes.

Hiérarchie#

# src/ocarina/custom_types/oc_test_layers.py

type TestId = str
type _TestStepsCount = int
type TestResult = Result[Any] | None
type TestSuiteResult = tuple[TestResult, _TestStepsCount, TestId]
type TestSuiteResults = dict[str, TestSuiteResult]                  # {test_name: TestSuiteResult}
type TestCampaignResults = dict[str, TestSuiteResults]              # {suite_name: TestSuiteResults}
type TestCycleResults = dict[str, TestCampaignResults]              # {campaign_name: TestCampaignResults}

Lecture :

TestCycleResults
  └── "Dashboard login" (campaign)
      └── "Login happy paths" (suite)
          ├── "Login - without OTP" (test) → (Ok(None), 15, "login_no_otp")
          └── "Login - with OTP"    (test) → (Fail(exc), 8, "login_otp")

Trois niveaux de nesting, indexés par nom. Le (TestResult, steps_count, test_id) est la feuille.

Scenario[Driver], TestRunner[Driver], TestChain#

@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)


@dataclass(frozen=True)
class TestRunner[Driver]:
    chain_runners: list[ChainRunner[Any]]
    skipped: bool
    setup: Effect | None
    teardown: Effect | None
    watchers: Sequence[Watcher[Driver]] | None


type TestChain = Sequence[ChainRunner[Any]]
type TestSetup = Effect | None
type TestTeardown = Effect | None
type TestWatchers[Driver] = Sequence[Watcher[Driver]] | None

Deux dataclasses + 4 type aliases. Pas de méthode, juste des bags de données.

custom_errors/#

FichierExceptionLevée par
test_framework/no_matching_branch.pyNoMatchingBranchErrormatch_page quand aucun when ne match
test_framework/pages.pyPageVerificationErrorConventionnellement levée par POM.verify quand la page n’est pas la bonne
test_framework/driver_died.pyDriverDiedErrordriver_healthcheck quand le driver ne répond plus
test_framework/campaigns.pyDuplicateTestNameErrorLevée quand deux tests d’une même campagne portent le même name. Sous-classe de DuplicatesError (lui-même InvariantViolationError) ; transporte la séquence duplicates pour le rapport.

DriverDiedError#

@final
class DriverDiedError(Exception):
    """Raised when a WebDriver instance dies or becomes unresponsive."""

→ Permet à un appelant de distinguer « le driver est mort » de « le driver fait quelque chose d’inattendu ». Ocarina la traite comme transient via les adapters projet (le projet ajoute DriverDiedError dans son transient_errors).

PageVerificationError#

Conventionnellement levée dans POM.verify :

def verify(self, *, timeout: float | None = None) -> Self:
    try:
        WebDriverWait(self._driver, timeout or get_timeout()).until(...)
    except TimeoutException as exc:
        raise PageVerificationError from exc
    return self

Permet de distinguer « la page n’est pas la bonne » de « il y a eu une erreur Selenium ».

NoMatchingBranchError#

Levée par _match_page_builder quand aucun when ne match. Voir 03-railway/07-match-page-when.md

C’est une erreur de logique de scénario : ça veut dire que l’utilisateur a oublié de couvrir un cas. Elle devrait être propagée en Fail sur le rail d’échec, pas dans transient_errors, parce que retry ne corrigera pas le manque de couverture.

custom_invariants/testing/#

FichierInvariantLève sur
workers.pyvalidate_workers_amountworkers < 1
oc_test_runners_ids.pyvalidate_test_runners_idsIDs en doublon
oc_test_runners_names.pyvalidate_test_runners_namesNoms en doublon OU invalides en tant que filenames
oc_test_suites_names.pyvalidate_test_suites_namesNoms de suites en doublon
oc_test_campaigns_names.pyvalidate_campaigns_namesNoms de campagnes en doublon
oc_test_cycles_names.pyvalidate_test_cycle_nameInvalide en tant que filename

Tous écrits avec FrameworkInvariantValidator.create(...) (cf. 04-invariants/05-business-vs-framework-validator.md).

SupportsWrite[T]#

from typing import Protocol, TypeVar

T_contra = TypeVar("T_contra", contravariant=True)

class SupportsWrite(Protocol[T_contra]):
    def write(self, s: T_contra, /) -> Any: ...

Protocol minimaliste : tout objet avec .write(s) est utilisable. Permet aux loggers de prendre un stream: SupportsWrite[str] | None (sys.stdout, sys.stderr, fichier, BytesIO mocké).

Note : contravariant=True parce qu’écrire est une opération input, donc contravariant en T.
Conforme à la stdlib de Python.