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.

04.02 — Cram tests (prysk)

04.02 — Cram tests (prysk)#

Tests CLI au format cram : un fichier .t contient des commandes shell et leur sortie attendue. Outil utilisé : prysk (réécriture moderne de cram).

Fichier .t#

Valid args produce a deterministic parsed config

  $ touch driver
  $ "$PYTHON" "$TESTDIR/_demo_cli.py" --browser firefox --driver-path driver --workers 3 --wait-timeout 20 --logger terminal
  browser=firefox
  driver_path=driver
  headless=True
  workers=3
  wait_timeout=20
  logger=terminal
  only=()
  exclude=()
  • Première ligne : titre (commentaire pour l’humain).
  • Ligne vide.
  • Lignes commençant par $ : commande shell exécutée.
  • Lignes suivantes (indent 2) : sortie attendue.

Si la sortie réelle diffère : le test fail.

04.03 — Tests scénarios (pytest + allure)

04.03 — Tests scénarios (pytest + allure)#

15 fichiers test_*.py du dossier tests/scenarios/ couvrent le DSL, l’orchestration et l’acteur Playwright. Tests dynamiques.

Listing#

tests/scenarios/
├── conftest.py                              # FakeDriver, RecordingPOM, make_*
├── test_railway_and_action_chain.py         # create_act, drive_page, Result, ChainRunner
├── test_match_page.py                       # match_page / when (premières / dernières branches, exceptions)
├── test_validate.py                         # validate, assert_that, otherwise, then, chain_validations
├── test_invariants_properties.py            # hypothesis (cf. 06-hypothesis-properties)
├── test_drivers_pool.py                     # WebDriversPool (acquire, warmup, shutdown, semaphore leaks)
├── test_screenshotter.py                    # Screenshotter (single shot, burst, healthcheck, threadsafety)
├── test_driver_builder.py                   # DriverBuilder (profile tmp dir, dispose)
├── test_watcher.py                          # Watcher (start, stop, callback errors swallowed, dedup cache)
├── test_playwright_driver_actor.py          # acteur PlaywrightDriver (sync_playwright mocké, sans navigateur)
├── test_playwright_adapter.py               # smoke navigateur réel (skip si pas de Chromium installé)
├── test_test_suite.py                       # TestSuite (parallel, saturation, only/exclude, transient_errors)
├── test_test_cycle_and_bootstrap.py         # TestCycle, mode fail-fast vs wait-for-all, bootstrap
├── test_loggers_and_reports.py              # PrintLogger, FileLogger, pretty_print, JSON
├── test_docx_tests_proofs.py                # generate_docx_proof (parse logs, insert images)
└── test_env.py                              # smoke test : versions, Python 3.14+

Exemple de test interne#

@allure.epic("Railway / action chain")
@allure.feature("Action chain")
@allure.tag("act", "handlers", "happy-path")
@allure.severity(allure.severity_level.CRITICAL)
@allure.label("layer", "unit")
@allure.title("A successful act reports success; the success handler fires once")
def test_success_act_fires_success_handler() -> None:
    pom = RecordingPOM()
    calls = {"success": 0, "failure": 0}

    chain = (
        create_act(pom, lambda p: p.step("click"))
        .failure(lambda _: calls.__setitem__("failure", calls["failure"] + 1))
        .success(lambda: calls.__setitem__("success", calls["success"] + 1))
        .execute()
    )

    assert chain.is_ok()
    assert pom.calls == ["click"]
    assert calls == {"success": 1, "failure": 0}
AssertionCible
chain.is_ok()API publique
pom.calls == ["click"]comportement observable du POM (recording)
calls == {"success": 1, "failure": 0}bon handler appelé

Annotations Allure#

CatégorieValeurs typiques
epic“Railway / action chain”, “Invariants”, “Orchestration”, “Watcher”, “Drivers pool”, “Reports”, “Match page”
feature“Action chain”, “Validate”, “TestSuite”, “TestCycle”, “Bootstrap”, “Pretty print”, “JSON”, “DOCX proofs”
tag“act”, “handlers”, “happy-path”, “error-handling”, “retry”, “transient”, “smoke”, “skip”, “saturation”, “parallel”
severityCRITICAL > NORMAL > MINOR > TRIVIAL
label("layer", ...)“unit”, “integration”

test_test_suite.py#

  • Tests passent en mode séquentiel (max_workers=1).
  • Tests passent en mode parallèle (max_workers=N).
  • Filtrage --only--exclude.
  • Mutex --only + --exclude.
  • Saturation : tests clonés avec [COPY N].
  • Retries sur transient errors.
  • Setup failure → SKIPPED si tous les attempts ratent dès le setup.
  • Test skipped=True s’arrête directement.
  • Garde-fous (noms uniques, IDs uniques).
  • take_screenshot appelé sur fail si autoscreen_on_fail.
  • Comptage exact des tentatives sur max_retries (un test couvre le bord de la boucle).

