Inicialización en el constructor.
Una clase de test inicializa los campos de su fixture en un constructor en lugar de en el hook de configuración dedicado del framework (setUp / @BeforeEach / TestInitialize), saltándose el ciclo de vida del test.
##Signos y síntomas
Una clase de test tiene un constructor explícito que construye el sistema bajo prueba, los mocks o los campos compartidos —trabajo que corresponde al hook de configuración del framework (setUp() / @Before / @BeforeEach en JUnit, [TestInitialize] en MSTest, beforeEach en Jest/Vitest)—.
Señales reveladoras:
- La clase de test declara un constructor que asigna a campos
final/de instancia. - La configuración del fixture vive fuera del ciclo de vida que el framework gestiona realmente, de modo que los hooks
@BeforeEach/@AfterEachy la configuración de la clase base se ejecutan en un orden confuso respecto al constructor, y en los frameworks que reutilizan la instancia (NUnit, JUnit 5 conPER_CLASS) los campos no se reinicializan antes de cada test. - La configuración asíncrona es imposible: no puedes usar
awaitdentro de un constructor, así que el trabajo de fixture asíncrono se ve forzado al lugar equivocado o se duplica.
Forma canónica en JUnit (del catálogo de test smells):
public class TagEncodingTest extends BrambleTestCase {
private final CryptoComponent crypto;
private final SecretKey tagKey;
public TagEncodingTest() { // mal olor: inicialización en el constructor
crypto = new CryptoComponentImpl(new TestSecureRandomProvider());
tagKey = TestUtils.getSecretKey();
}
}
Relacionado, pero no es el mismo olor: los archivos de test de JS/TS no tienen constructor de clase de test, pero construir el fixture una sola vez en el ámbito de describe/módulo provoca la fuga de estado que a menudo se atribuye a los constructores. Es un fixture compartido (véase General Fixture) que hace que los tests dependan del orden de ejecución (Dependencia del orden de los tests):
describe('Cart', () => {
const cart = new Cart(); // se construye una vez, filtra estado entre tests
it('adds an item', () => { cart.add(apple); expect(cart.size).toBe(1); });
it('starts empty', () => { expect(cart.size).toBe(0); }); // falla: ve apple
});
##Causas del problema
Por qué ocurre
- Las personas desarrolladoras que no conocen el propósito del hook de configuración recurren a la característica del lenguaje que ya conocen —el constructor— para inicializar los campos.
- Parece más limpio: los campos
finaljunto con un constructor resultan idiomáticos, e incluso un IDE puede generar el constructor por ti. - Imitación acrítica (cargo cult) de las clases de producción, donde la inicialización en el constructor sí es el patrón correcto.
Por qué es perjudicial
- Se salta el ciclo de vida del test. Los frameworks prometen un estado fresco por test mediante el hook de configuración y un par
@BeforeEach/@AfterEach(obeforeEach/afterEach). La lógica oculta en un constructor queda fuera de ese contrato, así que la limpieza, la configuración de la clase base y las garantías de orden pueden no aplicarse como se espera. - Fuga de estado, donde la instancia se reutiliza. JUnit (3, 4 y 5 con su ciclo de vida por defecto, por método), MSTest y xUnit.net crean una instancia nueva de la clase de test para cada método de test, así que allí el constructor se ejecuta antes de cada test y no arrastra estado de uno a otro. NUnit, en cambio, reutiliza por defecto una única instancia del fixture para todos los tests de la clase, igual que JUnit 5 con
@TestInstance(Lifecycle.PER_CLASS): los campos mutables inicializados en el constructor se filtran de un test al siguiente y el resultado depende del orden de ejecución. - Sin configuración asíncrona. Un constructor no puede usar
await. Cualquier configuración que necesite E/S, una conexión a BD o un servidor arrancado no puede vivir ahí de forma limpia; la gente lo sortea con llamadas bloqueantes o con código duplicado por test. - Legibilidad/consistencia. Los revisores y las herramientas esperan los fixtures en el hook convencional. Repartir la inicialización entre un constructor y un método de configuración hace que el fixture real sea difícil de encontrar y fácil de equivocar.
Advertencia: depende del framework. Esto no es una regla universal. En xUnit.net el constructor es la configuración idiomática por test (emparejado con IDisposable para el teardown), y MSTest incluso incluye una regla opuesta y opcional (MSTEST0020) que prefiere los constructores frente a [TestInitialize]. Considera «Constructor Initialization» un mal olor específicamente cuando tu framework proporciona un hook de configuración dedicado (JUnit, MSTest clásico, NUnit) y lo estás evitando.
##Tratamiento
Saca la inicialización de campos del constructor y llévala al hook de configuración del framework, y prefiere un estado fresco por test antes que instancias compartidas.
- Elimina el constructor de la clase de test; declara los campos y asígnalos en el hook de configuración (
@BeforeEach/setUp()para JUnit,[TestInitialize]para MSTest,beforeEachpara Jest/Vitest). - Si la configuración es asíncrona, esto es obligatorio: el hook puede usar
await; un constructor no. - Para el problema relacionado del fixture compartido en JS/TS (véase General Fixture / Dependencia del orden de los tests), construye el fixture dentro de
beforeEach(reasignando unlet) en lugar de una sola vez en el ámbito dedescribe/módulo, de modo que cada test reciba un objeto limpio. - Mantén el teardown simétrico: empareja
@BeforeEachcon@AfterEach(o, en el estilo de constructor de xUnit.net, implementaIDisposable).
Antes → después (JUnit):
// antes
public class TagEncodingTest {
private final CryptoComponent crypto = new CryptoComponentImpl(...);
}
// después
public class TagEncodingTest {
private CryptoComponent crypto;
@BeforeEach
void setUp() {
crypto = new CryptoComponentImpl(...);
}
}
Caso relacionado (Jest/Vitest):
describe('Cart', () => {
let cart: Cart;
beforeEach(() => { cart = new Cart(); }); // fresco por test
it('adds an item', () => { cart.add(apple); expect(cart.size).toBe(1); });
it('starts empty', () => { expect(cart.size).toBe(0); });
});
Si el fixture es genuinamente inmutable y costoso (y tu framework lo admite), un hook de una sola vez como @BeforeAll/beforeAll es aceptable, pero solo para estado compartido verdaderamente de solo lectura, nunca para objetos que los tests mutan.
##Detectado por
- tsDetect Constructor Initialization — Señala las clases de test que declaran un constructor (el olor Constructor Initialization del catálogo de tsDetect)
- mstest-analyzers MSTEST0019 — Preferir métodos TestInitialize a los constructores (analizador de MSTest que se activa manualmente)