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.

Code#

def run(self, test: Test[Driver]) -> TestSuiteResult:
    max_attempts = 1 + self._max_retries
    setup_failures = 0
    last_steps_count = -1
    last_result = None

    taxonomy = (self._cycle_name, self._campaign_name, self._suite_name, test.name)
    logger_with_taxonomy = self._create_logger().set_domain_taxonomy(taxonomy)
    logger_without_taxonomy = self._create_logger()

    for attempt in range(1, max_attempts + 1):
        self._act_counter.reset()

        with self._drivers_pool.acquire() as driver:
            outcome = self._executor.execute(
                test,
                driver=driver,
                taxonomy=taxonomy,
                logger_with_taxonomy=logger_with_taxonomy,
                logger_without_taxonomy=logger_without_taxonomy,
                attempt=attempt,
                max_attempts=max_attempts,
            )

        if outcome.skipped:
            return None, -1, test.test_id

        if outcome.setup_failed:
            setup_failures += 1

        last_result = outcome.result
        last_steps_count = outcome.steps_count

        if outcome.should_retry and attempt <= max_attempts:
            logger_with_taxonomy.cleanup()
            time.sleep(attempt)
            continue

        break

    if setup_failures >= max_attempts:
        msg = (
            f"{test.name} — Skipped: setup failed on all"
            f" {max_attempts} attempts. Fix the setup before retrying."
        )
        logger_with_taxonomy.warning(msg)
        return None, -1, test.test_id

    self._act_counter.reset()
    return last_result, last_steps_count, test.test_id

Anatomie ligne par ligne#

Calcul du nombre total de tentatives#

max_attempts = 1 + self._max_retries

_max_retries est passé au constructeur (default 8). Donc max_attempts = 9 par défaut. Le Holy Book y fait référence : « 9 vies comme un chat 🐱 » dans le chapitre « Premiers obstacles du monde réel ».

Construction des deux loggers une seule fois#

taxonomy = (cycle, campaign, suite, test_name)
logger_with_taxonomy = self._create_logger().set_domain_taxonomy(taxonomy)
logger_without_taxonomy = self._create_logger()

Deux loggers, construits une seule fois pour toutes les tentatives. C’est le FileLogger qui se charge de réinitialiser son fichier à la prochaine tentative (logger.cleanup(), voir plus bas).

Boucle principale#

for attempt in range(1, max_attempts + 1):
    self._act_counter.reset()
    with self._drivers_pool.acquire() as driver:
        outcome = self._executor.execute(...)
  • act_counter.reset() avant chaque tentative pour ne pas compter les pas de test de l’exécution précédente du test.
  • with pool.acquire() as driver : un driver frais par tentative. Le with garantit le dispose à la sortie, même en cas d’exception.
  • executor.execute(...) délègue tout le reste.

Trois branchements après chaque tentative#

if outcome.skipped:
    return None, -1, test.test_id

if outcome.setup_failed:
    setup_failures += 1

last_result = outcome.result
last_steps_count = outcome.steps_count

if outcome.should_retry and attempt < max_attempts:
    logger_with_taxonomy.cleanup()
    time.sleep(attempt)
    continue

break
CasComportement
outcome.skipped (par exemple Test(skipped=True))Retourne immédiatement (None, -1, test_id). Pas de retry, pas de comptage.
outcome.setup_failedIncrémente setup_failures ; le reste de la logique s’applique (peut quand même retry si should_retry=True).
outcome.should_retry and attempt < max_attemptslogger.cleanup() (recycle le fichier de log), time.sleep(attempt) (backoff linéaire 1s, 2s, 3s, …), continue la boucle.
sinonbreak — on a un verdict définitif (Pass ou Fail).

Backoff linéaire time.sleep(attempt)#

AttemptSleep avant retry
11s
22s
33s
44s
55s
66s
77s
88s
99s

Total dans le pire des cas : 1+2+3+4+5+6+7+8+9 = 45s de sleep cumulé, hors temps réel d’exécution.

Pourquoi linéaire plutôt qu’exponentiel (2^attempt) ? Parce que le pire des cas exponentiel ferait 2+4+8+16+32+64+128+256+512 = 1022s ≈ 17 min de sleep cumulé.

Cas « toutes les tentatives de setup ont échoué »#

if setup_failures >= max_attempts:
    msg = (
        f"{test.name} — Skipped: setup failed on all"
        f" {max_attempts} attempts. Fix the setup before retrying."
    )
    logger_with_taxonomy.warning(msg)
    return None, -1, test.test_id

Si setup() a échoué à chaque tentative, le test est SKIPPED, pas FAILED. Si le setup ne marche jamais, on ne peut pas conclure que la chaîne est cassée, on conclut juste que le test n’a pas pu être exécuté. Un message explicite invite à corriger le setup (action concrète).

C’est un détail qui sépare une défaillance d’infrastructure d’une défaillance fonctionnelle.

TestSuiteResult#

type TestSuiteResult = tuple[TestResult, _TestStepsCount, TestId]
  • TestResult : Ok, Fail, ou None (skip).
  • _TestStepsCount : int, -1 si skip / setup_failed-all.
  • TestId : str.

C’est le tuple qui remonte à TestSuite, qui l’agrège dans son dict[str, TestSuiteResult].

Pourquoi logger.cleanup() avant le retry#

On s’en fout d’avoir des artefacts (fichier de log dédié, ou bruit dans le DOCX) pour de la flakiness : inspecter le terminal pour constater la flakiness suffit. cleanup recycle donc le fichier de log courant à chaque retry, plutôt que d’en empiler un de plus pour chaque tentative.

Pour le PrintLogger : cleanup est un no-op.

Tableau récapitulatif des paramètres __init__#

def __init__(
    self,
    *,
    executor: TestExecutor[Driver],
    drivers_pool: WebDriversPool[Driver],
    create_logger: Thunk[ILogger],
    act_counter: ActCounter,
    cycle_name: str,
    campaign_name: str,
    suite_name: str,
    max_retries: int = _DEFAULT_MAX_RETRIES,   # 8
) -> None: ...
ParamètreSourcePourquoi
executorTestSuite._build_flow() instancie ; lui-même reçoit create_logger, take_screenshot, act_counter, transient_errors, autoscreen_on_failDélégation de l’exécution d’une tentative.
drivers_poolConstruit côté projet (create_selenium_drivers_pool)Acquisition de drivers frais.
create_loggerThunk qui retourne un nouveau loggerConstruire deux loggers une seule fois.
act_counterThreadsBasedActCounter() thread-localCompter les act exécutés.
cycle_name, campaign_name, suite_nameInjectés en cascade depuis TestSuiteConstruire la taxonomy pour le FileLogger.
max_retriesDéfini par projet via TestSuite(max_retries_per_test=...)8 par défaut ; 9 vies.