test_match_page.py#

CasAssertion
Première branche matche → exécutéechain.is_ok(), branches suivantes non évaluées
Deuxième branche matche → exécutéeid.
Aucune branche ne matcheNoMatchingBranchError dans le Fail
Branche matche, mais son then failFail propagé
condition lève une Exception ordinaireTraité comme False, branches suivantes essayées
condition lève une exception du tuple raised_exceptionsRe-raise
Branche then=[] matcheOk(None)
match_page au milieu d’un test_chainCourt-circuite si fail

test_drivers_pool.py#

Note : utilisation d’un FakeDriverFactory qui peut être configuré pour lever / bloquer / dormir, et qui compte ses appels.

04.04 — Tests du typage statique (pytest-mypy-plugins)

04.04 — Tests du typage statique (pytest-mypy-plugins)#

Cinq fichiers *test_types.yml qui testent le comportement du checker. C’est une dimension de tests unique à Ocarina dans son genre : on s’assure que ce qui devrait être une erreur mypy en est bien une.

Listing#

tests/
├── dsl/
│   ├── invariants/test_types.yml
│   └── testing_with_railway/test_types.yml
└── opinionated/
    ├── cli/test_types.yml
    ├── infra/test_types.yml
    └── plugins/reports/...   (snapshots — pas YAML)

YAML#

- case: compatible_predicate_types
  description: Predicates should accept values of compatible types
  main: |
    from ocarina.dsl.invariants.validate import validate
    from ocarina.dsl.invariants.assertions import is_email, is_positive

    validate(1234).assert_that(is_positive)
    validate("a@a.com").assert_that(is_email)

Sans out: → on attend que mypy passe sans erreur sur le code donné.

04.05 — Snapshot testing (syrupy)

04.05 — Snapshot testing (syrupy)#

syrupy est un plugin pytest qui sérialise la sortie d’un test dans un fichier .ambr et compare aux runs suivants. Utilisé pour la sortie de pretty_print_results et results_to_json.

.ambr#

tests/opinionated/plugins/reports/__snapshots__/
├── test_pretty_print_results.ambr
└── test_results_to_json.ambr

Généré et géré par syrupy.

Exemple#

# tests/opinionated/plugins/reports/test_pretty_print_results.py
def test_pretty_print_results_renders_campaign_suite_test(snapshot, capsys):
    results = {
        "Dashboard": {
            "Login happy paths": {
                "Login - without OTP": (Ok(None), 5, "login_no_otp"),
                "Login - with OTP": (Fail(error=RuntimeError("OTP missed")), 8, "login_otp"),
                "Skipped one": (None, -1, "skipped_one"),
            },
        },
    }
    pretty_print_results(results, with_colors=False)
    out = capsys.readouterr().out
    assert out == snapshot

Le snapshot (fixture de syrupy) :

04.06 — Property-based testing (hypothesis)

04.06 — Property-based testing (hypothesis)#

Un seul fichier (test_invariants_properties.py), mais le pattern est intéressant : on génère des inputs et on vérifie des propriétés universelles des prédicats d’invariants.

Le concept#

hypothesis génère automatiquement des cas de test qui satisfont des contraintes données, puis vérifie qu’une propriété est vraie pour tous les cas générés. En cas d’échec, hypothesis fait du shrinking pour trouver le plus petit contre-exemple.

from hypothesis import given, strategies as st

@given(st.integers())
def test_is_positive_passes_on_positive(value):
    if value >= 0:
        is_positive(value)             # ne doit pas lever
    else:
        with pytest.raises(InvariantViolationError):
            is_positive(value)         # doit lever

hypothesis génère des entiers (typiquement 100 par défaut), vérifie la propriété pour chacun. Si une exception inattendue surgit, c’est un fail. Le shrinker trouve le plus petit cas qui fail (0 ? -1 ? MIN_INT ?).

04.07 — Politique de couverture : ce qui est testé, ce qui ne l'est pas, pourquoi

04.07 — Politique de couverture : ce qui est testé, ce qui ne l’est pas, pourquoi#

La couverture par ligne est explicitement scopée au DSL pur et à l’infra agnostique. Le reste est testé par d’autres moyens (cram, types, snapshots, e2e) ou hors scope (shapes inertes).

pyproject.toml#tool.coverage.run#

