🔍 Visual Testing & OCR

De zéro à expert · Par Julien MER — QA Architect · 20 ans de terrain · 230+ tests en production

1. Le problème réel

Depuis 20 ans, les outils d'automatisation reposent sur une hypothèse implicite : l'application expose une structure programmatique interrogeable. Un DOM HTML, un arbre d'accessibilité Android, une API. Selenium cherche un id. Playwright attend un sélecteur CSS. Appium interroge un arbre de vues.

Cette hypothèse fonctionne pour les applications web modernes et les apps mobiles bien construites. Elle s'effondre dès qu'on sort de ce cadre. Et dans l'industrie, on en sort bien plus souvent qu'on ne le croit.

🟣 Retour terrain : dans les secteurs critiques — défense, biotech, retail, aéronautique — la majorité des applications à tester n'ont ni DOM, ni API, ni sélecteur. Des interfaces legacy vieilles de 20 ans, accessibles uniquement via VNC sur des réseaux contraints. Aucun outil classique ne peut y toucher. Le visual testing est la seule solution.

Ce que tu vas rencontrer sur le terrain

  • Un ERP legacy des années 90 avec une interface C++ sans hook d'automatisation
  • Un logiciel de caisse propriétaire dont personne ne connaît le code source
  • Une application Citrix qui n'est qu'un flux de pixels sur le réseau
  • Un terminal Linux affiché via VNC dans un datacenter distant
  • Un écran embarqué sur un équipement industriel
⚠️ La question que personne ne pose en entretien : "Comment tu testes une application qui n'a pas de sélecteur ?" C'est pourtant le problème numéro 1 en secteurs critiques.

2. Quand les outils classiques échouent

ContextePourquoi c'est bloquantExemples
Desktop natifPas de DOM, pas de sélecteur CSSWPF, Swing, Qt, Delphi
ERP / logiciel métier propriétaireAucun hook d'automatisation exposéSAP GUI, Oracle Forms, CEGID
Citrix / VNC / RDPFlux de pixels uniquementSessions desktop distantes, mainframes
Legacy LinuxInterface graphique via X11 ou VNC uniquementOutils internes déployés depuis 15+ ans
Applications embarquéesPas d'OS standard, interface sur écran dédiéBornes, équipements industriels
Canvas HTML5Pas d'élément interactif interrogeableGraphiques, éditeurs visuels
La règle de décision :
— Il y a un DOM accessible → Playwright / Selenium
— Il n'y a pas de DOM ou on veut valider l'affichage visuel → Visual Testing
— On veut lire du texte rendu visuellement → OCR
Les deux coexistent souvent dans un même projet.

3. Le principe du visual testing

Le visual testing inverse la logique : au lieu de chercher un élément dans une structure programmatique, on regarde l'écran comme un humain.

🖼️ Reconnaissance d'images

On capture une image de référence d'un bouton. Le moteur cherche cette image à l'écran par template matching. Quand il la trouve, il peut cliquer, hoverer, interagir.

🔤 Reconnaissance de texte (OCR)

Le moteur lit le texte affiché à l'écran, même s'il est rendu sous forme d'image. OCR extrait le texte lisible par un humain.

Le template matching

Le template matching compare pixel par pixel une image de référence avec les régions de l'écran et calcule un score de similarité entre 0 et 1.

⚠️ Le seuil par défaut est trop bas. SikuliX met 0.7 par défaut — ce qui génère des faux positifs en production. En pratique, 0.90 à 0.95 est le bon réglage.

4. Les outils du marché

OutilPoints fortsLimitesPrix
OculiXFork actif de SikuliX, VNC complet, SSH natif, Android ADB, multi-runnersPas encore sur Maven CentralGratuit / MIT
SikuliXMature, VNC intégré, OCR Tesseract, Java natifArchivé mars 2026 → OculiX prend le relaisGratuit / MIT
ApplitoolsIA pour comparer les captures, très précisCoûteux, dépendance cloud500€+/mois
PyAutoGUISimple, Python natifMatching basique, pas de VNC, fragileGratuit
🟣 Choix terrain : SikuliX / OculiX s'impose pour les projets enterprise — intégration Maven/Gradle native, Jenkins, VNC natif, zéro SaaS. C'est la seule solution production viable à coût zéro.

5. Pourquoi SikuliX + double moteur OCR

SikuliX embarque Tesseract — solide sur du texte standard en latin sur fond blanc. Mais il échoue sur texte coloré, polices non standard, captures basse résolution, caractères asiatiques.

La stratégie : un double moteur OCR en cascade.

🔤 Moteur 1 — Tesseract

Rapide (50ms), intégré à SikuliX, excellent sur texte simple. Tentative en premier.

🔤 Moteur 2 — PaddleOCR

Basé sur deep learning (300ms), performant sur cas complexes. Déclenché si Tesseract renvoie une confiance insuffisante.

Résultat en production sur 230+ tests : 89% des lectures via Tesseract (52ms en moyenne), 11% via PaddleOCR (310ms). Fiabilité globale : 99.3%.

6. Ce que tu vas savoir faire

✅ Structurer le projet

Arborescence, dossier d'images, classes de constantes — avant même d'écrire un test.

✅ Concevoir les managers

ClickManager, WaitManager, OCRManager, TypeManager, RegionManager, CaptureManager.

✅ Capturer les images

Avec SikuliX IDE, les bons réglages, les bonnes dimensions.

✅ Écrire un test de A à Z

Setup, actions, assertions, gestion d'erreurs, rapport.

✅ OCR double moteur

Lire du texte sur n'importe quel fond, valider des valeurs numériques.

✅ VNC et CI/CD

Docker headless, Jenkins pipeline, tests sur serveurs distants.

1. Arborescence du projet

Avant d'écrire une seule ligne de test, la structure du projet doit être en place. C'est ce qui fait la différence entre un framework maintenable et un chaos de fichiers éparpillés.

Voici la structure recommandée, indépendante du framework utilisé (Maven, Gradle, Katalon) :

MonProjet/
├── Pattern/ # Toutes les images de référence
│ ├── Navigation/ # Menus, onglets, boutons de navigation
│ │ ├── menu_principal.png
│ │ ├── btn_retour.png
│ │ └── icone_accueil.png
│ ├── Actions/ # Boutons d'action génériques
│ │ ├── btn_valider.png
│ │ ├── btn_annuler.png
│ │ └── btn_supprimer.png
│ ├── Statuts/ # Indicateurs visuels
│ │ ├── icone_succes.png
│ │ ├── icone_erreur.png
│ │ └── spinner_chargement.png
│ ├── ModuleStock/ # Images spécifiques au module Stock
│ │ ├── formulaire_mouvement.png
│ │ └── tableau_stock.png
│ └── ScenarioElements/ # Images de validation finale de scénarios
│ └── connexion_reussie_validation.png
├── Keywords/ # Les managers techniques
│ ├── ScreenOperationsManager.java
│ ├── ClickManager.java
│ ├── WaitManager.java
│ ├── OCRManager.java
│ ├── TypeManager.java
│ ├── RegionManager.java
│ └── CaptureManager.java
├── Constants/ # Centralise les chemins d'images
│ ├── ImageConstants.java # Navigation, Actions, Statuts
│ └── StockImageConstants.java # Images du module Stock
├── Tests/ # Les cas de test
│ └── TestConnexion.java
└── FailureScreenshots/ # Screenshots d'échec générés automatiquement
💡 Le dossier Pattern/ est le cœur du framework. Toutes les images de référence y vivent. Jamais un chemin d'image en dur dans un test — toujours via une constante.

