Notre plateforme s'adresse à des communautés en français, anglais, italien et espagnol. Chaque email transactionnel, chaque libellé de statut de commande, chaque écran du backstage existe donc en quatre exemplaires. Le problème n'est pas d'écrire ces traductions une fois : c'est de les garder synchronisées à chaque évolution du produit, quand une clé est ajoutée dans un fichier source et que rien n'oblige personne à répercuter le même ajout dans les trois autres langues. Un test unitaire s'en charge à notre place.

Ce test s'appelle TranslationParityTest et vit dans tests/System/TranslationParityTest.php. Il ne teste aucune fonctionnalité métier au sens classique : il compare des structures de clés entre fichiers, et fait échouer la suite si l'une d'elles diverge.

Où vit la vérité

Les traductions ne sont pas stockées dans un seul fichier par langue. Elles sont réparties par domaine : fr.core.json, fr.enums.json, fr.order.logistic.json, fr.backstage.json, et une douzaine d'autres, chacun dans resources/translations/{lang}/. Un outil, tool-merge-translations.php, fusionne l'ensemble des fichiers sources d'une langue en un unique {lang}.all.json par client — c'est ce fichier fusionné que l'application charge en exécution.

Cette séparation source / artefact généré a une conséquence directe pour les tests : ils doivent lire les fichiers sources, jamais le .all.json. Éditer un fichier fusionné directement serait invisible pour ces tests et écrasé au prochain build. TranslationParityTest::loadFile() résout donc systématiquement le chemin vers le domaine source, et une des méthodes de test — testAllTranslationFilesAreValidJson() — parcourt tous les fichiers sources en excluant explicitement ceux qui se terminent par .all.json.

Le dossier resources/translations/ compte 16 domaines (core, enums, order, order.logistic, backstage, msg, emails, navigation, account, offer, login, support, conciergerie, common, email.common, messages), déclinés dans les quatre langues supportées : 64 fichiers sources au total, avant même de compter les surcharges propres à chaque client dans resources/clients/{client}/translations/. À cette échelle, une revue manuelle de la parité à chaque pull request n'est plus réaliste — c'est exactement le genre de vérification répétitive qu'un test automatisé fait mieux qu'un humain.

Ce que le test vérifie

La suite couvre six angles différents de la parité :

  • Parité stricte du domaine logistique : les 16 clés du fichier order.logistic — les emails de suivi de colis, retour, point relais — doivent être rigoureusement identiques dans les quatre langues. Une clé qui existe en français mais pas en italien n'est pas une traduction manquante : c'est un échec de build.
  • Présence des clés de statut de commande : un sous-ensemble de clés critiques (statuts affichés au support, comme OrderStatus_RECEIVED_BY_STAFF ou OrderStatus_RETURNED_TO_SELLER) doit exister dans les quatre fichiers enums, indépendamment du reste.
  • Préservation des placeholders : chaque variable entre accolades ({link}, {link-style}, {firstname}) présente dans la version française d'un libellé logistique doit se retrouver, à l'identique, dans les autres langues. Un traducteur qui supprime accidentellement {link} en traduisant casse un lien de suivi de colis sans qu'aucune erreur ne remonte ailleurs dans le code.
  • Correspondance avec l'enum EmailTemplate : les clés EmailTemplate_* des fichiers enums sont comparées, pour chaque langue, à la liste des cases de l'enum PHP EmailTemplate — la source de vérité côté code. Ni clé manquante, ni clé obsolète issue d'un renommage qui traînerait encore dans un fichier de langue.
  • Parité stricte du backstage : les clés du domaine backstage (l'interface d'administration) doivent matcher exactement celles du français dans les trois autres langues.
  • Parité des enums hors EmailTemplate_ : le reste des clés d'énumération (statuts, catégories) suit la même règle de parité stricte avec le français, qui sert de référence partout dans ce fichier.

