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.

04.01 — Stratégie « dehors comme un utilisateur »

04.01 — Stratégie « dehors comme un utilisateur »#

C’est la posture documentée dès le conftest.py des tests scénarios. Aucun test ne triche en regardant les internes.

tests/scenarios/conftest.py#

ComposantRôle
FakeDriver (dataclass)Driver minimaliste : title, disposed. Aucune méthode Selenium.
make_built_driver()Factory (FakeDriver, dispose), soit la signature attendue par WebDriversPool.
make_pool(max_size=2)WebDriversPool[FakeDriver] prêt à l’emploi.
RecordingPOM(POMBase)POM qui consigne ses appels et peut être configuré pour lever.
acting(pom, step)ActionSuccess[RecordingPOM] complet (failure+success en no-op).
scenario_of("ok", "ok2")TestScenario[FakeDriver] à N steps.
failing_scenario(step="boom", exc=...)Scénario à 1 step qui lève.
make_test(name, scenario=..., test_id=..., skipped=False)Test[FakeDriver]
make_suite(name, tests, pool=..., transient_errors=..., max_retries_per_test=...)TestSuite[FakeDriver]
make_campaign(name, suites, max_workers=1)TestCampaign[FakeDriver]
make_cycle(name="cycle", campaigns=..., smoke=..., mode=...)TestCycle[FakeDriver]
run_chain(runner)Helper qui exécute un ChainRunner et retourne le ActionChain.

FakeDriver#

@dataclass
class FakeDriver:
    title: str = "fake"
    disposed: bool = False

C’est tout. Le cœur d’Ocarina n’appelle aucune API Selenium.

07.01 — Arborescence

07.01 — Arborescence#