Pourquoi cette séparation ?

  • Pattern/ — les images de référence versionées dans Git. Si l'interface change, on ne touche qu'aux images, pas au code.
  • Keywords/ — les managers techniques qui encapsulent SikuliX. Un test n'importe jamais SikuliX directement.
  • Constants/ — la colle entre les deux. Centralise les chemins pour qu'un renommage de fichier ne casse qu'un seul endroit.

2. Capturer les images de référence avec SikuliX IDE

C'est l'étape que tous les débutants ratent. Avant d'écrire du code, il faut créer les images de référence. La qualité de ces images détermine 80% de la fiabilité du framework.

Étape 1 — Ouvrir SikuliX IDE

SikuliX IDE est livré avec l'archive SikuliX. Lance runsikulix.cmd (Windows) ou runsikulix.sh (Linux/Mac). L'IDE s'ouvre avec un éditeur de script intégré.

Utiliser l'outil de capture intégré

  1. Dans SikuliX IDE, clique sur le bouton 📷 "Take screenshot" dans la barre d'outils
  2. L'écran se fige et un réticule apparaît
  3. Dessine un rectangle autour de l'élément à capturer — uniquement l'élément, pas le fond
  4. Relâche — l'image est automatiquement insérée dans le script
  5. Clique-droit sur l'image dans le script → "Save as" → sauvegarde dans Pattern/

Les règles d'une bonne image de référence

📐 Taille minimale

Capture uniquement l'élément, pas son entourage. Un bouton : juste le bouton. Plus l'image est petite et précise, plus le matching est fiable.

🖥️ Résolution fixe

Capture toujours à la même résolution que l'environnement d'exécution. Une image capturée en 4K ne matchera pas sur un écran FullHD.

🎯 Élément stable

Évite les zones dynamiques : compteurs, horodatages, données variables. Préfère les labels, icônes, contours de boutons.

📝 Nommage explicite

btn_valider_panier.png pas img1.png. Le nom doit permettre de savoir ce que c'est sans ouvrir le fichier.

🔴 Piège classique — L'image trop grande : si tu captures toute la fenêtre au lieu de juste le bouton, SikuliX cherchera cette grande image à l'écran. Au moindre déplacement de fenêtre ou changement de fond, le matching échoue. Règle absolue : l'image doit être la plus petite possible tout en restant unique à l'écran.

Étape 2 — Valider l'image capturée

Avant de committer l'image dans le projet, valide-la dans SikuliX IDE :

// Dans l'IDE SikuliX, tape ce mini-script et lance-le
Screen s = new Screen();
if (s.exists("Pattern/Actions/btn_valider.png", 3) != null) {
    s.highlight(2); // Surligne ce qui a été trouvé
    System.out.println("Image valide — score : " + s.find("Pattern/Actions/btn_valider.png").getScore());
} else {
    System.out.println("Image non trouvée — recapturer");
}

Le score retourné doit être supérieur à 0.92. En dessous, recapture l'image.

3. La classe de constantes d'images

C'est LE fichier le plus important du framework. Il centralise tous les chemins d'images. Jamais un chemin en dur dans un test. Jamais.

Pourquoi c'est indispensable

Sans cette classe, si tu renommes btn_valider.png en btn_confirmer.png, tu dois chercher et remplacer dans tous tes tests. Avec cette classe, tu modifies un seul endroit.

/**
 * Centralise tous les chemins d'images de référence du framework.
 * RÈGLE : aucun chemin d'image ne doit apparaître directement dans un test.
 */
public class ImageConstants {

    // ── Répertoire racine des images ──
    private static final String BASE = "Pattern/";

    // ── Navigation ──
    public static final String MENU_PRINCIPAL     = BASE + "Navigation/menu_principal.png";
    public static final String BTN_RETOUR         = BASE + "Navigation/btn_retour.png";
    public static final String ICONE_ACCUEIL      = BASE + "Navigation/icone_accueil.png";
    public static final String ONGLET_STOCK       = BASE + "Navigation/onglet_stock.png";

    // ── Actions génériques ──
    public static final String BTN_VALIDER        = BASE + "Actions/btn_valider.png";
    public static final String BTN_ANNULER        = BASE + "Actions/btn_annuler.png";
    public static final String BTN_SUPPRIMER      = BASE + "Actions/btn_supprimer.png";
    public static final String BTN_NOUVEAU        = BASE + "Actions/btn_nouveau.png";
    public static final String BTN_RECHERCHER     = BASE + "Actions/btn_rechercher.png";

    // ── Statuts et indicateurs ──
    public static final String ICONE_SUCCES       = BASE + "Statuts/icone_succes.png";
    public static final String ICONE_ERREUR       = BASE + "Statuts/icone_erreur.png";
    public static final String SPINNER            = BASE + "Statuts/spinner_chargement.png";
    public static final String DIALOGUE_CONFIRM   = BASE + "Statuts/dialogue_confirmation.png";
    public static final String BANDEAU_ALERTE     = BASE + "Statuts/bandeau_alerte.png";

    // ── Champs de saisie (labels permettant de localiser les champs) ──
    public static final String LABEL_IDENTIFIANT  = BASE + "Champs/label_identifiant.png";
    public static final String LABEL_MOT_DE_PASSE = BASE + "Champs/label_mot_de_passe.png";
    public static final String LABEL_MONTANT      = BASE + "Champs/label_montant.png";

    // Constructeur privé — classe utilitaire, pas d'instanciation
    private ImageConstants() {}
}

Une constante par module

Pour les modules applicatifs complexes, crée une constante dédiée :

/**
 * Constantes d'images spécifiques au module de gestion des stocks.
 */
public class StockImageConstants {

    private static final String BASE = "Pattern/ModuleStock/";

    public static final String TITRE_MODULE          = BASE + "titre_module_stock.png";
    public static final String BTN_NOUVEAU_MOUVEMENT = BASE + "btn_nouveau_mouvement.png";
    public static final String FORMULAIRE_MOUVEMENT  = BASE + "formulaire_mouvement.png";
    public static final String TABLEAU_RESULTATS     = BASE + "tableau_resultats.png";
    public static final String LIGNE_STOCK           = BASE + "ligne_stock_template.png";

    private StockImageConstants() {}
}
Résultat : dans un test, tu écris ImageConstants.BTN_VALIDER — jamais "Pattern/Actions/btn_valider.png". Si l'image change de nom ou de dossier, une seule ligne à modifier dans toute la codebase.

