Logotipo de ConstructiCat
CodeBust.
Explorar la sección ▾

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/@AfterEach y 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 con PER_CLASS) los campos no se reinicializan antes de cada test.
  • La configuración asíncrona es imposible: no puedes usar await dentro 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 final junto 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 (o beforeEach/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.

  1. 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, beforeEach para Jest/Vitest).
  2. Si la configuración es asíncrona, esto es obligatorio: el hook puede usar await; un constructor no.
  3. 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 un let) en lugar de una sola vez en el ámbito de describe/módulo, de modo que cada test reciba un objeto limpio.
  4. Mantén el teardown simétrico: empareja @BeforeEach con @AfterEach (o, en el estilo de constructor de xUnit.net, implementa IDisposable).

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)