Le français est systématiquement pris comme référence : c'est la langue dans laquelle une fonctionnalité est écrite en premier, et celle sur laquelle l'équipe a le plus de certitude. Les trois autres langues sont comparées contre elle, jamais entre elles.

Le bug que ce test empêche

LocalizeServices::translate() a un comportement de repli précis : si la clé demandée n'a pas de traduction dans la langue active, la fonction renvoie la clé brute telle quelle plutôt que de lever une exception. C'est un choix défensif raisonnable — mieux vaut un texte moche qu'une page blanche — mais il a un revers : rien ne signale l'incident en production. Un membre du staff qui bascule son interface en anglais peut se retrouver face à EmailTemplate_OFFER_APPROVED affiché tel quel dans une liste, sans qu'aucune erreur applicative ne soit journalisée. Le seul signal, c'est un utilisateur qui remonte que « ça affiche du code ».

TranslationParityTest déplace ce contrôle de la production vers la CI. Une clé ajoutée en français sans son équivalent italien ou espagnol fait échouer composer test avant le merge, au lieu d'attendre qu'un utilisateur hispanophone tombe dessus.

Là où ce test s'insère dans le workflow

Ce test ne tourne pas isolément : il fait partie de la suite PHPUnit exécutée par composer test, avec la configuration tools/phpunit/config.xml. C'est aussi la dernière étape de la checklist de pré-commit de l'équipe, après le lint PHP, la mise à jour des dépendances et l'analyse statique PHPStan. La règle qu'on applique aux échecs de test s'y applique de la même façon : un échec de TranslationParityTest est traité comme une régression potentielle, pas comme un obstacle à contourner en modifiant l'assertion pour la faire passer.

En pull request, SonarCloud fait tourner sa quality gate sur les branches main, preprod et dev. La parité des traductions n'est pas une métrique que Sonar calcule lui-même — c'est PHPUnit qui porte cette vérification — mais un TranslationParityTest rouge fait échouer la suite de tests dans le même mouvement que n'importe quelle autre régression, avant que le code n'atteigne ces branches.

Ce que ça ne couvre pas

Il faut être clair sur les limites de cette suite. Elle vérifie une parité structurelle — les mêmes clés existent, les mêmes placeholders sont présents — pas la qualité ni l'exactitude de la traduction. Un texte italien grammaticalement correct mais qui contresens le message français passe ces tests sans problème. Rien ici ne remplace une relecture humaine ou un traducteur natif pour les langues où l'équipe n'a pas de locuteur en interne.

La couverture est aussi partielle par construction : seuls certains domaines (order.logistic, une partie des enums, backstage) sont testés en parité stricte. Étendre la même garantie à l'ensemble des domaines de traduction est un ajustement mécanique — dupliquer le pattern loadKeys() / assertSame() pour un nouveau domaine — mais ça reste un choix explicite fait fichier par fichier, pas une règle générale appliquée automatiquement à tout nouveau domaine de traduction créé.

Enfin, ce test s'exécute avec la suite PHPUnit complète, via composer test, donc à la vitesse du reste de la suite. Ce n'est pas un vérificateur de traduction en temps réel dans l'éditeur : la dérive est détectée au moment du commit ou de la pull request, pas au moment où la clé est tapée.

En résumé

Faire vivre une plateforme dans quatre langues avec des fichiers JSON versionnés plutôt qu'une plateforme de gestion de traduction dédiée repose sur la discipline de l'équipe et sur des garde-fous automatisés. TranslationParityTest ne traduit rien et ne corrige rien : il rend visible, en CI, une dérive qui serait autrement invisible jusqu'à ce qu'un utilisateur tombe dessus. Pour une plateforme qui vend la confiance et la qualité d'expérience comme argument différenciant, une clé de traduction brute affichée à un client est le genre de détail qui coûte cher en crédibilité — largement plus que le coût d'un test qui compare des tableaux de clés triées.