4. Organisation par module — Scaler le framework

Sur un projet de 100+ tests, une seule classe ImageConstants devient ingérable. On découpe par domaine fonctionnel.

Constants/
├── ImageConstants.java # Navigation, Actions, Statuts communs
├── StockImageConstants.java # Module gestion des stocks
├── CommandeImageConstants.java # Module commandes
├── LoginImageConstants.java # Écrans d'authentification
└── RapportImageConstants.java # Module rapports et exports
Pattern/
├── Navigation/ # → ImageConstants
├── Actions/ # → ImageConstants
├── Statuts/ # → ImageConstants
├── ModuleStock/ # → StockImageConstants
├── ModuleCommande/ # → CommandeImageConstants
└── Login/ # → LoginImageConstants

Règle de découpage

Une classe de constantes = un domaine fonctionnel cohérent. Si tu dois chercher dans deux classes pour écrire un test, c'est que le découpage est mal fait.

5. Les classes métier

Au-dessus des managers techniques, on peut créer des classes métier qui expriment des actions fonctionnelles. Ce n'est pas obligatoire mais cela améliore la lisibilité des tests.

⚠️ Important : les classes métier sont optionnelles. Les managers techniques suffisent pour écrire des tests complets. Les classes métier sont un bonus de lisibilité, pas une obligation architecturale.
/**
 * Exemple de classe métier — Login.
 * Regroupe les actions liées à la connexion en un vocabulaire fonctionnel.
 * Elle utilise les managers techniques — jamais SikuliX directement.
 */
public class LoginActions {

    private final ScreenOperationsManager som;

    public LoginActions(ScreenOperationsManager som) {
        this.som = som;
    }

    public boolean seConnecter(String identifiant, String motDePasse) {
        // Attendre l'écran de login
        if (!som.waitForElement(LoginImageConstants.ECRAN_LOGIN, 20)) return false;

        // Saisir les identifiants
        som.insertText(LoginImageConstants.CHAMP_IDENTIFIANT, identifiant);
        som.typeTextWithEnter(motDePasse);

        // Cliquer sur "Se connecter"
        som.clickOn(LoginImageConstants.BTN_SE_CONNECTER);

        // Vérifier le succès
        return som.waitForElement(ImageConstants.ICONE_ACCUEIL, 10);
    }

    public void seDeconnecter() {
        som.clickOn(ImageConstants.MENU_PRINCIPAL);
        som.waitForElement(LoginImageConstants.BTN_DECONNEXION, 5);
        som.clickOn(LoginImageConstants.BTN_DECONNEXION);
    }
}

Le test devient alors :

// Avec classes métier — lisible par un non-développeur
LoginActions login = new LoginActions(som);
login.seConnecter("admin", "motdepasse");

// Sans classes métier — directement avec les managers
som.waitForElement(LoginImageConstants.ECRAN_LOGIN, 20);
som.insertText(LoginImageConstants.CHAMP_IDENTIFIANT, "admin");
som.typeTextWithEnter("motdepasse");
som.clickOn(LoginImageConstants.BTN_SE_CONNECTER);
💡 Les deux approches sont valides. En pratique, commence par les managers directs. Extrais en classes métier uniquement quand une séquence est répétée dans plus de 3 tests.

Architecture d'ensemble

Le framework est structuré en managers techniques spécialisés. Chaque manager a une responsabilité unique. Un test ou une classe métier n'importe jamais SikuliX directement — il passe toujours par un manager.

┌─────────────────────────────────┐
│ Test / Classe Métier │
└────────────────┬────────────────┘

┌────────────────▼────────────────┐
│ ScreenOperationsManager │ ← Point d'entrée unique
└──┬──────┬──────┬──────┬────┬───┘
│ │ │ │ │
Click Wait Type OCR Region Capture
Mgr Mgr Mgr Mgr Mgr Mgr
│ │ │ │ │
┌──▼──────▼──────▼──────▼────▼───┐
│ SikuliX Engine │
└─────────────────────────────────┘

Principe de délégation

ScreenOperationsManager est le seul point d'entrée exposé aux tests. Il délègue chaque type d'action au manager spécialisé correspondant. Les tests ne savent pas que ClickManager ou WaitManager existent.

// Dans ScreenOperationsManager — délégation aux managers spécialisés
public class ScreenOperationsManager {

    private final ClickManager   clickManager;
    private final WaitManager    waitManager;
    private final TypeManager    typeManager;
    private final OCRManager     ocrManager;
    private final RegionManager  regionManager;
    private final CaptureManager captureManager;

    // ThreadLocal pour la thread-safety en exécution parallèle
    private static final ThreadLocal<Region> screenHolder = ThreadLocal.withInitial(Screen::new);

    public ScreenOperationsManager() {
        this.typeManager    = new TypeManager(screenHolder);
        this.clickManager   = new ClickManager(screenHolder, HIGHLIGHT_DELAY, typeManager);
        this.waitManager    = new WaitManager(screenHolder, HIGHLIGHT_DELAY, clickManager);
        this.ocrManager     = new OCRManager(screenHolder);
        this.regionManager  = new RegionManager(screenHolder, HIGHLIGHT_DELAY, waitManager, ocrManager);
        this.captureManager = new CaptureManager(screenHolder);
    }

    // Délégation — le test appelle clickOn, pas clickManager.clickOn
    public void clickOn(String imagePath) {
        clickManager.clickOn(imagePath);
    }

    public boolean waitForElement(String imagePath, int timeout) {
        return waitManager.waitForElement(imagePath, timeout);
    }

    public void typeText(String text) {
        typeManager.typeText(text);
    }

    // ... toutes les autres délégations
}

ThreadLocal — pourquoi c'est important

Le ThreadLocal<Region> garantit que chaque thread d'exécution a son propre écran. En exécution parallèle (plusieurs tests en même temps sur des VNC différents), les screens ne se mélangent pas.

ScreenOperationsManager — Point d'entrée unique

Classe façade qui expose l'API publique complète du framework. Les tests n'interagissent qu'avec cette classe. Elle instancie et délègue à chaque manager spécialisé.

🎯 Responsabilité

Façade unique, instanciation des managers, initialisation de l'écran (local ou VNC), configuration globale SikuliX.

clickOn() waitForElement() typeText() waitForText() findRegionBelow() captureScreen() initVNC()
ClickManager — Toutes les interactions souris

Gère l'intégralité des clics et interactions pointer. Un clic n'est jamais appelé directement sur screenHolder.get() depuis un test — toujours via ce manager.

🖱️ Méthodes principales

clickOn(imagePath) clickOn(imagePath, similarity) doubleClickOn(imagePath) hoverOn(imagePath) clickNearImage(imagePath, offset, direction) clickAndTypeNearImage() clickOnWindowCenter()

Focus sur clickNearImage — une méthode clé

Certaines interfaces n'ont pas de bouton cliquable identifiable par image. Mais elles ont un label stable à côté d'une zone de saisie. clickNearImage clique à une distance et dans une direction données par rapport à une image de référence.