src/
├── main.py                                                     # entry point (cf. README)
│
├── api/                                                        # clients HTTP externes
│   ├── retrieve_dashboard_otp_code.py                          # filter + sort + pick OTP
│   ├── get_otp_history.py                                      # HTTP GET /api/otp-history
│   └── constants/endpoints.py                                  # URLs des endpoints tests-workers
│
├── caches/                                                     # cache L1 in-memory
│   ├── l1.py                                                   # dogpile.cache.memory TTL 30m
│   └── reserve_free_cache_key.py                               # UUID + threading.Lock
│
├── constants/                                                  # constantes
│   ├── pages/
│   │   ├── homepage.py                                         # HOMEPAGE_URL
│   │   ├── dashboard.py                                        # DASHBOARD_URL + ids des inputs
│   │   ├── random_loaders.py
│   │   ├── sacred_upload.py
│   │   ├── corsicamon.py
│   │   ├── chaotic_form.py
│   │   ├── madness.py
│   │   ├── random_error_page.py
│   │   └── donkey_sausage_eater_detector.py
│   └── sys/
│       ├── redis_keys.py                                       # OTP_SEND_LOCK_KEY, ...
│       └── transient_errors.py                                 # (WebDriverException, HttpError...)
│
├── lib/
│   ├── connectors/test_steps/actions/                          # fonctions (TPOM) -> TPOM
│   │   ├── homepage.py
│   │   ├── dashboard_login.py                                  # ~10 connectors (with/without retries)
│   │   ├── dashboard_welcome.py
│   │   ├── dashboard_protected_page.py
│   │   ├── sacred_upload.py
│   │   ├── corsicamon_enter_api_key.py
│   │   ├── corsicamon_main.py
│   │   ├── chaotic_form.py
│   │   ├── random_error.py
│   │   ├── random_loaders.py
│   │   ├── madness.py
│   │   ├── this_is_bastia.py
│   │   ├── cors_errors.py
│   │   ├── bsod.py
│   │   ├── dsed.py
│   │   └── ids_bypassed.py
│   ├── custom_errors/
│   │   ├── http.py                                             # HttpErrorPageReachedError
│   │   └── transient_error.py                                  # TransientError
│   └── ext/                                                    # extensions / adapters
│       ├── ocarina/
│       │   ├── adapters/
│       │   │   ├── agnostic/
│       │   │   │   ├── act.py                                  # ⭐ adapter act (hook on_failure)
│       │   │   │   ├── env_getters.py                          # ⭐ adapter EnvGetters typé
│       │   │   │   └── match_page.py                           # ⭐ adapter match_page
│       │   │   └── selenium/
│       │   │       ├── test_suite.py                           # ⭐ adapter TestSuite
│       │   │       ├── test_campaign.py                        # ⭐ adapter TestCampaign
│       │   │       ├── cli_getters.py                          # getters typés depuis le CliStore
│       │   │       ├── logs.py                                 # create_just_log_error, etc.
│       │   │       └── screenshotter.py                        # take_screenshot helper
│       │   └── regex/error_page.py                             # ERROR_PAGE_REGEX
│       ├── redis/
│       │   └── client.py                                       # Singleton client Redis
│       └── selenium/
│           ├── humanize/                                       # ⭐ HumanizedDriver + keyboard
│           │   ├── proxy.py
│           │   └── keyboard.py
│           ├── pages/verify_elements_presence.py
│           └── watchers/catch_me_if_you_can_watcher.py         # ⭐ Watcher callback
│
├── pages/                                                      # POMs (Selenium + SeleniumTitleMixin)
│   ├── homepage.py
│   ├── random_loaders.py
│   ├── chaotic_form.py
│   ├── random_error.py
│   ├── sacred_upload/
│   │   ├── sacred_upload.py
│   │   └── fixtures/                                           # fichiers à uploader
│   ├── dashboard/
│   │   ├── login.py                                            # ⭐ avec OTP, retries, redis locks
│   │   ├── welcome_page.py
│   │   └── protected_page.py
│   ├── corsicamon/
│   │   ├── enter_api_key.py
│   │   └── main.py
│   ├── madness/
│   │   ├── base.py
│   │   ├── matchers.py                                         # has_cors, has_this_is_bastia
│   │   ├── cors.py
│   │   └── this_is_bastia.py
│   └── donkey_sausage_detector/
│       └── ids_bypassed.py
│
└── tests/
    ├── cycles/e2e.py                                           # ⭐ TestCycle (smoke + main)
    ├── campaigns/
    │   ├── global_smoke_tests.py                               # smoke
    │   ├── corsicamon.py                                       # main (+ smoke variant)
    │   ├── dashboard_login.py                                  # main
    │   ├── randomness.py                                       # main
    │   └── sacred_upload.py                                    # main
    ├── suites/
    │   ├── global_smoke_tests.py
    │   ├── randomness.py
    │   ├── dashboard/
    │   │   ├── access/
    │   │   │   ├── happy_paths.py
    │   │   │   └── unhappy_paths.py
    │   │   └── data_driven/multi_login.py
    │   ├── sacred_upload/{happy_paths,unhappy_paths}.py
    │   └── corsicamon/{smoke_tests,happy_paths,unhappy_paths}.py
    └── scenarios/
        ├── homepage/verify_homepage.py
        ├── dashboard/
        │   ├── access/{happy_paths,unhappy_paths}.py
        │   ├── back_to_igoristan.py
        │   └── data_driven/{multi_login,datasets/multi_login}.py
        ├── sacred_upload/{upload_files,just_go_back_to_igoristan}.py
        ├── corsicamon/{enter_api_key,new_draw,add_corsicamon,back_to_igoristan}.py
        └── randomness/
            ├── level_1/{random_error_page,random_loaders_page}.py
            ├── level_2/{dsed,madness}.py
            ├── level_3/chaotic_form.py
            └── level_4/walkthrough.py

Sémantique des dossiers#

DossierRôleConvention
pages/POMs (héritent SeleniumTitleMixin, POMBase)Une classe par page, fluent (return self)
lib/connectors/test_steps/actions/Connectors (TPOM) -> TPOMFonctions pures ; si paramètres, closures
lib/custom_errors/Exceptions projetSous-classes d’Exception
lib/ext/ocarina/adapters/Adapters au-dessus d’OcarinaCf. 02-adapters.md
lib/ext/redis/Wrapper Singleton RedisPour les locks distribués
lib/ext/selenium/Extensions Selenium spécifiques au projetHumanizedDriver, Watcher callback
caches/Cache L1 in-memorydogpile.cache.memory + UUID reservation
constants/ConstantesURLs, redis keys, transient errors
api/Clients HTTP externesOTP retrieval, OTP history
tests/Hiérarchie ISTQBcycles / campaigns / suites / scenarios

lib/ vs pages/#

  1. pages/ : POMs, encapsulent l’état d’une page (locators, méthodes d’action).
  2. lib/connectors/test_steps/actions/ : connectors, fonctions qui réfèrent aux POMs.
act(on_homepage, open_homepage)
#                ^^^^^^^^^^^^^
#                connector défini dans lib/connectors/test_steps/actions/homepage.py
def open_homepage(p: Homepage) -> Homepage:
    return p.open()

