ТестированиеАвтоматизация тестированияИнженер по автоматизации тестирования

В коде зависимость создаётся внутри тестируемого класса, из за чего тест обращается к реальному HTTP сервис...

В коде зависимость создаётся внутри тестируемого класса, из-за чего тест обращается к реальному HTTP-сервису. Какое архитектурное изменение позволит управлять этой зависимостью в тесте?

class OrderChecker {
  constructor() {
    this.client = new HttpClient("https://orders.example");
  }

  async isPaid(id) {
    return (await this.client.get(`/orders/${id}`)).status === "paid";
  }
}
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Зависимость нужно передавать в класс извне через конструктор или другой явный механизм внедрения зависимостей. Тогда рабочая конфигурация сможет передать реальный HTTP-клиент, а тест — контролируемую реализацию, не изменяя бизнес-логику OrderChecker.

Это внедрение зависимостей (Dependency Injection, DI). Оно отделяет создание объектов от их использования и делает границу тестируемого компонента явной.

Исторический контекст

Изначально прикладной код часто сам создавал все необходимые объекты: клиентов баз данных, HTTP-клиенты, файловые хранилища и часы. Такой подход удобен для небольшого кода, но связывает бизнес-логику с конкретной инфраструктурой.

По мере роста систем стало важно отдельно проверять логику компонента без запуска сети, базы данных или внешних сервисов. Инверсия управления и DI решают эту проблему: компонент получает готовые зависимости, а решение о том, какие реализации использовать, переносится на внешний уровень — обычно на конфигурацию приложения или тестовый код.

Постановка проблемы

В исходном варианте OrderChecker одновременно выполняет две разные задачи: реализует проверку статуса заказа и создаёт HTTP-клиент. Тест не может надёжно подать заранее известный ответ, поэтому зависит от доступности сервиса, сетевых задержек и состояния внешних данных.

Попытка заменить HttpClient после создания объекта будет хрупкой: она опирается на детали реализации, а не на публичный контракт класса. Если такой код распространён по проекту, изменение способа подключения к сервису приводит к массовым правкам и усложняет изоляцию тестов.

Подробное решение

Зависимость объявляют частью контракта класса и передают извне:

class OrderChecker { constructor(client) { this.client = client; } async isPaid(id) { const order = await this.client.get(`/orders/${id}`); return order.status === "paid"; } } const client = { get: async () => ({ status: "paid" }) }; const checker = new OrderChecker(client);

В production-коде на верхнем уровне приложения создают настоящий HTTP-клиент и передают его в OrderChecker. В тесте передают реализацию с предсказуемым ответом. Это может быть stub, fake или mock; DI само по себе не определяет, какой именно вариант будет выбран.

Важно внедрять зависимость через узкий контракт. OrderChecker должен знать только о необходимой операции get, а не о полном API конкретной библиотеки. Это уменьшает связанность и снижает стоимость замены инструмента или транспортного протокола.

DI не означает, что каждую строку нужно превращать в интерфейс или заменять все зависимости имитациями. Для простых неизменяемых объектов прямое создание может быть понятнее. Абстракция оправдана, когда зависимость относится к инфраструктуре, создаёт побочные эффекты, медленна, нестабильна или должна иметь разные реализации.

У DI есть компромиссы. Явная передача большого количества зависимостей может сделать конструкторы перегруженными и выявить чрезмерную ответственность класса — это полезный сигнал, но не всегда повод вводить контейнер зависимостей. Автоматические контейнеры уменьшают ручную сборку объектов, однако усложняют трассировку конфигурации и могут скрывать реальные зависимости.

Ситуация из практики

В системе проверки заказов UI-тесты вызывали реальный сервис оплаты через несколько внутренних классов. При временной недоступности стенда падали не только интеграционные проверки, но и тесты локальной логики отображения статуса.

Рассматривались три варианта. Первый — оставить реальные вызовы во всех тестах: это сохраняло максимальную близость к production, но делало тесты медленными и зависимыми от окружения. Второй — изменять внутреннее поле клиента в каждом тесте: это быстро внедрялось, но связывало тесты с реализацией и ломалось при рефакторинге. Третий — передавать клиент через конструктор и использовать настоящий клиент только в интеграционном слое.

Выбрали третий вариант. Модульные тесты получили предсказуемую реализацию клиента, а отдельные интеграционные тесты сохранили проверку реального взаимодействия. В результате граница ответственности стала явной: ошибки бизнес-логики диагностировались без сети, а совместимость с сервисом проверялась специализированным набором.

Что кандидаты часто упускают

  1. Чем DI отличается от простого создания mock-объекта?

DI — это способ передать зависимость компоненту и отделить её создание от использования. Mock — только один из возможных объектов, который можно передать таким способом. Внедрённая зависимость может быть реальным объектом, stub, fake, spy или адаптером.

Если класс получает настоящий объект через конструктор, это тоже DI. Поэтому DI не равен автоматическому подменению всех зависимостей и не требует обязательного применения mocking-фреймворка.

  1. Где должна находиться сборка графа зависимостей?

Обычно её размещают в composition root — внешней точке приложения, где создаются компоненты и связываются их зависимости. Бизнес-классы не должны самостоятельно выбирать конкретные инфраструктурные реализации.

Такой подход позволяет иметь разные сборки для production, интеграционных тестов и модульных тестов. Если создание зависимостей распределено по бизнес-коду, тесты получают меньше контроля, а конфигурация становится неявной.

  1. Почему внедрение зависимости через глобальную переменную хуже конструктора?

Глобальная переменная скрывает обязательную зависимость: по сигнатуре класса непонятно, что ему нужен определённый клиент. Тесты начинают разделять состояние, а порядок их выполнения может влиять на результат.

Конструктор делает зависимость явной и позволяет создать объект в полностью заданном состоянии. Глобальный объект иногда оправдан для действительно неизменяемой конфигурации, но для сетевых клиентов, хранилищ и других побочных эффектов явное внедрение обычно даёт лучшую изоляцию и диагностику.