// Cliquer dans le champ de saisie qui est 150px à droite du label "Montant :"
som.clickNearImage(ImageConstants.LABEL_MONTANT, 0.92, 150, "right");

// Puis saisir la valeur
som.typeText("1500");
WaitManager — Attentes et assertions visuelles

Gère toutes les attentes sur des éléments visuels. C'est le manager le plus utilisé dans les tests — chaque interaction commence par une attente.

⏳ Méthodes principales

waitForElement(imagePath, timeout) waitForElement(imagePath, timeout, silentMode) waitForElement(imagePath, timeout, similarity) waitForElementWithSimilarity() waitForAllImages() waitUntilElementVisibleAndClick() waitForImageToDisappear() waitForScreenStable() verifyImagePresence() verifyImageNotPresent() assertElementPresent() validateFinalVisualState()

Le mode silencieux — waitForElement avec silentMode

Parfois tu veux vérifier si un élément est présent sans que son absence soit une erreur. Le mode silencieux ne loggue pas d'erreur si l'élément n'est pas trouvé.

// Mode normal — si non trouvé, log d'erreur et retourne false
boolean present = som.waitForElement(ImageConstants.DIALOGUE_CONFIRM, 3);

// Mode silencieux — vérifie sans bruit si un dialogue optionnel est apparu
boolean dialoguePresent = som.waitForElement(ImageConstants.DIALOGUE_CONFIRM, 3, true);
if (dialoguePresent) {
    som.clickOn(ImageConstants.BTN_VALIDER);
}

validateFinalVisualState — la méthode de validation premium

Valide l'état visuel final d'un scénario par rapport à une image de référence capturée lors des premiers runs validés. Si l'écran ne correspond pas, le test échoue avec une capture d'échec.

// A la fin d'un scénario, valider que l'écran correspond à la référence
som.validateFinalVisualState("Connexion_Standard", "Apres_Connexion_Reussie");
// → Cherche Pattern/ScenarioElements/Connexion_Standard_Apres_Connexion_Reussie.png
// → Si non trouvé avec sim 0.99 → markFailedAndStop + capture d'échec
TypeManager — Saisie clavier