Le CLAUDE.md du projet IA en fait une discipline :

03.03 — Évaluation paresseuse

03.03 — Évaluation paresseuse#

Dans Ocarina, rien n’est exécuté tant qu’on ne l’a pas explicitement déclenché. C’est ce qui rend les scénarios composables comme des valeurs.

Zzz#

EndroitFormeDéclencheur
ChainRunner[T]Thunk[ActionChain[T]]runner.run()
validate(...)ValidationStartBlockValidationAssertBlock.execute()
match_page(...)retourne un ChainRunner[Any].run() (via la chaîne englobante)
Watcher.callbackCallable[[Watcher], None]_loop quand start() est appelé
logger.set_prefix(thunk)Thunk[str]recalculé à chaque appel de log
Scenario.setupteardownEffectappelé par TestExecutor
bootstrap(post_exec=...)Callable[[TestCycleResults], None]appelé après run_plugins
CliBuilder(effects_factory=lambda ns: (...))Effectsappelés après le parse argparse
test_scenario: TestScenario[Driver]Callable[[Driver, ILogger], Scenario[Driver]]appelé par Test.spawn
dispatch[mode]() dans TestCycle.run_alldict de Thunk[bool]appelé en lookup

ChainRunner#

runner = drive_page(act1, act2, act3)        # ⚠️  rien exécuté
# … plus tard …
chain = runner.run()                         # ▶︎  exécution
CapacitéSans paresseAvec paresse
Stocker un scénario dans une variableimpossible (déjà exécuté)trivial
Multiplier [runner] * 5exécute 1 fois, on a 5 références au résultatexécute 5 fois
Passer un runner à un autre runner (composition)impossibletrivial
Réordonner les act dans un test refactordifficiletrivial

validate(...).execute()#

v = validate(value, name="x").assert_that(is_positive).assert_that(is_not_zero)
# … on peut composer …
combined = chain_validations(v, other_validation)
# … rien d'exécuté jusqu'ici …
combined.execute().raise_if_invalid()        # ▶︎  exécution + agrégation

C’est ce qui permet à _ValidationChain de collecter toutes les erreurs avant d’en lever une seule (AggregateInvariantViolationError).

07.03 — Scénarios Dashboard login

07.03 — Scénarios Dashboard login#

Trois familles : happy paths, unhappy paths, data-driven multi-login. Tous exercent le useAuth à 10% de raté et la coordination OTP.

dashboard_login#

# src/tests/campaigns/dashboard_login.py
def create_igoristan_login_campaign(*, drivers_pool: SeleniumWebDriversPool) -> TestCampaign:
    return TestCampaign(
        name="Dashboard login",
        suites=[
            create_igoristan_login_happy_paths_test_suite(drivers_pool=drivers_pool),
            create_igoristan_login_unhappy_paths_test_suite(drivers_pool=drivers_pool),
            create_igoristan_login_data_driven_test_suite(drivers_pool=drivers_pool),
        ],
    )

Happy paths#

# src/tests/suites/dashboard/access/happy_paths.py
def create_igoristan_login_happy_paths_test_suite(*, drivers_pool) -> TestSuite:
    return TestSuite(
        name="Login happy paths",
        tests=[
            test_dashboard_login_without_otp_happy_path,
            test_dashboard_login_with_otp_happy_path,
            test_dashboard_login_page_back_to_igoristan_button,
        ],
        drivers_pool=drivers_pool,
    )

Test 1 : login sans OTP#

