02.05.01 — Test[Driver]#

Fichier source : src/ocarina/dsl/testing/oc_test.py

Signature#

@final
class Test[Driver]:
    def __init__(
        self,
        *,
        name: TestName,
        test_id: str | None = None,
        test_scenario: TestScenario[Driver],
        pre_test_scenarios_fragments: Sequence[TestScenarioFragment[Driver]] | None = None,
        post_test_scenarios_fragments: Sequence[TestScenarioFragment[Driver]] | None = None,
        skipped: bool = False,
    ) -> None:
        if test_id is None:
            test_id = name
        self.name = name
        self.test_id = test_id
        self._test_scenario = test_scenario
        self._pre_test_scenarios_fragments = pre_test_scenarios_fragments or []
        self._post_test_scenarios_fragments = post_test_scenarios_fragments or []
        self._skipped = skipped

Six paramètres :

ParamètreTypeRôle
nameTestName (str)Label humain ; apparaît dans le rapport, et devient le nom du fichier de log (donc soumis à is_valid_filename).
test_idstr | NoneIdentifiant stable pour --only--exclude. Si absent : prend name.
test_scenarioTestScenario[Driver] (alias = Callable[[Driver, ILogger], Scenario[Driver]])Factory qui construit le Scenario au moment de l’exécution.
pre_test_scenarios_fragmentsSequence[TestScenarioFragment[Driver]] | NoneFonctions (driver, logger) -> TestChain exécutées avant le scénario principal.
post_test_scenarios_fragmentsSequence[TestScenarioFragment[Driver]] | NoneFonctions (driver, logger) -> TestChain exécutées après le scénario principal.
skippedboolSi True, le test est enregistré mais pas exécuté.

1. @final#

Pas d’héritage utilisateur. Si l’on veut un test « spécial », on compose via les fragments ou via un scénario, on ne sous-classe pas.

2. * (keyword-only)#

Tous les paramètres sont keyword-only.

Test(
    name="Login - without OTP",
    test_id="login_no_otp",
    test_scenario=lambda driver, logger: Scenario(test_chain=...),
    pre_test_scenarios_fragments=[],
    skipped=False,
)

3. test_id is None → test_id = name#

Dans tous les cas, on a besoin d’un ID. Mais si on n’est pas interfacé à un système qui exige un ID distinct du nom, le name fait l’affaire, l’unicité des name est de toute façon vérifiée.

4. pre_test_scenarios_fragments or []#

Le or [] permet de passer None. La séquence par défaut est [], pas un tuple, mais c’est traité comme une Sequence. Sequence est immutable, ce qui rend le type checker d’autant plus strict.

5. Pas de _driver ni de _logger#

Les dépendances runtime sont injectées par spawn, pas par __init__. Cela garantit qu’un Test est une valeur : on peut le sérialiser, le mocker, le passer en argument sans aucun contexte d’exécution.

spawn(driver, logger) -> TestRunner[Driver]#

def spawn(self, driver: Driver, logger: ILogger) -> TestRunner[Driver]:
    chain_runners: list[ChainRunner[Any]] = []

    for pre_chain in self._pre_test_scenarios_fragments:
        chain_runners.extend(pre_chain(driver, logger))

    scenario = self._test_scenario(driver, logger)
    chain_runners.extend(scenario.test_chain)

    for post_chain in self._post_test_scenarios_fragments:
        chain_runners.extend(post_chain(driver, logger))

    return TestRunner(
        chain_runners=chain_runners,
        skipped=self._skipped,
        setup=scenario.setup,
        teardown=scenario.teardown,
        watchers=scenario.watchers,
    )
  1. Fragments pre : toutes les chaînes concernées sont concaténées dans chain_runners.
  2. Scénario principal : self._test_scenario(driver, logger) retourne un Scenario[Driver]. Sa test_chain est concaténée ici.
  3. Fragments post : chaque fragment est appelé (driver, logger) -> TestChain ; toutes les chaînes sont concaténées à la suite.

Le résultat est un TestRunner qui contient tout ce qu’il faut pour l’exécution : la chaîne complète, le setup, le teardown, les watchers, et le drapeau skipped.

La forme (driver, logger) -> TestChain pour les fragments#

C’est exactement la signature d’un test_scenario mais qui retourne directement un TestChain (et pas un Scenario). Pourquoi cette différence ?

DifférenceRaison
Un fragment n’a pas de setupteardownwatchers propresLes fragments sont de la glue — ils s’attendent à utiliser le setup/teardown/watchers du scénario principal
Un fragment est concaténé plutôt qu’imbriquéLa chain final est [pre.chain..., scenario.chain..., post.chain...], plat

Le CLAUDE.md du projet IA détaille le pattern :

Reusable pre/post-conditions live under src/tests/scenarios/_fragments/. Wire them via:

  • pre_test_scenarios_fragments=[fragment_fn, ...] — before the main scenario chain.
  • post_test_scenarios_fragments=[fragment_fn, ...] — after.

A fragment has the scenario shape (driver: WebDriver, logger: ILogger) -> list[ChainRunner]. The framework concatenates pre + scenario + post into one ChainRunner sequence.

Exemple : login_as_demo_user — voir ../../08-ai-example/

TestRunner[Driver]#

# src/ocarina/custom_types/test_runner.py
@dataclass(frozen=True)
class TestRunner[Driver]:
    chain_runners: list[ChainRunner[Any]]
    skipped: bool
    setup: Effect | None
    teardown: Effect | None
    watchers: Sequence[Watcher[Driver]] | None

Frozen dataclass, immutable, juste un agrégateur de données. C’est ce que consomme TestExecutor.

Quand utiliser le skipped=True#

Le skipped flag est un échappatoire :

  • Pour les tests « WIP » qui ne doivent pas tourner mais doivent rester documentés.
  • Pour les tests temporairement cassés (avec ticket en regard, idéalement référencé dans le nom : name="[WIP] Some test").

Le Test.skipped est lu par TestExecutor.execute. Si True, il renvoie immédiatement un ExecutionOutcome(skipped=True).

À noter : on peut aussi skipper dynamiquement via --exclude <test_id> ou via --only (cf. 08-filter-tests-by-ids.md). Subtilité ici : les tests non ciblés sont détruits plutôt que vraiment “skippés”. Ils n’apparaissent donc pas explicitement comme skipped, le cycle de tests est simplement rétréci.

La factory create_selenium_test#

src/ocarina/dsl/testing/selenium/create_test.py fournit une factory Selenium-aware :

def create_selenium_test(
    *,
    name: TestName,
    test_id: str | None = None,
    test_scenario: TestScenario[WebDriver],
    pre_test_scenarios_fragments: Sequence[TestScenarioFragment[WebDriver]] | None = None,
    post_test_scenarios_fragments: Sequence[TestScenarioFragment[WebDriver]] | None = None,
    skipped: bool = False,
):
    return Test(
        name=name,
        test_id=test_id,
        test_scenario=test_scenario,
        pre_test_scenarios_fragments=pre_test_scenarios_fragments,
        post_test_scenarios_fragments=post_test_scenarios_fragments,
        skipped=skipped,
    )

Juste un alias typé Driver = WebDriver. Permet aux call-sites de ne pas avoir à écrire Test[WebDriver](...) à chaque test.