Gère toute la saisie clavier — texte simple, touches spéciales, caractères accentués, combinaisons de touches. La saisie de caractères spéciaux (@, #, €, [, ]) est une source de bugs classique selon le layout clavier.

⌨️ Méthodes principales

typeText(text) typeTextWithEnter(text) pasteText(text) insertText(imagePath, text) pressShiftAlt()

insertText vs typeText — quelle différence ?

// typeText — frappe directe dans la zone active (focus courant)
som.typeText("MonTexte");

// insertText — clique sur l'image puis frappe dans ce champ
// Utile quand le focus n'est pas garanti
som.insertText(ImageConstants.CHAMP_RECHERCHE, "MonTexte");

// typeTextWithEnter — frappe + touche Entrée (valider un formulaire)
som.typeTextWithEnter("MonTexte");
⚠️ Caractères spéciaux et layout clavier : sur un clavier AZERTY français, le @ s'obtient avec AltGr+0. SikuliX type caractère par caractère et peut mal interpréter les caractères spéciaux. Le TypeManager gère ces cas avec une table de correspondance.
OCRManager — Lecture de texte

Gère toutes les opérations de lecture de texte à l'écran via PaddleOCR (moteur principal) et Tesseract (fallback). Permet de lire, attendre et cliquer sur du texte sans image de référence.

🔤 Méthodes principales

waitForText(text) waitForText(text, timeout) waitForText(text, timeout, leftHalf) waitForTextVanish(text, timeout) waitForTextAndClick(text, timeout, fullRegion) clickOnText(text) detectTextPairInActiveApp() waitForTextInRegion(region, text) detectTextAndClick(region, text)

Quand utiliser OCR plutôt que l'image

// Si le texte est dynamique (montant variable), utilise OCR
som.waitForText("Paiement validé");     // Peu importe comment c'est rendu visuellement
som.waitForText("1 250,00 €", 10);      // Attend ce montant précis à l'écran
som.clickOnText("Confirmer");           // Clique sur ce mot où qu'il soit

// Si l'élément est toujours identique visuellement, utilise l'image
som.waitForElement(ImageConstants.BTN_VALIDER, 5);  // Plus rapide, plus précis

detectTextPairInActiveApp — valider deux valeurs ensemble

Cas d'usage réel : valider qu'un libellé et son montant associé apparaissent ensemble sur la même ligne d'un écran de récapitulatif.

// Vérifier que "Remise fidélité" et "-5,00 €" sont sur la même ligne
boolean ok = som.detectTextPairInActiveApp("Remise fidélité", "-5,00 €", 15.0);
// toleranceY=15 = les deux textes doivent être à moins de 15px de hauteur l'un de l'autre
RegionManager — Zones et navigation

Gère les opérations sur des régions de l'écran : recherche de zones relatives à une image, scroll, extraction de texte dans une zone précise, clic par coordonnées OCR.

🗺️ Méthodes principales

findRegionBelow(imagePath, height) validateRegion(imagePath, errorMessage, height) performScrollAndFind(refImage, targetImage, distance, attempts) performScrollAndFindText(refImage, text, distance, attempts) scrollVerticallyFromLeft(xRatio, scrollRatio, up) extractTextNearImage(imagePath, similarity, marginX) clickOnInputByRowAndColumn()

findRegionBelow — localiser un champ par son label

Le pattern le plus utilisé : un label est stable (image de référence), mais le champ de saisie à côté ne l'est pas (valeur variable). findRegionBelow crée une région juste en dessous du label.

// Le champ "Quantité" est sous le label "Quantité :"
Region champQte = som.findRegionBelow(ImageConstants.LABEL_QUANTITE, 35);
champQte.click();
som.typeText("150");

clickOnInputByRowAndColumn — grille OCR

Pour les tableaux sans sélecteur, localise une cellule par l'intersection de sa ligne (texte OCR) et sa colonne (en-tête OCR), puis clique dessus.

// Cliquer sur la cellule "Montant" de la ligne "Carte bancaire"
som.clickOnInputByRowAndColumn("Moyen de paiement", "Carte bancaire", "Montant");
CaptureManager — Captures et rapport

Gère les captures d'écran pour le rapport d'exécution. Chaque échec génère automatiquement une capture horodatée dans FailureScreenshots/.

📸 Méthodes principales

captureScreen(savePath) logInfo(message) logError(message) showAutoClosingPopup(message, duration)
// Capture manuelle à un point clé du test
som.captureScreen(RunConfiguration.getReportFolder());
// → Sauvegarde : FailureScreenshots/NomDuScenario_20240315_143022_Error.png

// Popup de debug pendant le développement (se ferme automatiquement)
som.showAutoClosingPopup("Connexion établie — attente de l'écran principal", 2000);
1 Setup Maven — Intégrer SikuliX

Prérequis

  • Java 11 minimum (java -version)
  • Maven 3.6+ (mvn -version)
  • Un environnement graphique — pas un serveur headless (voir onglet CI/CD)
  • Sur Linux : sudo apt install libopencv-dev tesseract-ocr
⚠️ SikuliX archivé depuis mars 2026. Utilise OculiX pour les nouveaux projets. En attendant la publication Maven Central d'OculiX, utilise SikuliX 2.0.5 pour ces exercices.

pom.xml

<dependency>
    <groupId>com.sikulix</groupId>
    <artifactId>sikulixapi</artifactId>
    <version>2.0.5</version>
</dependency>

Vérification du setup

import org.sikuli.script.Screen;

public class SetupVerification {
    public static void main(String[] args) {
        Screen ecran = new Screen();
        System.out.println("Résolution : " + ecran.w + "x" + ecran.h);
        ecran.capture().save("/tmp/", "verification_setup.png");
        System.out.println("Setup OK — capture sauvegardée");
    }
}

Test attendu

  1. Lance mvn compile exec:java
  2. Tu vois : Résolution : 1920x1080 (ou ta résolution)
  3. Le fichier /tmp/verification_setup.png existe et montre ton écran
🔴 UnsatisfiedLinkError : SikuliX charge des bibliothèques natives. Sur Ubuntu : sudo apt install libopencv-dev tesseract-ocr. Sur Windows : vérifier que le chemin du projet ne contient pas d'espaces.
2 Seuils et Pattern — Calibrer la précision

La table de décision des seuils

SeuilComportementRisque
0.5 – 0.7Matche n'importe quoi qui ressemble vaguement🔴 Faux positifs garantis en prod
0.7 (défaut)Acceptable en démo, inacceptable en prod🟡 Faux positifs occasionnels
0.90 – 0.95Match fiable sur éléments stables✅ Recommandé en production
0.98 – 1.0Identique pixel par pixel🔴 Faux négatifs dès que l'interface change d'un pixel
import org.sikuli.script.*;

// ── Configuration globale — au démarrage du framework ──
Settings.MinSimilarity = 0.92;
Settings.WaitScanRate = 15;       // Scans/seconde pendant wait() — défaut 3
Settings.MoveMouseDelay = 0.0f;   // Supprimer l'animation du curseur en CI

// ── Pattern personnalisé par image ──
Pattern btnValider      = new Pattern(ImageConstants.BTN_VALIDER).similar(0.95);
Pattern iconeChargement = new Pattern(ImageConstants.SPINNER).similar(0.80);

// Logger le score réel pour calibrer
Match result = ecran.find(btnValider);
System.out.println("Score réel : " + result.getScore());

Méthode de calibration

  1. Commencer à 0.70 et logger le score (match.getScore())
  2. Lancer 10 fois dans des conditions variables
  3. Prendre le score minimum observé
  4. Fixer le seuil à minimum - 0.03
  5. Valider sur 50 runs — zéro faux positif, zéro faux négatif
3 Régions ciblées — Accélérer la recherche

Par défaut, SikuliX scanne tout l'écran. Sur un écran 1920x1080, c'est 2 millions de pixels. Avec une région, on réduit à quelques milliers.

💡 Recherche plein écran ≈ 150-300ms. Région 400x300 ≈ 20-40ms. Sur 230 tests avec 5 interactions chacun : plusieurs minutes gagnées par run CI.
// Régions fixes — zones stables de l'interface
Region zoneMenu    = new Region(0,    0,   300, 900);  // Panneau gauche
Region zoneContenu = new Region(300,  0,  1200, 900);  // Zone principale
Region zoneFooter  = new Region(0,   900, 1920, 180);  // Barre du bas

// Chercher dans la zone concernée uniquement
zoneMenu.click(ImageConstants.BTN_MENU_FICHIER);
zoneContenu.wait(ImageConstants.FENETRE_RESULTAT, 5);

// ── Région dynamique — calculée à partir d'un élément trouvé ──
Match labelNom = ecran.find(ImageConstants.LABEL_NOM);
Region champSaisie = new Region(
    labelNom.x + labelNom.w + 10,
    labelNom.y - 2,
    200,
    labelNom.h + 4
);
champSaisie.click();
champSaisie.type("Julien MER");
4 Wait, exists, find — Choisir la bonne méthode
MéthodeBloquantSi non trouvéUsage
wait(img, timeout)OuiFindFailedPrérequis — si absent, le test ne peut pas continuer
find(img)OuiFindFailedL'élément doit être là maintenant, pas de raison d'attendre
exists(img, timeout)Oui mais silencieuxnullVérification optionnelle — l'élément peut ne pas être là
waitVanish(img, timeout)OuiFindFailedAttendre qu'un spinner, un dialogue disparaisse
⚠️ Erreur classique : utiliser find() là où il faut wait(). Si l'élément met 2 secondes à apparaître (animation, chargement réseau), find() lève une exception immédiatement. En production, utilise presque toujours wait().
5 Gestion d'erreurs — Rendre les tests robustes

Retry avec backoff exponentiel

public Match trouverAvecRetry(Screen ecran, String image, int tentativesMax) {
    int tentative = 0;
    while (tentative < tentativesMax) {
        try {
            return ecran.find(image);
        } catch (FindFailed e) {
            tentative++;
            if (tentative == tentativesMax)
                throw new RuntimeException("Élément non trouvé après " + tentativesMax + " tentatives : " + image, e);
            int attente = (int) Math.pow(2, tentative) * 500; // 500ms, 1s, 2s...
            try { Thread.sleep(attente); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
        }
    }
    return null;
}

Gérer les dialogues parasites

// En début de test — fermer les dialogues qui pourraient bloquer
String[] dialoguesConnus = {
    ImageConstants.DIALOGUE_MISE_A_JOUR,
    ImageConstants.DIALOGUE_SESSION_EXPIREE
};
for (String dialogue : dialoguesConnus) {
    if (ecran.exists(dialogue, 1) != null) {
        ecran.click(ImageConstants.BTN_FERMER_DIALOGUE);
    }
}
1 Tesseract natif — Lire du texte

Prérequis

  • Tesseract est embarqué dans SikuliX sur Windows — aucune installation
  • Linux : sudo apt install tesseract-ocr tesseract-ocr-fra
  • Mac : brew install tesseract tesseract-lang
import org.sikuli.script.*;

// ── Lire le texte dans une zone précise ──
Region zonePrix = new Region(500, 300, 200, 50);
String texte = zonePrix.text();

// Nettoyer (espaces parasites, retours à la ligne)
String propre = texte.trim().replaceAll("\\s+", " ");
System.out.println("Lu : '" + propre + "'");

// ── Valider ──
if (propre.contains("12.50") || propre.contains("12,50")) {
    System.out.println("✅ Prix correct");
}

// ── Cliquer sur du texte ──
Match cible = ecran.findText("Valider commande");
if (cible != null) ecran.click(cible);
🔴 Tesseract échoue sur : texte blanc sur fond coloré, polices décoratives, rotation, petites polices, bruit dans l'image, caractères spéciaux. → Utiliser le double moteur.
2 PaddleOCR — Le moteur IA
# Installation
pip install paddlepaddle paddleocr

from paddleocr import PaddleOCR
from PIL import ImageGrab
import numpy as np

ocr = PaddleOCR(use_angle_cls=True, lang='fr', show_log=False)

# ── Lire une zone de l'écran ──
capture = ImageGrab.grab(bbox=(500, 300, 700, 350))
img = np.array(capture)
import cv2
img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)

resultats = ocr.ocr(img_bgr, cls=True)
for ligne in resultats[0]:
    texte     = ligne[1][0]
    confiance = ligne[1][1]
    print(f"'{texte}' — {confiance:.0%}")
CasTesseractPaddleOCR
Texte noir sur fond blanc✅ 99%✅ 99%
Texte blanc sur fond rouge❌ 20%✅ 95%
Police LCD (afficheurs numériques)⚠️ 60%✅ 92%
Image basse résolution⚠️ 45%✅ 80%
Temps de traitement✅ 50ms⚠️ 300ms
3 Double moteur — L'architecture en cascade
class DoubleMoteurOCR:
    """Tesseract en premier (rapide). PaddleOCR si confiance insuffisante."""

    SEUIL = 0.80

    def __init__(self):
        import easyocr
        self.reader = easyocr.Reader(['fr', 'en'], gpu=False, verbose=False)

    def lire_zone(self, x1, y1, x2, y2):
        from PIL import ImageGrab
        import numpy as np
        capture = ImageGrab.grab(bbox=(x1, y1, x2, y2))
        img = np.array(capture)

        # Tentative 1 — Tesseract
        r = self._tesseract(img)
        if r['confiance'] >= self.SEUIL:
            r['moteur'] = 'tesseract'
            return r

        print(f"Tesseract {r['confiance']:.0%} → fallback EasyOCR")

        # Tentative 2 — EasyOCR
        r = self._easyocr(img)
        r['moteur'] = 'easyocr'
        return r

    def _tesseract(self, img):
        import pytesseract
        from PIL import Image
        data = pytesseract.image_to_data(
            Image.fromarray(img), lang='fra+eng',
            output_type=pytesseract.Output.DICT
        )
        mots, confs = [], []
        for i, t in enumerate(data['text']):
            c = data['conf'][i]
            if t.strip() and c > 0:
                mots.append(t)
                confs.append(c / 100.0)
        return {
            'texte': ' '.join(mots).strip(),
            'confiance': sum(confs) / len(confs) if confs else 0.0
        }

    def _easyocr(self, img):
        res = self.reader.readtext(img)
        if not res: return {'texte': '', 'confiance': 0.0}
        return {
            'texte': ' '.join(r[1] for r in res).strip(),
            'confiance': sum(r[2] for r in res) / len(res)
        }

# Utilisation
moteur = DoubleMoteurOCR()
r = moteur.lire_zone(600, 350, 800, 390)
print(f"'{r['texte']}' ({r['confiance']:.0%}) via {r['moteur']}")
🟣 Résultats en production (230+ tests, 18 mois) : 89% via Tesseract (52ms), 11% via EasyOCR (310ms). Fiabilité : 99.3%. Avant double moteur : 94.1%.
4 Validation numérique
import re

class ValidateurNumerique:
    def extraire_nombre(self, texte_ocr):
        t = re.sub(r'[€$£¥\s]', '', texte_ocr.strip())
        t = re.sub(r'[^\d.,]', '', t)
        if ',' in t and '.' in t:
            if t.index(',') < t.index('.'):
                t = t.replace(',', '')          # 1,234.56 → 1234.56
            else:
                t = t.replace('.', '').replace(',', '.')  # 1.234,56 → 1234.56
        elif ',' in t:
            t = t.replace(',', '.')
        return float(t)

    def valider_prix(self, texte_ocr, attendu, tolerance=0.01):
        lu = self.extraire_nombre(texte_ocr)
        ok = abs(lu - attendu) <= tolerance
        print(f"{'✅' if ok else '❌'} Lu: {lu} | Attendu: {attendu}")
        return ok

v = ValidateurNumerique()
assert v.valider_prix("12.50 €",    12.50)
assert v.valider_prix("1 234,56 €", 1234.56)
assert v.valider_prix("EUR 12,50",  12.50)
5 Prétraitement d'image
import cv2, numpy as np
from PIL import ImageGrab

def pretraiter(x1, y1, x2, y2):
    """Prétraite une capture pour améliorer la lecture OCR."""
    img = cv2.cvtColor(np.array(ImageGrab.grab(bbox=(x1,y1,x2,y2))), cv2.COLOR_RGB2BGR)
    img = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)  # Agrandir x2
    gris = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
    contraste = clahe.apply(gris)
    binaire = cv2.adaptiveThreshold(
        contraste, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2
    )
    return binaire