def dashboard_login_without_otp_happy_path(driver, logger):
    dashboard_creds = create_env_getters().get_credentials("dashboard")
    on_dashboard_login_page = DashboardLoginPage(driver=driver)
    on_dashboard_welcome_page = DashboardWelcomePage(driver=driver)

    just_log_error = create_just_log_error(logger=logger)
    log_error_with_current_url = create_log_error_with_current_url(logger=logger, driver=driver)
    just_log_success = create_just_log_success(logger=logger)
    log_success_with_current_url_and_take_screenshot = create_log_success_with_current_url_and_take_screenshot(...)

    retries_amount = max(get_max_workers(), 10)

    return [
        drive_page(
            act(on_dashboard_login_page, open_dashboard_login_page)
                .failure(just_log_error("Failed to open the dashboard login page..."))
                .success(just_log_success("Opened the dashboard login page!")),
            act(on_dashboard_login_page, verify_dashboard_login_page)
                .failure(log_error_with_current_url("Failed to verify..."))
                .success(log_success_with_current_url_and_take_screenshot("Verified!")),
            act(on_dashboard_login_page,
                login_without_otp_and_with_retries(dashboard_creds, retries_amount, logger=logger))
                .failure(just_log_error("Failed to connect to the dashboard without OTP..."))
                .success(just_log_success("Connected to the dashboard!")),
        ),
        drive_page(
            act(on_dashboard_welcome_page, verify_dashboard_welcome_page)
                .failure(log_error_with_current_url("Failed to verify..."))
                .success(log_success_with_current_url_and_take_screenshot("Verified!")),
        ),
    ]
  1. retries_amount = max(get_max_workers(), 10) : on rejoue le login interne (au POM) au moins 10 fois, à cause des 10% d’échec aléatoire.
  2. login_without_otp_and_with_retries(creds, retries_amount, logger=logger) : closure qui capture creds + nb retries + logger. Le connector retourne Callable[[DashboardLoginPage], DashboardLoginPage].
  3. 2 drive_page : login + welcome page. Chacun ouvre, vérifie, screenshot.
  4. Log factories partout : aucune lambda inline.
  5. SeleniumTitleMixin côté POM : get_current_title() est utilisé par le hook on_failure de act.

Test 2 : login avec OTP#

def dashboard_login_with_otp_happy_path(driver, logger):
    # ... setup pareil ...

    cache = in_memory_cache_with_30m_ttl
    fresh_cache_key_for_username = reserve_free_cache_key(cache)
    fresh_cache_key_for_otp_send_button_click_date = reserve_free_cache_key(cache)

    return [
        drive_page(
            act(on_dashboard_login_page, open_dashboard_login_page)...,
            act(on_dashboard_login_page, verify_dashboard_login_page)...,
            act(on_dashboard_login_page,
                start_to_login_with_otp_and_with_retries(
                    dashboard_creds, retries_amount,
                    cache=cache, logger=logger,
                    username_cache_key=fresh_cache_key_for_username,
                    otp_send_button_click_date_cache_key=fresh_cache_key_for_otp_send_button_click_date,
                ))...,
            act(on_dashboard_login_page, verify_otp_screen)...,
            act(on_dashboard_login_page,
                type_otp_with_retries(
                    retries_amount,
                    cache=cache, logger=logger,
                    username_cache_key=fresh_cache_key_for_username,
                    otp_send_button_click_date_cache_key=fresh_cache_key_for_otp_send_button_click_date,
                ))...,
        ),
        drive_page(
            act(on_dashboard_welcome_page, verify_dashboard_welcome_page)...,
            act(on_dashboard_welcome_page, click_on_go_to_nested_page_btn)...,
        ),
        drive_page(
            act(on_dashboard_protected_page, verify_dashboard_protected_page)...,
        ),
    ]

Trois drive_page. Cinq act dans le premier. Cache L1 + clés réservées pour partager des valeurs entre acts (cf. 08-caches-locks.md).

03.05 — Programmation déclarative

03.05 — Programmation déclarative#

Un scénario d’Ocarina décrit, il n’exécute pas. C’est ce qui le rend factorisable, multipliable, et lisible.

Démonstration#

return [
    drive_page(
        act(on_homepage, open_then_verify_homepage)
            .failure(just_log_error("Failed to reach the homepage..."))
            .success(log_success_with_current_url_and_take_screenshot("On the homepage!")),
        act(on_homepage, click_book_call_page_cta)
            .failure(just_log_error("Failed to click on the 'Book a call' CTA..."))
            .success(just_log_success("Clicked on the 'Book a call' CTA!")),
    ),
    drive_page(
        act(on_book_a_call_page, verify_book_call_page)
            .failure(just_log_error("Failed to verify the 'Book a call' page..."))
            .success(log_success_with_current_url_and_take_screenshot("On the 'Book a call' page!")),
    ),
]

Ce code n’exécute rien. Il décrit :

Chapitre 04 — Tests internes du framework

Chapitre 04 — Tests internes du framework#

Comment Ocarina se teste lui-même. Cinq familles de tests, une politique de couverture lucide, un rapport Allure historisé sur GitHub Pages.

Plan#

