20 000 apprenants, un quiz commun ouvert à 9 heures précises, une fenêtre de dix minutes pour le valider avant la clôture automatique : c’est le scénario que le service pédagogique d’un organisme de formation nous a demandé de sécuriser avant la rentrée. Le risque n’est pas la lenteur du serveur, déjà couverte par un test de charge classique, mais la corruption du score quand deux requêtes touchent la même ligne au même instant.
Un test de charge mesure des temps de réponse. Un test de concurrence vérifie qu’aucune donnée n’est perdue ou écrasée quand plusieurs écritures visent la même ressource en parallèle. Les deux sont nécessaires, mais seul le second révèle les scores à zéro ou dupliqués qui apparaissent uniquement sous charge réelle, jamais en test manuel séquentiel.
Étape 1 — Isoler la table à risque
Le module de quiz stocke les résultats dans une table personnalisée, wp_quiz_resultats, avec une ligne par tentative et une colonne score_final mise à jour au fil des réponses. C’est cette table, et non les tables natives WordPress, qui concentre le risque : chaque soumission de réponse déclenche un UPDATE basé sur la lecture préalable du score courant.
UPDATE wp_quiz_resultats
SET score_final = score_final + 1
WHERE tentative_id = %d
Cette requête, correcte isolément, devient dangereuse si le code applicatif lit d’abord le score en PHP, l’incrémente, puis le réécrit avec un UPDATE ... SET score_final = %d figé : deux requêtes concurrentes basées sur la même lecture initiale s’écrasent l’une l’autre, et une réponse correcte disparaît silencieusement.
Étape 2 — Écrire un test qui rejoue la concurrence

PHPUnit exécute les tests séquentiellement, il faut donc simuler la concurrence explicitement. Deux approches marchent bien sur ce type de module :
- Un test PHPUnit qui exécute deux mises à jour dans deux connexions distinctes à la base, sans passer par la connexion partagée de WordPress, pour reproduire un vrai entrelacement de transactions
- Un scénario de charge avec
k6qui envoie des centaines de requêtes REST simultanées sur l’endpoint de soumission de réponse, puis vérifie en fin de run que le score final correspond au nombre de bonnes réponses attendu
import http from 'k6/http';
import { check } from 'k6';
export const options = { vus: 200, duration: '30s' };
export default function () {
const res = http.post(
'https://staging.exemple.test/wp-json/quiz/v1/reponse',
JSON.stringify({ tentative_id: 42, reponse_id: 7 }),
{ headers: { 'Content-Type': 'application/json' } }
);
check(res, { 'statut 200': (r) => r.status === 200 });
}
Le test échoue quand le score final observé après le run est inférieur au nombre de requêtes acceptées : c’est la preuve directe qu’une partie des incréments a été perdue.
Étape 3 — Corriger avec une écriture atomique
La correction la plus simple reste souvent la meilleure : remplacer la lecture-puis-écriture par une requête SQL atomique qui laisse le moteur de base de données gérer l’incrémentation :
global $wpdb;
$wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->prefix}quiz_resultats
SET score_final = score_final + %d
WHERE tentative_id = %d",
1,
$tentative_id
)
);
Cette forme élimine la fenêtre de course, car l’incrémentation est effectuée par MySQL lui-même, sans passage intermédiaire par PHP. Pour les cas plus complexes, où la logique métier dépasse un simple incrément, un verrou optimiste avec une colonne de version (version incrémentée à chaque écriture, vérifiée dans la clause WHERE) évite les écritures silencieusement perdues.
Étape 4 — Vérifier le comportement aux limites
Une fois la correction en place, le test de concurrence doit être rejoué à plusieurs paliers de charge, pas uniquement au pic attendu :
- 50 utilisateurs simultanés, pour vérifier l’absence de régression sur le cas courant
- 200 utilisateurs simultanés, le pic annoncé par le service pédagogique
- 400 utilisateurs simultanés, pour observer la marge de sécurité réelle avant dégradation
Cette progression révèle si le correctif tient uniquement au niveau attendu ou s’il reste solide avec une marge confortable, ce qui compte quand un deuxième groupe se connecte en retard sur le premier quiz encore ouvert.
Un test de charge qui ne vérifie que le temps de réponse ne dira jamais qu’un score a été perdu : il faut relire la donnée après le run, pas seulement chronométrer la requête.
Notre verdict
Sur ce projet, le passage d’une lecture-écriture séquentielle à un UPDATE atomique a suffi à éliminer toute perte de score jusqu’à 400 utilisateurs simultanés, largement au-delà du pic annoncé. Le test de concurrence en k6, conservé dans la suite CI en exécution manuelle avant chaque rentrée, reste le seul filet de sécurité fiable : aucun test unitaire classique n’aurait révélé ce problème, car il n’existe qu’en présence d’un vrai entrelacement de requêtes.