# Utilisation
img = pretraiter(100, 200, 400, 250)
texte = pytesseract.image_to_string(img, lang='fra')
print("Après prétraitement :", texte)
✅ Ce prétraitement améliore le taux Tesseract de 60% à 87% sur des interfaces avec fonds colorés (rouge/vert, police LCD). Combiné au fallback EasyOCR : 99.3%.

1. Préparer les images de référence

Avant d'écrire une seule ligne de test, toutes les images nécessaires doivent être capturées et validées. C'est l'étape que les débutants sautent et qu'ils regrettent.

Protocole de capture — à suivre pour chaque image

  1. Ouvrir l'application dans l'état exact où le test va la trouver
  2. Lancer SikuliX IDE → bouton 📷 "Take screenshot"
  3. Capturer uniquement l'élément — pas son fond, pas ses voisins
  4. Nommer explicitement : btn_se_connecter.png, pas bouton1.png
  5. Sauvegarder dans le bon sous-dossier de Pattern/
  6. Valider avec le mini-script ci-dessous — score > 0.92 requis
// Mini-script de validation d'une image — colle dans SikuliX IDE et lance
Screen s = new Screen();
String img = "Pattern/Actions/btn_se_connecter.png";
Match m = s.exists(img, 3);
if (m != null) {
    m.highlight(2);
    System.out.println("✅ Valide — score : " + m.getScore());
    // Score > 0.95 = excellent | 0.90-0.95 = bon | < 0.90 = recapturer
} else {
    System.out.println("❌ Non trouvée — recapturer");
}

Liste des images pour un test de connexion

Pour un test de connexion simple, voilà ce qu'il faut capturer :

FichierCe que c'estDossier
ecran_login.pngLogo ou titre de l'écran de login — identifie que l'écran est làLogin/
label_identifiant.pngLe label "Identifiant :" — permet de localiser le champLogin/
label_mot_de_passe.pngLe label "Mot de passe :"Login/
btn_se_connecter.pngLe bouton de connexionLogin/
icone_accueil.pngUn élément stable du tableau de bord — prouve que la connexion a réussiNavigation/
message_erreur_login.pngLe message d'erreur si les identifiants sont mauvaisLogin/

2. Créer les constantes

public class LoginImageConstants {
    private static final String BASE = "Pattern/Login/";