#FichierSujet
0101-strategy.mdStratégie « dehors comme un utilisateur » + le conftest.py (FakeDriver, RecordingPOM, builders).
0202-cram-prysk.mdCram tests (prysk) : fichiers .t
0303-pytest-scenarios.mdScénarios de test appliqués au framework (pytest + allure + hypothesis).
0404-mypy-plugins-types.mdTests sur le typage statiques via pytest-mypy-plugins (*.yml).
0505-syrupy-snapshots.mdSnapshot tests (syrupy) pour pretty_print_results et results_to_json.
0606-hypothesis-properties.mdProperty-based testing pour les invariants.
0707-coverage-policy.mdPolitique de couverture : ce qui est testé, ce qui ne l’est PAS, pourquoi.
0808-allure-history.mdAllure + action composite allure-history + déploiement GH Pages.

Tableau récapitulatif#

FamilleOutilQuantitéSujetCible
Scénariospytest + allure-pytest~15 fichiers test_*.pyDSL, orchestration, acteur PlaywrightCouvre le comportement
Cramprysk15 fichiers .tCLI Selenium + Playwright : parsing, validations, defaultsCouvre la surface utilisateur CLI
Types statiquespytest-mypy-plugins~5 fichiers *test_types.ymlInférences de type, narrowing, erreurs attenduesCouvre le typage
Snapshotssyrupy2 fichiers .ambrSortie de pretty_print_results, results_to_jsonCouvre le format de sortie
Property-basedhypothesis1 fichier (test_invariants_properties.py)Comportement sur valeurs aléatoiresCouvre la robustesse aux inputs

Approche#

Dans le fichier conftest.py :

02.03.06 — drive_page

02.03.06 — drive_page#

Fichier source : src/ocarina/opinionated/dsl/drive_page.py

Code#

def drive_page(
    first: ActionSuccess[TPOM], *rest: ActionSuccess[TPOM]
) -> ChainRunner[TPOM]:
    return chain_actions(first, *rest)

C’est exactement chain_actions.

Pourquoi cet alias ?#

1. Sémantique : « je prends le contrôle d’une page »#

Citation du Holy Book (Premiers scénarios) :

drive_page exprime que l’on prend le contrôle d’une page. Toute transition devient explicite par l’ouverture d’un nouveau drive_page.

return [
    drive_page(
        act(on_homepage, open_homepage)...,
        act(on_homepage, verify_homepage)...,
        act(on_homepage, click_cta)...,
    ),                                          # ⬅ fermeture du contrôle de la homepage
    drive_page(                                 # ⬅ ouverture du contrôle de la page suivante
        act(on_target_page, verify_target_page)...,
    ),
]

2. Discipline#

Mélanger des actes sur deux POMs différents dans un même drive_page devient une erreur mypy (cf. 02-action-chain-states.md, section « narrowing par act() »). Cela force concrètement à respecter la sémantique.

02.06 — Scenario[Driver]

02.06 — Scenario[Driver]#

Fichier source : src/ocarina/custom_types/scenario.py

Dataclass#

@final
@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)
# src/ocarina/custom_types/test_components.py
type TestChain = Sequence[ChainRunner[Any]]
type TestSetup = Effect | None
type TestTeardown = Effect | None
type TestWatchers[Driver] = Sequence[Watcher[Driver]] | None

Cycle de vie (per attempt)#

1. setup()           — optionnel, Effect libre (DB, API, …)
       → lève     :  skip de test_chain, jump à teardown,
                     return Outcome(setup_failed=True, should_retry=True)
       → ok       :  continue à test_chain

2. test_chain        — la chaîne réelle (Sequence[ChainRunner])
       (les watchers tournent pendant ce temps)

3. teardown()        — optionnel, toujours exécuté
       → lève     :  log warning + ignore (n'affecte pas le verdict)

Si TOUTES les tentatives lèvent au setup :
    → test marqué SKIPPED (pas FAILED)
    → log warning « setup keeps failing »

setup et teardown#

setup et teardown sont driver-free et injectionless by design.

09.03.07 — Skills Refactor

09.03.07 — Skills Refactor#

Skills qui refactor la base de tests automatisés existante.

Listing (potentiellement non exhaustif)#

SkillCible
refactor-fragmentationDRY selon préférence utilisateur
introduce-pom-retriesRetries internes aux POMs, avec dédoublement (first-try + with-retries)

refactor-fragmentation#

input  : la base de tests
output : suggestions de refactor DRY :
            - blocs répétés dans 3+ scénarios → extract en fragment
            - connectors quasi-identiques → extract un connector paramétré
            - séquences de log+screenshot répétées → extract un helper
         + recommandation au cas par cas

CLAUDE.md :