02.05.01 — Test[Driver]

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.

02.05.02 — TestExecutor[Driver]

02.05.02 — TestExecutor[Driver]#

Fichier source : src/ocarina/dsl/testing/internals/test_executor.py

Responsabilité unique : exécuter une seule tentative d’un test, avec un seul driver. Ne connaît ni le rejeu, ni la pool de drivers, ni l’agrégation au niveau suite.

ExecutionOutcome#

@final
@dataclass(frozen=True, slots=True)
class ExecutionOutcome:
    result: TestResult
    skipped: bool
    setup_failed: bool
    should_retry: bool
    steps_count: int
ChampTypeSens
resultTestResult = Result[Any] | NoneLe résultat de la chaîne (Ok, Fail), ou None si skip / setup_failed.
skippedboolTrue si test_runner.skipped (i.e. Test(skipped=True)).
setup_failedboolTrue si la fonction setup() du scénario a levé.
should_retryboolTrue si la règle de rejeu s’applique (transient_error détecté).
steps_countintNombre d’act appelés ; -1 si skip ou setup_failed.
  • slots=True : empêche l’ajout dynamique d’attributs et économise mémoire. C’est un objet qui circule en hot-path, le slots est justifié.
  • frozen=True : immutable, sûr à partager entre threads.
  • @final : pas d’héritage.

Ordre d’exécution d’une tentative#

┌──────────────────────────────────────────────────────────────────────┐
│  test_runner = test.spawn(driver, logger_with_taxonomy)              │
└──────────────────────────────┬───────────────────────────────────────┘
                               │
                               ▼
              ┌─────────────────────────────────┐
              │  test_runner.skipped ?          │── True ─► return Outcome(skipped=True, ...)
              └────────────────┬────────────────┘
                               │ False
                               ▼
              ┌─────────────────────────────────┐
              │  logger.test_name(test.name)    │   (annotation)
              └────────────────┬────────────────┘
                               │
                               ▼
              ┌─────────────────────────────────┐
              │  setup() (si non-None)          │── leve ─► teardown() (si non-None)
              └────────────────┬────────────────┘            ↓
                               │                  return Outcome(setup_failed=True, should_retry=True, ...)
                               ▼
              ┌─────────────────────────────────┐
              │  watchers.start(driver, logger, │
              │                 take_screenshot)│   (1 daemon thread par watcher)
              └────────────────┬────────────────┘
                               │
                               ▼
              ┌─────────────────────────────────┐
              │  _run_chain(chain_runners, ...) │── retourne (result, should_retry)
              └────────────────┬────────────────┘
                               │
                               ▼
              ┌─────────────────────────────────┐
              │  watchers.stop()                │   (toujours)
              └────────────────┬────────────────┘
                               │
                               ▼
              ┌─────────────────────────────────┐
              │  teardown() (si non-None)       │   (TOUJOURS, même si chain a fail)
              └────────────────┬────────────────┘     (les exceptions sont logguées & avalées)
                               │
                               ▼
              ┌─────────────────────────────────┐
              │  steps_count = act_counter.get()│
              │  return Outcome(...)            │
              └─────────────────────────────────┘

execute#

def execute(
    self, test: Test[Driver], *,
    driver: Driver,
    taxonomy: tuple[str, ...],
    logger_with_taxonomy: ILogger,
    logger_without_taxonomy: ILogger,
    attempt: int, max_attempts: int,
) -> ExecutionOutcome:
    test_runner = test.spawn(driver, logger_with_taxonomy)

    if test_runner.skipped:
        return ExecutionOutcome(result=None, skipped=True, setup_failed=False,
                                should_retry=False, steps_count=-1)

    logger_without_taxonomy.test_name(test.name)

    if test_runner.setup is not None:
        try:
            test_runner.setup()
        except Exception as exc:
            msg = f"{test.name} -- Setup failed (attempt {attempt}/{max_attempts}): {exc}"
            logger_with_taxonomy.warning(msg)
            if test_runner.teardown is not None:
                self._run_teardown(test_runner.teardown, test_name=test.name, logger=logger_with_taxonomy)
            return ExecutionOutcome(result=None, skipped=False, setup_failed=True,
                                    should_retry=True, steps_count=-1)

    watchers: Sequence[Watcher[Driver]] = test_runner.watchers or []
    self._start_watchers(watchers, driver=driver, test_name=test.name, taxonomy=taxonomy)

    result, should_retry = self._run_chain(
        test_runner.chain_runners, test_name=test.name, attempt=attempt,
        driver=driver, logger=logger_without_taxonomy,
        logger_with_taxonomy=logger_with_taxonomy, max_attempts=max_attempts,
    )

    self._stop_watchers(watchers)

    if test_runner.teardown is not None:
        self._run_teardown(test_runner.teardown, test_name=test.name, logger=logger_with_taxonomy)

    steps_count = self._act_counter.get()
    return ExecutionOutcome(result=result, skipped=False, setup_failed=False,
                            should_retry=should_retry, steps_count=steps_count)