    public static final String ECRAN_LOGIN      = BASE + "ecran_login.png";
    public static final String LABEL_IDENTIFIANT = BASE + "label_identifiant.png";
    public static final String LABEL_MDT_DE_PASSE = BASE + "label_mot_de_passe.png";
    public static final String BTN_SE_CONNECTER = BASE + "btn_se_connecter.png";
    public static final String MESSAGE_ERREUR   = BASE + "message_erreur_login.png";

    private LoginImageConstants() {}
}
💡 Les constantes ICONE_ACCUEIL et autres éléments communs sont déjà dans ImageConstants. Pas de doublon.

3. Instancier le framework

Selon l'environnement — local ou VNC distant — l'initialisation diffère. Le test ne change pas.

// ── Mode local ──
ScreenOperationsManager som = new ScreenOperationsManager();

// ── Mode VNC distant ──
ScreenOperationsManager som = new ScreenOperationsManager("192.168.1.100", 5900, "password");

// ── Via variable d'environnement (CI/CD) ──
// Si -Dtest.env=vnc → VNC automatiquement
// Si -Dtest.env=local → local automatiquement
ScreenOperationsManager som = ScreenOperationsManager.createFromEnv();
💡 Dans Katalon Studio, le ScreenOperationsManager est instancié une fois dans le TestListener et partagé entre tous les tests via une variable globale.

4. Écrire le test

Structure d'un test visual testing bien écrit — les mêmes phases qu'un test unitaire : Arrange / Act / Assert.

/**
 * Test de connexion avec identifiants valides.
 * Prérequis : application démarrée, écran de login visible.
 */
@Test
public void testConnexionValide() {

    // ── ARRANGE — Vérifier les prérequis ──
    // L'écran de login doit être affiché avant de commencer
    boolean loginVisible = som.waitForElement(LoginImageConstants.ECRAN_LOGIN, 20);
    if (!loginVisible) {
        som.captureScreen(RunConfiguration.getReportFolder());
        KeywordUtil.markFailedAndStop("Écran de login non trouvé — vérifier que l'application est démarrée");
        return;
    }

    // ── ACT — Exécuter les actions ──

    // Saisir l'identifiant (cliquer sur le champ via son label + saisir)
    som.clickNearImage(LoginImageConstants.LABEL_IDENTIFIANT, 0.92, 200, "right");
    som.typeText("admin");

    // Saisir le mot de passe
    som.clickNearImage(LoginImageConstants.LABEL_MDT_DE_PASSE, 0.92, 200, "right");
    som.typeText("motdepasse123");

    // Cliquer sur "Se connecter"
    som.clickOn(LoginImageConstants.BTN_SE_CONNECTER);

    // ── ASSERT — Vérifier le résultat ──

    // Le tableau de bord doit apparaître dans les 10 secondes
    boolean connexionReussie = som.waitForElement(ImageConstants.ICONE_ACCUEIL, 10);

    if (!connexionReussie) {
        // Vérifier si c'est un message d'erreur
        boolean erreurPresente = som.waitForElement(LoginImageConstants.MESSAGE_ERREUR, 2, true);
        som.captureScreen(RunConfiguration.getReportFolder());
        String msg = erreurPresente
            ? "Connexion refusée — identifiants invalides"
            : "Tableau de bord non trouvé après connexion";
        KeywordUtil.markFailedAndStop(msg);
        return;
    }

    KeywordUtil.logInfo("✅ Connexion réussie — tableau de bord affiché");

    // Validation visuelle finale du scénario
    som.validateFinalVisualState("Connexion_Standard", "Apres_Connexion_Reussie");
}

5. Lancer et déboguer

Premier lancement

# Maven
mvn test -Dtest=TestConnexion

# Katalon
katalonc -projectPath="MonProjet" -testSuitePath="Test Suites/TS_Connexion" -browserType="Chrome"

Que faire si ça échoue

ErreurCause probableSolution
FindFailed sur la première imageL'application n'est pas démarrée ou l'écran n'est pas celui attenduVérifier manuellement l'état de l'application avant le test
FindFailed sur un boutonImage de mauvaise résolution ou seuil trop élevéLogger le score, recapturer l'image, réajuster le seuil
Clic au mauvais endroitImage trop générique, présente à plusieurs endroitsAjouter une région ou utiliser un Pattern plus spécifique
OCR lit malFond coloré ou police non standardActiver le double moteur, prétraiter l'image
Test instable (passe parfois)Timing — l'interface n'est pas prête quand SikuliX chercheAugmenter les timeouts de wait(), ajouter waitVanish sur les spinners

Le screenshot d'échec est ton meilleur allié

Quand un test échoue, la première chose à faire est d'ouvrir le screenshot dans FailureScreenshots/. Il montre exactement ce que SikuliX voyait au moment de l'échec. 90% des bugs se diagnostiquent en 30 secondes avec cette image.

6. Test complet annoté — le template

Voilà le template de test à copier pour chaque nouveau cas. Il intègre toutes les bonnes pratiques.

/**
 * NomDuScenario — description en une phrase de ce que le test valide.
 *
 * Prérequis :
 * - [lister les prérequis manuels, ex: "application démarrée", "utilisateur admin créé"]
 *
 * Données de test :
 * - [lister les données utilisées]
 */
@Test
public void testNomDuScenario() {

    // ═══════════════════════════════════════════
    // PHASE 1 — PRÉREQUIS
    // ═══════════════════════════════════════════
    // Toujours vérifier que l'état initial est celui attendu.
    // Si ce n'est pas le cas, le test s'arrête proprement avec une capture.

    if (!som.waitForElement(ImageConstants.ECRAN_ATTENDU, 20)) {
        som.captureScreen(RunConfiguration.getReportFolder());
        KeywordUtil.markFailedAndStop("Prérequis non satisfait : écran attendu non trouvé");
        return;
    }

    // ═══════════════════════════════════════════
    // PHASE 2 — ACTIONS
    // ═══════════════════════════════════════════
    // Chaque action est une ligne lisible.
    // On ne met jamais de logique métier ici.

    som.clickOn(ImageConstants.BTN_ACTION_1);
    som.waitForElement(ImageConstants.CONFIRMATION_ACTION_1, 5);

    som.clickNearImage(ImageConstants.LABEL_CHAMP_SAISIE, 0.92, 200, "right");
    som.typeText("Valeur de test");

    som.clickOn(ImageConstants.BTN_VALIDER);

    // ═══════════════════════════════════════════
    // PHASE 3 — ASSERTIONS
    // ═══════════════════════════════════════════
    // Vérifier le résultat attendu.
    // Toujours prévoir le cas d'échec avec capture.

    boolean resultat = som.waitForElement(ImageConstants.RESULTAT_ATTENDU, 10);
    if (!resultat) {
        som.captureScreen(RunConfiguration.getReportFolder());
        KeywordUtil.markFailedAndStop("Résultat attendu non trouvé après l'action");
        return;
    }

    // Validation OCR si une valeur textuelle doit être vérifiée
    boolean textePresent = som.waitForText("Opération réussie", 5);
    if (!textePresent) {
        som.captureScreen(RunConfiguration.getReportFolder());
        KeywordUtil.markFailedAndStop("Message de succès absent");
        return;
    }

    // ═══════════════════════════════════════════
    // PHASE 4 — VALIDATION FINALE
    // ═══════════════════════════════════════════
    // Comparaison visuelle de l'état final du scénario.
    som.validateFinalVisualState("NomDuScenario", "Apres_Action_Finale");

    KeywordUtil.logInfo("✅ Scénario NomDuScenario validé avec succès");
}

