« On a un script de migration, on l’a testé en local, ça marche. » Cette phrase, nous l’avons entendue avant un incident client où une migration censée être réversible avait laissé une table wp_reservations_v2 orpheline après un rollback exécuté en urgence sur la production. Le script de retour arrière existait, mais personne ne l’avait jamais exécuté avant ce jour-là.
Une migration n’est réversible que si le chemin retour est testé aussi souvent que le chemin aller. Nous avons donc pris l’habitude d’écrire, pour chaque migration destinée à modifier un schéma existant, un test qui applique la migration, en vérifie l’effet, la défait, puis vérifie qu’aucune trace ne subsiste.
Structurer une migration en deux fonctions symétriques
La condition préalable est simple : chaque migration doit exposer une fonction d’application et une fonction d’annulation clairement séparées, sans effets de bord cachés dans l’une ou l’autre.
function migration_2024_01_25_up() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( "CREATE TABLE {$wpdb->prefix}reservations_archive (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
reservation_id BIGINT UNSIGNED NOT NULL,
archived_at DATETIME NOT NULL,
PRIMARY KEY (id)
) {$charset_collate};" );
}
function migration_2024_01_25_down() {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}reservations_archive" );
}
Écrire le test qui applique puis défait
Le test central ne se contente pas de vérifier que la table existe après up() : il insère des données représentatives, applique down(), puis vérifie qu’il ne reste ni table, ni ligne orpheline dans une table liée.

class Test_Migration_Reservations extends WP_UnitTestCase {
public function test_migration_est_reversible_sans_orphelins() {
global $wpdb;
migration_2024_01_25_up();
$table = $wpdb->prefix . 'reservations_archive';
$this->assertNotEmpty(
$wpdb->get_var( "SHOW TABLES LIKE '{$table}'" )
);
$wpdb->insert( $table, [
'reservation_id' => 42,
'archived_at' => current_time( 'mysql' ),
] );
migration_2024_01_25_down();
$this->assertNull( $wpdb->get_var( "SHOW TABLES LIKE '{$table}'" ) );
// vérifier qu'aucune référence orpheline ne subsiste ailleurs
$orphelins = $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->prefix}reservation_logs WHERE reservation_id = 42 AND archive_ref IS NOT NULL"
);
$this->assertEquals( 0, $orphelins );
}
}
Le piège des migrations qui modifient des lignes existantes
Le cas de la création de table est le plus simple : DROP TABLE efface tout proprement. Le cas piégeux concerne les migrations qui transforment des données en place, par exemple en fusionnant deux colonnes de métadonnées en une seule colonne JSON. Là, un rollback naïf ne peut pas reconstituer l’état d’origine à moins d’avoir explicitement sauvegardé l’ancienne forme.
- Avant toute transformation destructrice, copier les colonnes d’origine dans une table ou des méta temporaires.
- Ne supprimer ces copies qu’après une fenêtre de confirmation, jamais dans le même déploiement que la migration.
- Tester explicitement le cas « rollback après transformation partielle », pas seulement le cas nominal.
Isoler chaque test de migration dans sa propre transaction
WP_UnitTestCase encapsule chaque test dans une transaction annulée à la fin, ce qui est pratique mais peut masquer un problème réel : certaines instructions comme CREATE TABLE ou DROP TABLE provoquent un commit implicite en MySQL, ce qui casse l’isolation transactionnelle habituelle. Il faut donc nettoyer explicitement dans tearDown() plutôt que de compter sur le rollback automatique de PHPUnit.
protected function tearDown(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}reservations_archive" );
parent::tearDown();
}
Notre verdict
Un script de rollback jamais exécuté n’est qu’une hypothèse. Le tester automatiquement, avec des données insérées puis vérifiées absentes, transforme cette hypothèse en garantie exploitable un jour de panique en production. Sur nos projets qui gèrent plus de dix migrations cumulées, ce filet a déjà évité deux incidents où le rollback manuel aurait laissé des données incohérentes. Le coût d’écriture est faible comparé au coût d’un rollback raté sous pression.