Initialisation dans le constructeur.
Une classe de test initialise les champs de sa fixture dans un constructeur au lieu du hook de mise en place dédié du framework (setUp / @BeforeEach / TestInitialize), contournant le cycle de vie des tests.
##Signes et symptômes
Une classe de test possède un constructeur explicite qui construit le système sous test, les mocks ou les champs partagés — un travail qui revient au hook de mise en place du framework (setUp() / @Before / @BeforeEach dans JUnit, [TestInitialize] dans MSTest, beforeEach dans Jest/Vitest).
Signes révélateurs :
- La classe de test déclare un constructeur qui affecte des champs
final/d’instance. - La mise en place de la fixture vit en dehors du cycle de vie réellement géré par le framework — de sorte que les hooks
@BeforeEach/@AfterEachet la mise en place de la classe de base s’exécutent dans un ordre déroutant par rapport au constructeur, et que, dans les frameworks qui réutilisent l’instance (NUnit, JUnit 5 avecPER_CLASS), les champs ne sont pas réinitialisés avant chaque test. - La mise en place asynchrone est impossible : vous ne pouvez pas faire
awaitdans un constructeur, si bien que le travail de fixture asynchrone est forcé au mauvais endroit ou dupliqué.
Forme JUnit canonique (tirée du catalogue des odeurs de test) :
public class TagEncodingTest extends BrambleTestCase {
private final CryptoComponent crypto;
private final SecretKey tagKey;
public TagEncodingTest() { // odeur : initialisation dans le constructeur
crypto = new CryptoComponentImpl(new TestSecureRandomProvider());
tagKey = TestUtils.getSecretKey();
}
}
Problème voisin, mais distinct : les fichiers de test JS/TS n’ont pas de constructeur de classe de test, mais construire la fixture une seule fois à la portée describe/module provoque la fuite d’état qu’on impute souvent aux constructeurs. C’est une fixture partagée (voir Fixture générale) qui rend les tests dépendants de l’ordre d’exécution (Dépendance à l’ordre des tests) :
describe('Cart', () => {
const cart = new Cart(); // construite une seule fois, fait fuir l'état entre les tests
it('adds an item', () => { cart.add(apple); expect(cart.size).toBe(1); });
it('starts empty', () => { expect(cart.size).toBe(0); }); // échoue : voit apple
});
##Causes du problème
Pourquoi cela arrive
- Les développeurs qui ne connaissent pas le rôle du hook de mise en place se tournent vers la fonctionnalité du langage qu’ils maîtrisent déjà — le constructeur — pour initialiser les champs.
- Cela paraît plus propre : des champs
finalassortis d’un constructeur semblent idiomatiques, et un IDE peut même générer le constructeur à votre place. - Du cargo-culting depuis les classes de production, où l’initialisation dans le constructeur est le bon modèle.
Pourquoi c’est nuisible
- Contourne le cycle de vie des tests. Les frameworks promettent un état neuf pour chaque test via le hook de mise en place et une paire
@BeforeEach/@AfterEach(oubeforeEach/afterEach). La logique cachée dans un constructeur se situe en dehors de ce contrat, si bien que le nettoyage, la mise en place de la classe de base et les garanties d’ordonnancement peuvent ne pas s’appliquer comme prévu. - Fuite d’état — là où l’instance est réutilisée. JUnit (3, 4 et 5 avec son cycle de vie par défaut, par méthode), MSTest et xUnit.net créent une nouvelle instance de la classe de test pour chaque méthode de test : le constructeur s’y exécute donc avant chaque test et ne transporte pas d’état de l’un à l’autre. NUnit, en revanche, réutilise par défaut une seule instance de fixture pour tous les tests de la classe, tout comme JUnit 5 avec
@TestInstance(Lifecycle.PER_CLASS): les champs mutables initialisés dans le constructeur débordent alors d’un test sur le suivant, et le résultat dépend de l’ordre d’exécution. - Pas de mise en place asynchrone. Un constructeur ne peut pas faire
await. Toute mise en place nécessitant des E/S, une connexion à une base de données ou un serveur démarré ne peut pas y résider proprement ; on contourne le problème avec des appels bloquants ou du code dupliqué par test. - Lisibilité/cohérence. Les relecteurs et l’outillage s’attendent à trouver les fixtures dans le hook conventionnel. Répartir l’initialisation entre un constructeur et une méthode de mise en place rend la vraie fixture difficile à trouver et facile à mal configurer.
Mise en garde — cela dépend du framework. Ce n’est pas une règle universelle. Dans xUnit.net, le constructeur est la mise en place idiomatique par test (associé à IDisposable pour le nettoyage), et MSTest propose même une règle inverse et optionnelle (MSTEST0020) qui privilégie les constructeurs plutôt que [TestInitialize]. Considérez « Initialisation dans le constructeur » comme une odeur spécifiquement là où votre framework fournit un hook de mise en place dédié (JUnit, MSTest classique, NUnit) et que vous le contournez.
##Traitement
Déplacez l’initialisation des champs hors du constructeur, vers le hook de mise en place du framework, et préférez un état neuf par test plutôt que des instances partagées.
- Supprimez le constructeur de la classe de test ; déclarez les champs et affectez-les dans le hook de mise en place (
@BeforeEach/setUp()pour JUnit,[TestInitialize]pour MSTest,beforeEachpour Jest/Vitest). - Si la mise en place est asynchrone, c’est obligatoire — le hook peut faire
await; un constructeur, non. - Pour le problème voisin de la fixture partagée en JS/TS (voir Fixture générale / Dépendance à l’ordre des tests), construisez la fixture à l’intérieur de
beforeEach(en réaffectant unlet) plutôt qu’une seule fois à la portéedescribe/module, afin que chaque test obtienne un objet propre. - Gardez un nettoyage symétrique : associez
@BeforeEachà@AfterEach(ou, dans le style à constructeur de xUnit.net, implémentezIDisposable).
Avant → après (JUnit) :
// avant
public class TagEncodingTest {
private final CryptoComponent crypto = new CryptoComponentImpl(...);
}
// après
public class TagEncodingTest {
private CryptoComponent crypto;
@BeforeEach
void setUp() {
crypto = new CryptoComponentImpl(...);
}
}
Cas voisin (Jest/Vitest) :
describe('Cart', () => {
let cart: Cart;
beforeEach(() => { cart = new Cart(); }); // neuf pour chaque test
it('adds an item', () => { cart.add(apple); expect(cart.size).toBe(1); });
it('starts empty', () => { expect(cart.size).toBe(0); });
});
Si la fixture est véritablement immuable et coûteuse (et que votre framework le permet), un hook unique tel que @BeforeAll/beforeAll est acceptable — mais uniquement pour un état partagé véritablement en lecture seule, jamais pour des objets que les tests modifient.
##Détecté par
- tsDetect Constructor Initialization — Signale les classes de test qui déclarent un constructeur (l’odeur Constructor Initialization du catalogue tsDetect)
- mstest-analyzers MSTEST0019 — Préférer les méthodes TestInitialize aux constructeurs (analyseur MSTest à activer explicitement)