Un développeur qui reprend une extension héritée reconnaît vite le symptôme : une fonction de trois cents lignes qui calcule un tarif dégressif, exécute une requête WP_Query, puis imprime directement du HTML avec des echo disséminés entre les conditions. Impossible d’en réutiliser le résultat ailleurs, impossible de le tester sans faire tourner tout un affichage, impossible d’en changer le format sans risquer de casser le calcul.
Ce mélange n’est pas une simple question de style. Il a un coût mesurable en maintenance : chaque évolution demandée — afficher le même tarif dans un e-mail, dans un export CSV, dans un widget — oblige à copier-coller la fonction plutôt qu’à la réutiliser. Voici les symptômes les plus fréquents de ce couplage, et la manière d’en sortir progressivement.
Symptôme : le calcul renvoie du HTML plutôt qu’une valeur

Voici le type de fonction que l’on rencontre dans une extension héritée. Le calcul d’une remise par paliers et la fabrication du balisage sont entremêlés, et rien ne sort de la fonction que du texte déjà imprimé :
function afficher_tarif_degressif( $produit_id, $quantite ) {
$prix = (float) get_post_meta( $produit_id, '_prix_unitaire', true );
echo '<div class="tarif">';
if ( $quantite >= 100 ) {
$prix *= 0.80;
echo '<span class="remise">-20 %</span>';
} elseif ( $quantite >= 10 ) {
$prix *= 0.90;
echo '<span class="remise">-10 %</span>';
}
echo '<strong>' . number_format( $prix * $quantite, 2, ',', ' ' ) . ' €</strong>';
echo '</div>';
}
Tout y est : la règle métier (les seuils et les taux), l’accès aux données (get_post_meta()), le calcul sur des nombres à virgule et la mise en forme. Pour savoir si douze unités à cinq euros valent bien cinquante-quatre euros, il faut charger WordPress, créer un produit, capturer la sortie et la relire en texte. La règle de remise est aussi introuvable : elle se cache entre deux echo.
Première étape : figer le comportement avant d’y toucher
Avant tout déplacement de code, on écrit un test qui décrit ce que la fonction produit aujourd’hui. Ce test, dit de caractérisation, ne juge pas si le résultat est beau ; il garantit que le refactoring ne le change pas par inadvertance. La fonction imprime : on capture donc la sortie avec la mise en tampon de PHP.
public function test_sortie_actuelle_conservee() {
$id = self::factory()->post->create();
update_post_meta( $id, '_prix_unitaire', '5' );
ob_start();
afficher_tarif_degressif( $id, 12 );
$html = ob_get_clean();
$this->assertStringContainsString( '-10 %', $html );
$this->assertStringContainsString( '54,00', $html );
}
Deuxième étape : extraire le calcul dans une fonction pure
Une fonction pure ne lit rien d’autre que ses paramètres et ne fait rien d’autre que retourner une valeur. On en profite pour remplacer les nombres à virgule par des centimes entiers, qui évitent les écarts d’arrondi sur l’argent :
final class Tarif_Degressif {
// Seuil de quantité => pourcentage de remise, du plus grand seuil au plus petit.
private const PALIERS = array( 100 => 20, 10 => 10 );
public static function calculer( int $prix_unitaire_centimes, int $quantite ): array {
$taux = 0;
foreach ( self::PALIERS as $seuil => $remise ) {
if ( $quantite >= $seuil ) {
$taux = $remise;
break;
}
}
$total = (int) round( $prix_unitaire_centimes * $quantite * ( 100 - $taux ) / 100 );
return array(
'taux' => $taux,
'total_centimes' => $total,
);
}
}
Cette classe ne dépend pas de WordPress : elle se teste avec PHPUnit seul, sans base de données ni chargement de l’environnement, et en quelques millisecondes.
use PHPUnit\Framework\TestCase;
final class Tarif_Degressif_Test extends TestCase {
public function test_aucune_remise_sous_dix_unites(): void {
$this->assertSame(
array( 'taux' => 0, 'total_centimes' => 4500 ),
Tarif_Degressif::calculer( 500, 9 )
);
}
public function test_dix_pour_cent_des_dix_unites(): void {
$this->assertSame(
array( 'taux' => 10, 'total_centimes' => 5400 ),
Tarif_Degressif::calculer( 500, 12 )
);
}
public function test_vingt_pour_cent_des_cent_unites(): void {
$this->assertSame(
array( 'taux' => 20, 'total_centimes' => 40000 ),
Tarif_Degressif::calculer( 500, 100 )
);
}
}
Troisième étape : isoler l’affichage
Le balisage devient une fonction qui reçoit le résultat du calcul et retourne une chaîne, sans rien imprimer. La fonction d’origine ne garde que le rôle de colle : lire la donnée WordPress, appeler le calcul, afficher le résultat.
function tarif_en_html( array $tarif ): string {
$html = '<div class="tarif">';
if ( $tarif['taux'] > 0 ) {
$html .= '<span class="remise">-' . (int) $tarif['taux'] . ' %</span>';
}
$html .= '<strong>' . esc_html( number_format( $tarif['total_centimes'] / 100, 2, ',', ' ' ) ) . ' €</strong>';
return $html . '</div>';
}
function tarif_en_texte( array $tarif ): string {
return number_format( $tarif['total_centimes'] / 100, 2, ',', ' ' ) . ' €';
}
function afficher_tarif_degressif( $produit_id, $quantite ) {
$prix = (int) round( (float) get_post_meta( $produit_id, '_prix_unitaire', true ) * 100 );
echo tarif_en_html( Tarif_Degressif::calculer( $prix, (int) $quantite ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
Le test de caractérisation de la première étape doit passer sans modification : c’est la preuve que le comportement visible n’a pas changé. Les nouveaux formats coûtent désormais une ligne chacun : l’e-mail reprend tarif_en_texte(), l’export CSV lit directement les deux valeurs du tableau, le bloc ou le widget appelle tarif_en_html(). La règle de remise, elle, n’existe qu’à un seul endroit.
Les autres symptômes du même couplage
- Un
echodans un filtre. Un filtre doit retourner une valeur ; un filtre qui imprime casse tout ce qui l’enchaîne après lui. - Un
exitoudie()au milieu d’une fonction. Le code appelant ne peut plus rien faire de l’erreur, et aucun test ne survit à la sortie brutale du processus. - La lecture de
$_POSTou de$_GETdans le calcul. La fonction dépend d’une requête HTTP ; passez les valeurs en paramètres, et lisez-les à la frontière de l’application. - Une requête
WP_Querydans un fichier de gabarit. Le gabarit décide alors de quelles données existent ; préférez une fonction qui prépare les données et un gabarit qui les affiche. - Des variables globales partagées (
global $produit) comme moyen de transmettre un état : chaque appel dépend de l’ordre des précédents.
Séparer sans multiplier les fichiers
Le danger inverse guette celui qui découvre la séparation des responsabilités : fabriquer une interface, une usine, un dépôt et un service pour une règle qui tient en vingt lignes. Dans l’exemple ci-dessus, deux éléments suffisent : une classe de calcul, sans dépendance, et une ou deux fonctions d’affichage. Le critère pratique est de pouvoir répondre à deux questions : peut-on tester la règle sans charger WordPress, et peut-on changer le balisage sans relire le calcul ? Si oui, la séparation est faite ; ajouter des couches ne fait que disperser le code.
Autre règle de prudence : on ne réécrit pas tout d’un coup. On commence par la fonction qui change le plus souvent ou qui cause le plus de bogues, avec son test de caractérisation, et on avance fonction par fonction. Le code qui fonctionne et que personne ne modifie peut attendre.
Une fonction qui calcule et qui affiche en même temps finit toujours par être copiée : pas par paresse, mais parce qu’on ne peut pas réutiliser la moitié seulement.
Conclusion
Mélanger calcul et affichage ne coûte rien le premier jour et coûte cher ensuite : des tests impossibles, une logique dupliquée à chaque nouveau format, des règles métier introuvables. Le chemin de sortie est court : figer le comportement par un test, extraire le calcul en fonction pure, isoler le balisage, garder une fine couche de colle. Gardez la mesure : deux fichiers bien séparés valent mieux que huit fichiers vides de sens.