7. Checklist avant de committer un test

🖼️ Images

  • ☐ Capturées à la bonne résolution
  • ☐ Nommées explicitement
  • ☐ Dans le bon sous-dossier
  • ☐ Score validé > 0.92
  • ☐ Versionées dans Git

📝 Constantes

  • ☐ Aucun chemin en dur dans le test
  • ☐ Constantes dans la bonne classe
  • ☐ Noms de constantes en MAJUSCULES
  • ☐ Pas de doublon avec une constante existante

🧪 Test

  • ☐ Phase prérequis présente
  • ☐ Capture sur chaque échec
  • ☐ markFailedAndStop au lieu d'Assert seul
  • ☐ Pas de Thread.sleep() sauf cas justifié
  • ☐ validateFinalVisualState en fin de scénario

⚙️ Seuils

  • ☐ MinSimilarity ≥ 0.90 global
  • ☐ Score loggué sur 10+ runs
  • ☐ Zéro faux positif validé
  • ☐ Zéro faux négatif validé
1 Docker headless — CI reproductible
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive DISPLAY=:1

RUN apt-get update && apt-get install -y \
    xvfb x11vnc fluxbox \
    openjdk-17-jdk \
    tesseract-ocr tesseract-ocr-fra \
    python3 python3-pip \
    libopencv-dev libxtst6 libxi6 libxrender1 \
    && rm -rf /var/lib/apt/lists/*

RUN pip3 install easyocr paddlepaddle paddleocr pillow opencv-python-headless

COPY start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 5900
CMD ["/start.sh"]
#!/bin/bash
# start.sh
Xvfb :1 -screen 0 1920x1080x24 -ac +extension GLX +render -noreset &
sleep 1
DISPLAY=:1 fluxbox &
sleep 1
x11vnc -display :1 -forever -nopw -listen 0.0.0.0 -port 5900 &
tail -f /dev/null
Avantage : n'importe quel développeur reproduit l'environnement CI en 3 commandes. Fini le "ça marche chez moi".
2 Jenkins pipeline
pipeline {
    agent { docker { image 'visual-testing:latest'; args '--shm-size=2g' } }

    stages {
        stage('Environnement graphique') {
            steps {
                sh 'Xvfb :99 -screen 0 1920x1080x24 &'
                sh 'export DISPLAY=:99 && x11vnc -display :99 -forever -nopw -port 5900 &'
                sh 'sleep 2'
            }
        }
        stage('Tests') {
            steps {
                sh 'mvn test -Dtest.env=vnc -Dvnc.host=localhost -Dvnc.port=5900'
            }
            post {
                always {
                    archiveArtifacts artifacts: 'FailureScreenshots/**/*.png', allowEmptyArchive: true
                    junit 'target/surefire-reports/*.xml'
                }
            }
        }
    }
    post {
        failure {
            emailext subject: "ÉCHEC Visual Testing — ${env.JOB_NAME} #${env.BUILD_NUMBER}",
                     body: "Rapport : ${env.BUILD_URL}artifact/FailureScreenshots/",
                     to: 'qa-team@monentreprise.com'
        }
    }
}
3 VNCScreen — Tester à distance
import org.sikuli.vnc.VNCScreen;

// Connexion directe VNC
VNCScreen ecran = VNCScreen.start("192.168.1.100", 5900, "password", 30);

// Utilisation identique à un Screen local
Settings.MinSimilarity = 0.90;
Settings.WaitScanRate = 5;  // Réduire sur VNC — bande passante limitée
ecran.wait("Pattern/Navigation/menu_principal.png", 20);
ecran.click("Pattern/Actions/btn_valider.png");
ecran.capture().save("FailureScreenshots/", "capture_distante");
ecran.stop();

// ── Tunnel SSH + VNC (port non exposé publiquement) ──
// 1. Créer le tunnel SSH en amont : ssh -L 5901:localhost:5900 user@serveur
// 2. Connecter SikuliX sur localhost:5901
VNCScreen ecranTunnel = VNCScreen.start("localhost", 5901, "", 15);
⚠️ Latence réseau : sur VNC, augmenter tous les timeouts de 50% par rapport au local. Ajouter une pause de 2 secondes après les actions critiques.

10 problèmes classiques en CI

#ProblèmeSolution
1Passe en local, échoue en CIMême résolution obligatoire. Configurer Xvfb à 1920x1080 et capturer les images de référence à cette résolution.
2FindFailed aléatoireAugmenter les timeouts de wait() en CI. Ajouter waitVanish sur les spinners avant chaque assertion.
3OCR lit mal en CILa résolution du rendu change la taille des polices. Recapturer les images OCR depuis l'environnement CI.
4Test bloqué indéfinimentAjouter un timeout global Maven : <forkedProcessTimeoutInSeconds>300</forkedProcessTimeoutInSeconds>
5Tests parallèles qui s'interfèrentLe visual testing ne supporte pas le parallèle sur le même écran. Utiliser des VNC différents ou séquencer.
6Application sale entre les testsAjouter un @AfterEach qui remet l'application en état connu (déconnexion, fermeture des fenêtres).
7Fuite mémoire après 50+ testsSikuliX garde les captures en mémoire. Appeler ImageCache.clear() régulièrement.
8VNC qui se déconnecteConfigurer x11vnc -nevershared -forever -noxdamage. Ajouter un keepalive dans le ScreenProvider.
9Images obsolètes après mise à jour UIStratégie de nommage avec version : btn_valider_v2.png. Script de validation périodique des images.
10Rapport sans contexte visuelCapturer l'écran après chaque test (succès et échec) dans @AfterEach. La capture montre l'état exact.

Checklist production

🖼️ Images

  • ✅ Capturées à la résolution de prod
  • ✅ Versionées dans Git
  • ✅ Score validé > 0.92 sur 10+ runs
  • ✅ Zéro faux positif validé

🔤 OCR

  • ✅ Double moteur configuré
  • ✅ Prétraitement activé si besoin
  • ✅ Validé sur 100+ lectures
  • ✅ Taux de confiance loggué

🏭 CI/CD

  • ✅ Environnement Docker stable
  • ✅ Résolution identique local/CI
  • ✅ Timeouts +50% vs local
  • ✅ Screenshots archivés Jenkins

📊 Indicateurs

  • ✅ Fiabilité ≥ 99%
  • ✅ Un test < 60 secondes
  • ✅ Zéro faux positif/semaine
  • ✅ Maintenance < 2h/mois