Deux loggers : pourquoi#

logger_with_taxonomy vs logger_without_taxonomy :

02.05.03 — TestFlow[Driver] — politique de rejeu

02.05.03 — TestFlow[Driver] — politique de rejeu#

Fichier source : src/ocarina/dsl/testing/internals/test_flow.py

Responsabilité unique : pour un seul Test, exécuter la boucle de rejeu, acquérir un driver propre à chaque tentative, et appliquer la politique de backoff.

Pourquoi un niveau intermédiaire entre TestSuite et TestExecutor#

NiveauNe sait pas
TestExecutorLe rejeu, la pool de drivers
TestFlowLa concurrence inter-tests, l’agrégation suite-level
TestSuiteL’exécution d’une tentative, le rejeu

C’est une séparation vraiment stricte. TestFlow est le seul à connaître à la fois la pool et la politique de rejeu : son job est de combiner les deux.

02.05.04 — TestSuite[Driver] — moteur parallélisé

02.05.04 — TestSuite[Driver] — moteur parallélisé#

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

Responsabilité unique : exécuter une séquence de Test en parallèle, gérer la saturation, le filtrage par IDs, et la validation des invariants pré-exécution.

Constructeur#

def __init__(
    self,
    *,
    name: str,
    tests: Sequence[Test[Driver]],
    create_logger: Thunk[ILogger],
    drivers_pool: WebDriversPool[Driver],
    take_screenshot: ITakeScreenshot[Driver],
    act_counter: ActCounter | None = None,
    transient_errors: tuple[type[Exception], ...] = (),
    copy_indicator: str = "COPY",
    put_space_after_copy_indicator: bool = True,
    max_retries_per_test: int | None = None,
    autoscreen_on_fail: bool = False,
    saturate_workers: bool | None = None,
    only_ids: Iterable[str] = (),
    exclude_ids: Iterable[str] = (),
) -> None:
    self._guards_on_invoke(tests)
    # ... assignations ...
    self._tests = filter_tests_by_ids(
        tests,
        only=only_ids,
        exclude=exclude_ids,
        logger=create_logger().set_prefix(lambda: f"{self.name}: filtering tests..."),
    )
ParamètreTypeRôleDéfaut
namestrNom de la suite (apparaît dans logs & rapports) — 
testsSequence[Test[Driver]]Liste des tests à exécuter — 
create_loggerThunk[ILogger]Factory de logger — 
drivers_poolWebDriversPool[Driver]Pool partagée — 
take_screenshotITakeScreenshot[Driver]Callable (driver, logger, category) -> None — 
act_counterActCounter | NoneSi None → ThreadsBasedActCounter()None
transient_errorstuple[type[Exception], ...]Exceptions qui déclenchent un retry()
copy_indicatorstrPréfixe pour les tests clonés en saturation"COPY"
put_space_after_copy_indicatorbool[COPY 1] (True) vs [COPY1] (False)True
max_retries_per_testint | NoneNombre maximum de retentatives d’un test potentiellement flakyNone → _DEFAULT_MAX_RETRIES_PER_TEST (8)
autoscreen_on_failboolCapture automatiquement un ou des screenshots (rafale) en cas d’échecFalse
saturate_workersbool | NoneClonage des tests pour saturer tous les workers si la pool est suffisamment grosseNone → cascade : suite > campaign > bootstrap
only_idsIterable[str]Filtre --only()
exclude_idsIterable[str]Filtre --exclude()

Garde-fous#

Au moment du __init__#

def _guards_on_invoke(self, tests: Sequence[Test[Driver]]) -> None:
    validate_test_runners_ids(tests=tests, name="tests").execute().raise_if_invalid()

→ Tous les test_id doivent être uniques. Sinon : AggregateInvariantViolationError. Levé dès la construction, avant la moindre tentative d’exécution. Si l’utilisateur a dupliqué un test_id par erreur, il l’apprend immédiatement.

02.05.05 — Saturation des workers

02.05.05 — Saturation des workers#

Mécanisme propre à Ocarina : si une suite a moins de tests que de workers disponibles, on clone aléatoirement des tests jusqu’à atteindre le nombre de workers. Les copies sont renommées [COPY 1] <name>, [COPY 2] <name>, etc.

Pourquoi#

Le Holy Book formalise la motivation dans le chapitre « Premiers obstacles du monde réel » :

