Логотип ConstructiCat
CodeBust.
Навигация по разделу ▾

Инициализация в конструкторе.

Тестовый класс инициализирует поля фикстуры в конструкторе вместо специального хука подготовки фреймворка (setUp / @BeforeEach / TestInitialize), обходя жизненный цикл теста.

##Признаки и симптомы

Тестовый класс имеет явный конструктор, который строит систему под тестом, моки или общие поля — работа, которая должна жить в хуке подготовки фреймворка (setUp() / @Before / @BeforeEach в JUnit, [TestInitialize] в MSTest, beforeEach в Jest/Vitest).

Характерные признаки:

  • Тестовый класс объявляет конструктор, присваивающий значения final-полям/полям экземпляра.
  • Подготовка фикстуры живёт вне жизненного цикла, которым реально управляет фреймворк, — поэтому хуки @BeforeEach/@AfterEach и подготовка базового класса выполняются в запутанном порядке относительно конструктора, а во фреймворках, переиспользующих экземпляр (NUnit, JUnit 5 с PER_CLASS), поля не переинициализируются перед каждым тестом.
  • Асинхронная подготовка невозможна: внутри конструктора нельзя выполнить await, поэтому асинхронная работа по фикстуре вынужденно попадает не туда или дублируется.

Каноническая форма для JUnit (из каталога тестовых запахов):

public class TagEncodingTest extends BrambleTestCase {
    private final CryptoComponent crypto;
    private final SecretKey tagKey;

    public TagEncodingTest() {                 // запах: инициализация в конструкторе
        crypto = new CryptoComponentImpl(new TestSecureRandomProvider());
        tagKey = TestUtils.getSecretKey();
    }
}

Родственная, но другая проблема: в тестовых файлах JS/TS нет конструктора тестового класса, однако фикстура, построенная один раз на уровне describe/модуля, даёт ту самую утечку состояния, которую часто приписывают конструкторам. Это общая фикстура (см. Универсальная фикстура), из-за которой тесты начинают зависеть от порядка выполнения (Зависимость от порядка тестов):

describe('Cart', () => {
  const cart = new Cart();          // построена один раз, протекает состоянием между тестами
  it('adds an item', () => { cart.add(apple); expect(cart.size).toBe(1); });
  it('starts empty', () => { expect(cart.size).toBe(0); }); // падает: видит apple
});

##Причины проблемы

Почему это происходит

  • Разработчики, незнакомые с назначением хука подготовки, хватаются за известную им языковую возможность — конструктор — чтобы инициализировать поля.
  • Так выглядит чище: final-поля плюс конструктор кажутся идиоматичными, а IDE может даже сгенерировать конструктор за вас.
  • Карго-культ из боевых классов, где инициализация в конструкторе и есть правильный приём.

Почему это вредно

  • Обходит жизненный цикл теста. Фреймворки обещают свежее состояние на каждый тест через хук подготовки и пару @BeforeEach/@AfterEach (или beforeEach/afterEach). Логика, спрятанная в конструкторе, находится вне этого контракта, поэтому очистка, подготовка базового класса и гарантии порядка могут не сработать так, как ожидается.
  • Утечка состояния — там, где экземпляр переиспользуется. JUnit (3, 4 и 5 с жизненным циклом по умолчанию — экземпляр на метод), MSTest и xUnit.net создают новый экземпляр тестового класса для каждого тестового метода, поэтому конструктор там выполняется перед каждым тестом и не переносит состояние из одного теста в другой. А вот NUnit по умолчанию переиспользует один экземпляр фикстуры для всех тестов класса — как и JUnit 5 с @TestInstance(Lifecycle.PER_CLASS): изменяемые поля, инициализированные в конструкторе, перетекают из теста в тест, и результат зависит от порядка выполнения.
  • Нет асинхронной подготовки. Конструктор не может выполнять await. Любая подготовка, которой нужны ввод-вывод, соединение с БД или запущенный сервер, не может чисто жить там; люди обходят это блокирующими вызовами или дублированием кода в каждом тесте.
  • Читаемость/единообразие. Ревьюеры и инструменты ожидают фикстуры в общепринятом хуке. Разделение инициализации между конструктором и методом подготовки затрудняет поиск реальной фикстуры и облегчает ошибки.

Оговорка — это зависит от фреймворка. Это не универсальное правило. В xUnit.net конструктор и есть идиоматичная подготовка на каждый тест (в паре с IDisposable для очистки), а MSTest даже поставляет противоположное, включаемое по выбору правило (MSTEST0020), предпочитающее конструкторы вместо [TestInitialize]. Считайте «инициализацию в конструкторе» запахом именно там, где ваш фреймворк предоставляет специальный хук подготовки (JUnit, классический MSTest, NUnit), а вы его обходите.

##Лечение

Перенесите инициализацию полей из конструктора в хук подготовки фреймворка и предпочитайте свежее состояние на каждый тест общим экземплярам.

  1. Удалите конструктор тестового класса; объявите поля и присвойте их в хуке подготовки (@BeforeEach/setUp() для JUnit, [TestInitialize] для MSTest, beforeEach для Jest/Vitest).
  2. Если подготовка асинхронна, это обязательно — хук может выполнить await; конструктор не может.
  3. Для родственной проблемы общей фикстуры в JS/TS (см. Универсальная фикстура / Зависимость от порядка тестов) стройте фикстуру внутри beforeEach (переприсваивая let), а не один раз на уровне describe/модуля, чтобы каждый тест получал чистый объект.
  4. Сохраняйте симметрию очистки: соединяйте @BeforeEach с @AfterEach (или, в стиле конструкторов xUnit.net, реализуйте IDisposable).

До → после (JUnit):

// до
public class TagEncodingTest {
    private final CryptoComponent crypto = new CryptoComponentImpl(...);
}

// после
public class TagEncodingTest {
    private CryptoComponent crypto;

    @BeforeEach
    void setUp() {
        crypto = new CryptoComponentImpl(...);
    }
}

Родственный случай (Jest/Vitest):

describe('Cart', () => {
  let cart: Cart;
  beforeEach(() => { cart = new Cart(); }); // свежая на каждый тест
  it('adds an item', () => { cart.add(apple); expect(cart.size).toBe(1); });
  it('starts empty', () => { expect(cart.size).toBe(0); });
});

Если фикстура действительно неизменяема и дорога (и ваш фреймворк это поддерживает), одноразовый хук вроде @BeforeAll/beforeAll приемлем — но только для по-настоящему доступного только для чтения общего состояния, никогда для объектов, которые тесты изменяют.

##Обнаружение анализаторами

  • tsDetect Constructor Initialization — Помечает тестовые классы, объявляющие конструктор (запах «Инициализация в конструкторе» в каталоге tsDetect)
  • mstest-analyzers MSTEST0019 — Предпочитайте методы TestInitialize конструкторам (необязательный анализатор MSTest, включается вручную)