[tool.coverage.run]
omit = [
    "*/__init__.py",
    "*/tests/*",
    "*/test_*.py",
    "src/ocarina/custom_errors/*",
    "src/ocarina/custom_types/*",
    "src/ocarina/ports/*",
    "src/ocarina/opinionated/loggers/custom_types/*",
    "src/**/*singleton.py",
    "src/**/consts/**",
    # Selenium adapter layer — exercised only against a real browser.
    "src/ocarina/infra/selenium/*",
    "src/ocarina/dsl/testing/selenium/*",
    "src/ocarina/opinionated/cli/selenium/*",
    "src/ocarina/pom/selenium/muted.py",
    # Playwright adapter layer — exercised only against a real browser.
    "src/ocarina/infra/playwright/*",
    "src/ocarina/dsl/testing/playwright/*",
    "src/ocarina/opinionated/cli/playwright/*",
    "src/ocarina/pom/playwright/*",
    "src/ocarina/opinionated/cli/phantoms.py",
    # Opinionated loggers — only file_logger.py stays in scope.
    "src/ocarina/opinionated/loggers/create_matching_logger.py",
    "src/ocarina/opinionated/loggers/muted_logger.py",
    "src/ocarina/opinionated/loggers/print_logger.py",
    "src/ocarina/opinionated/loggers/print_and_file_logger.py",
    "src/ocarina/opinionated/loggers/utils/*",
    # Traversed by cram tests
    "src/ocarina/opinionated/cli/store.py",
    "src/ocarina/opinionated/cli/builder.py",
]

Catégories#

1. Exclusions évidentes#

PatternJustification
*/__init__.pyModules vides (Python 3 n’en a souvent pas besoin)
*/tests/*, */test_*.pyLes tests ne se testent pas eux-mêmes

2. Types / errors / ports / shapes#

CheminJustification
src/ocarina/custom_types/*Juste des type X = ... et frozen dataclasses. Pas de logique runtime.
src/ocarina/custom_errors/*Juste des sous-classes d’Exception. Pas de logique.
src/ocarina/ports/*ABCs et Protocols. Pas de comportement.
src/ocarina/opinionated/loggers/custom_types/*id.
src/**/consts/**Constantes (LOGGERS_CHOICES = (...)).
src/**/*singleton.pyWrappers Singleton triviaux.

3. Couches Selenium et Playwright (e2e seulement)#

CheminJustification
src/ocarina/infra/selenium/*Code Selenium réel (Chrome(), Firefox()). Ne s’exécute qu’avec un vrai navigateur.
src/ocarina/dsl/testing/selenium/*create_selenium_test, create_selenium_watcher (factory triviales sur TestWatcher).
src/ocarina/opinionated/cli/selenium/*create_selenium_*_cli_store (lit platform.system, instancie).
src/ocarina/pom/selenium/muted.pyMutedPOM utilitaire (presque vide).
src/ocarina/infra/playwright/*Code Playwright réel + l’acteur PlaywrightDriver. Le test de bout en bout ne se prouve qu’avec un vrai navigateur.
src/ocarina/dsl/testing/playwright/*create_playwright_test, create_playwright_watcher (mêmes factory triviales).
src/ocarina/opinionated/cli/playwright/*create_playwright_cli_store (CLI Playwright).
src/ocarina/pom/playwright/*MutedPlaywrightPOM + mixins (Null Object, presque vide).

Ces fichiers sont couverts par :

04.08 — Allure + action composite allure-history + déploiement GH Pages

04.08 — Allure + action composite allure-history + déploiement GH Pages#

Le rapport Allure est généré à chaque CI, historisé sur une branche Git dédiée, puis publié sur GitHub Pages. Live URL : https://mojo-molotov.github.io/ocarina/allure-report/.

Pipeline#

┌────────────────────────────────────────────────────────┐
│ main_ci.yml (push/PR main, ou dispatch)                │
└──────────────────┬─────────────────────────────────────┘
                   ▼
   ┌───────────────────────────────────┐
   │ job : test                        │
   │  - matrix (ubuntu / windows × py) │
   │  - make test (cram + pytest)      │
   │  - upload-artifact allure-results │
   └───────────────┬───────────────────┘
                   ▼
   ┌───────────────────────────────────┐
   │ job : allure-history              │
   │  - download all allure-results    │
   │  - action composite locale :      │
   │       allure-history/action.yml   │
   │  - upload-artifact allure-report  │
   └───────────────┬───────────────────┘
                   ▼
   ┌───────────────────────────────────┐
   │ job : deploy                      │
   │  - download allure-report         │
   │  - prepare pages-root/            │
   │  - actions/upload-pages-artifact  │
   │  - actions/deploy-pages           │
   └───────────────────────────────────┘

allure-history#

.github/actions/allure-history/action.yml : action composite locale au repo (uses: ./.github/actions/allure-history).