Son option saturate_workers permet de forcer du clonage aléatoire de tests à l’intérieur d’une suite.

02.05.06 — TestCampaign[Driver]

02.05.06 — TestCampaign[Driver]#

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

Responsabilité unique : exécuter une séquence de suites dans l’ordre, partager une config de workers, et déclarer campaign_has_failed.

Code#

class TestCampaign[Driver]:
    def __init__(
        self,
        *,
        name: str,
        suites: Sequence[TestSuite[Driver]],
        max_workers: int,
        saturate_workers: bool | None = None,
    ) -> None:
        validate_test_suites_names(suites=suites, name="suites").execute().raise_if_invalid()

        self.name = name
        self._suites = suites
        self._results: TestCampaignResults = {}
        self._max_workers = max_workers
        self._saturate_workers = saturate_workers

        for suite in self._suites:
            suite._campaign_name = self.name

    def run_all(
        self, *, skip_all: bool = False, saturate_workers: bool = True
    ) -> TestCampaignResults:
        self._results.clear()

        resolved_saturate_workers = (
            self._saturate_workers
            if self._saturate_workers is not None
            else saturate_workers
        )

        if skip_all:
            for suite in self._suites:
                self._results[suite.name] = {
                    name: (None, -1, test_id)
                    for name, test_id in suite.test_names_and_ids
                }
            return self._results

        for suite in self._suites:
            self._results[suite.name] = suite.run(
                max_workers=self._max_workers,
                saturate_workers=resolved_saturate_workers,
            )

        return self._results


def campaign_has_failed(results: TestCampaignResults) -> bool:
    return any(
        is_test_result_fail(outcome)
        for campaign_results in results.values()
        for outcome, _, _ in campaign_results.values()
    )

Anatomie#

Garde-fou au constructeur#

validate_test_suites_names(suites=suites, name="suites").execute().raise_if_invalid()

→ Les noms de suites de la campagne doivent être uniques — de façon insensible à la casse depuis 1.1.10 (NFC + casefold). Levé dès la construction.

02.05.07 — TestCycle[Driver] + modes

02.05.07 — TestCycle[Driver] + modes#

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

Responsabilité unique : orchestrer les campagnes smoke puis main, et appliquer un mode de gestion d’échec des smoke.

Deux modes#

type Mode = Literal[
    "fail-fast-on-first-smoke-campaigns-sequence-fail",
    "wait-for-all-smoke-tests",
]
ModeComportement
fail-fast-on-first-smoke-campaigns-sequence-fail (par défaut)Dès qu’une campagne de smoke fail, les suivantes sont skippées.
wait-for-all-smoke-testsToutes les campagnes de smoke s’exécutent. Si au moins une fail, les campagnes main sont skippées.
CasMode
Smoke par dépendance (par exemple : “login” puis “dashboard accessible”)fail-fast (si login échoue, dashboard est inutile)
Smoke parallèles (par exemple : “homepage” + “API ping”)wait-for-all (on veut voir les deux)

Le default est fail-fast parce que c’est le cas le plus fréquent.

02.05.08 — filter_tests_by_ids

02.05.08 — filter_tests_by_ids#

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

Utilisé par TestSuite.__init__ pour appliquer les flags CLI --only <ids> et --exclude <ids>. Mutex : on ne peut pas passer les deux.

Code#

def filter_tests_by_ids[Driver](
    tests: Sequence[Test[Driver]],
    *,
    only: Iterable[str] = (),
    exclude: Iterable[str] = (),
    logger: ILogger,
) -> Sequence[Test[Driver]]:
    only_set = set(only)
    exclude_set = set(exclude)

    if only_set and exclude_set:
        raise ValueError("--only and --exclude cannot be used together")

    known_ids = {test.test_id for test in tests}

    if only_set:
        matched = only_set & known_ids
        if matched:
            joined = ", ".join(sorted(matched))
            logger.info(f"--only: matched test IDs: {joined}")
        return [test for test in tests if test.test_id in only_set]

    if exclude_set:
        matched = exclude_set & known_ids
        if matched:
            joined = ", ".join(sorted(matched))
            logger.info(f"--exclude: matched test IDs: {joined}")
        return [test for test in tests if test.test_id not in exclude_set]

    return tests

Quatre garanties#

1. Mutex --only--exclude#

if only_set and exclude_set:
    raise ValueError("--only and --exclude cannot be used together")

Tenter de passer les deux est une erreur runtime (pas une erreur mypy). Sémantiquement, ça n’aurait pas de sens : « ne garder que A, B » ET « exclure C, D » ? Qu’est-ce que c’est que ces putains de conneries (à l’échelle d’une CLI) ? Le mutex évite l’ambiguïté.