АШШумилкин А. О.ОАиП · ПиТПМ
ПиТПМ · Лекция 03

Лекция №3. Архитектура тестируемых систем. Изоляция компонентов. Использование mock-объектов и заглушек (Stubs, Mocks)

4 468 слов10 разделов0 иллюстраций
↓ Скачать Markdown

Введение

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

§3.1 От юнит-тестов к архитектуре: почему тесты требуют правильного проектирования

В реальных проектах метод редко существует в вакууме. Он почти всегда обращается к внешним ресурсам — базам данных, файловой системе, веб-сервисам, сторонним API, очередям сообщений. Если мы попытаемся запустить юнит-тест, который обращается к реальной базе данных, мы получим три негативных эффекта:

  1. Медленный тест

    — сетевые вызовы и дисковые операции занимают секунды, а не миллисекунды. Набор из сотни таких тестов сделает сборку проекта невыносимо долгой.

  2. Нестабильный тест

    — если база данных недоступна, изменились тестовые данные или сетевой таймаут превышен, тест упадет без вины разработчика. Такой тест называют flaky (нестабильным).

  3. Сложный тест

    — требуется настраивать окружение, создавать тестовые данные, чистить их после прогона. Это увеличивает время на написание и поддержку тестов.

Рассмотрим пример нетестируемого кода:

csharp

public class OrderService 
{ 
    private readonly SmtpClient _smtpClient = new SmtpClient("smtp.gmail.com");
    
    public void ProcessOrder(Order order) 
    {
        // Бизнес-логика обработки заказа...
        _smtpClient.Send(order.CustomerEmail, "Заказ принят", "Спасибо за покупку!"); 
    } 
}

Проблема

Этот код невозможно протестировать изолированно. Причина — жесткая зависимость от конкретной реализации SmtpClient. Класс сам создает свою зависимость, не позволяя подменить её в тесте.

Из этой ситуации следует ключевой принцип модульного тестирования:

Юнит-тест должен проверять логику, а не интеграцию

Если тест обращается к внешнему ресурсу — это уже не юнит-тест, а интеграционный. Интеграционные тесты тоже нужны, но у них другая скорость, другая цена и другая область применения.

Таким образом, проблема тестируемости — это не проблема тестов. Это проблема архитектуры кода.

Когда мы говорим «код плохо тестируется», мы на самом деле говорим «код плохо спроектирован». И исправлять это нужно не дописыванием тестов, а изменением архитектуры.

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

  • Test Doubles (Stubs и Mocks) — объекты-заменители, которые позволяют изолировать тестируемый модуль.
  • Dependency Injection (DI) — паттерн, при котором зависимости передаются в класс извне, а не создаются внутри.
  • Dependency Inversion Principle (DIP) — принцип SOLID, требующий зависимости от абстракций, а не от конкретных реализаций.
  • IoC-контейнеры — инструменты, автоматизирующие управление зависимостями в больших проектах.
  • Антипаттерны — частые ошибки проектирования, которые делают код нетестируемым, и способы их исправления.

Важность принципов

Эти принципы превращают тестирование из «проверки написанного кода» в инструмент проектирования архитектуры. Если систему трудно тестировать — это сигнал, что архитектура нуждается в улучшении.

Владение этими инструментами отличает инженера по качеству от «нажимателя кнопок» и позволяет участвовать в обсуждении архитектурных решений наравне с разработчиками.

§3.2 Проблема зависимостей: что делать, когда модуль не изолирован

В современной разработке тестирование перестало быть второстепенной задачей, выполняемой после написания кода. Сегодня оно становится неотъемлемой частью процесса проектирования, а его невозможность — верным признаком архитектурных просчетов. Чтобы наглядно продемонстрировать это утверждение, обратимся к конкретному примеру из реальной практики.

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

Рассмотрим этот пример внимательно, чтобы понять, как смешение ответственностей и жесткие зависимости от внешних ресурсов превращают рабочий код в архитектурную ловушку.

csharp

public class ReportGenerator 
{ 
    public void GenerateSalesReport(DateTime start, DateTime end) 
    { 
        // 1. Получение данных из базы 
        var connection = new SqlConnection("Server=localhost;Database=Shop;"); 
        connection.Open(); 
        var command = new SqlCommand(
            "SELECT SUM(Total) FROM Orders WHERE OrderDate BETWEEN @start AND @end", 
            connection 
        ); 
        var total = command.ExecuteScalar();

        // 2. Формирование отчета 
        var report = $"Отчет за период: {start:dd.MM.yyyy}-{end:dd.MM.yyyy}\n"; 
        report += $"Итого продаж: {total} руб.";

        // 3. Сохранение в файл 
        File.WriteAllText($"report_{start:yyyyMMdd}_{end:yyyyMMdd}.txt", report); 
    } 
}

Три фатальные проблемы

На первый взгляд код рабочий. Но с точки зрения тестирования он содержит три фатальные проблемы.

Первая проблема — жесткая связь с базой данных. Класс сам создает подключение и формирует SQL-запрос. В тесте мы не можем подменить БД на тестовую или заглушку. Это означает, что для проверки метода нам каждый раз потребуется реальная база данных с реальными данными.

Вторая проблема — работа с файловой системой. Метод сохраняет отчет на диск. В тесте мы не можем проверить, какой именно файл создан и что в нем записано, не заглядывая в папку. Более того, если несколько тестов запускаются параллельно, они могут перезаписать файлы друг друга.

Третья проблема — смешение ответственностей. Один метод делает три разных дела: получает данные, формирует отчет, сохраняет файл. Это нарушает принцип единственной ответственности (Single Responsibility Principle). Метод сложно тестировать, потому что нужно проверять все три аспекта одновременно.

Что мы хотим от теста для этого метода? Мы хотим проверить:

  1. Что SQL-запрос формируется корректно (с правильными параметрами).
  2. Что отчет формируется в правильном формате (заголовок, итоговая сумма).
  3. Что файл сохраняется с правильным именем и содержимым.

Но мы не хотим, чтобы тест:

  • Обращался к реальной базе данных.
  • Создавал реальные файлы на диске.
  • Зависел от настроек окружения.

Ключевое противоречие

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

Тестировщик, видя такой код, должен понимать: его задача — не просто написать тесты к этому методу (это будет сложно и дорого), а вернуть его разработчику с комментарием:

Профессиональный подход

«Метод не тестируем. Требуется рефакторинг: вынести работу с БД и файловой системой в отдельные компоненты и передавать их через конструктор».

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

Вывод

Проблема изоляции модуля — это не техническая деталь, а архитектурное требование. В следующих параграфах мы разберем, как правильно проектировать код, чтобы он был изолирован и тестируем: через Test Doubles, Dependency Injection и соблюдение принципов SOLID.

§3.3 Введение в тест-дублёры (Test Doubles): что это и зачем нужно

В предыдущем параграфе мы столкнулись с проблемой: код класса ReportGenerator жестко привязан к базе данных и файловой системе. Чтобы протестировать такой код, нам нужно чем-то заменить эти внешние зависимости на управляемые объекты. Для этого в инженерии тестирования используется понятие Test Double (тест-дублёр).

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

Существует пять основных видов Test Doubles, каждый из которых решает свою задачу:

ВидНазначениеКогда использовать
DummyОбъект, который передаётся, но никогда не используется. Нужен только для удовлетворения сигнатуры метода.Когда параметр обязателен, но не влияет на тест.
StubПредоставляет фиксированные ответы на вызовы методов.Когда нужно вернуть определённое значение (например, курс валют, текущую дату).
SpyАналог Stub, но дополнительно запоминает, как его вызывали (сколько раз, с какими параметрами).Когда нужно проверить, что метод был вызван, но без строгих ожиданий.
MockЗаранее настроен на ожидание конкретных вызовов. Тест проверяет, были ли они выполнены корректно.Когда нужно проверить взаимодействие (отправка письма, логирование, сохранение в БД).
FakeУпрощённая рабочая реализация (например, база данных в памяти вместо реальной).Когда нужна полноценная работа, но без внешних ресурсов.

В экосистеме .NET для создания Stub и Mock чаще всего используются библиотеки Moq и NSubstitute. Именно они являются индустриальным стандартом и будут применяться в наших примерах.

Фундаментальное различие Stub и Mock

  • Stub отвечает на вопрос: «Что вернуть?». Его задача — поставить данные, чтобы тест мог выполниться. Stub не проверяет, как его вызывали.
  • Mock отвечает на вопрос: «Как вызывали?». Его задача — убедиться, что метод был вызван с правильными параметрами, нужное количество раз. Mock проверяет взаимодействие.

Эта разница не просто терминологическая. Она определяет стратегию тестирования.

Если мы проверяем результат вычислений (например, конвертацию валюты) — нам нужен Stub, который вернет курс. Если мы проверяем побочный эффект (отправлено ли письмо) — нам нужен Mock, который подтвердит факт вызова.

Хотя на практике в .NET-проектах чаще всего используются Stub и Mock, понимание остальных видов Test Doubles помогает читать профессиональную литературу и участвовать в архитектурных обсуждениях. Рассмотрим их кратко.

§3.4 Stubs (Заглушки) — поставщики фиксированных ответов

Stub

— это вид тест-дублёра, который возвращает заранее заданные данные при вызове своих методов.

Его задача — не проверять взаимодействие, а поставлять данные, чтобы тестируемая логика могла выполниться. Stub не заботится о том, как его вызывали, сколько раз и в каком порядке. Он просто отвечает на вызов фиксированным результатом.

Рассмотрим практический пример. Пусть у нас есть сервис конвертации валют, который для расчета использует внешний API курсов:

csharp

public interface IExchangeRateProvider 
{ 
    decimal GetRate(string fromCurrency, string toCurrency); 
}

public class CurrencyConverter 
{ 
    private readonly IExchangeRateProvider _rateProvider;

    public CurrencyConverter(IExchangeRateProvider rateProvider) 
    { 
        _rateProvider = rateProvider; 
    }

    public decimal Convert(decimal amount, string from, string to) 
    { 
        var rate = _rateProvider.GetRate(from, to); 
        return amount * rate; 
    } 
}

В реальной работе GetRate делает HTTP-запрос к внешнему сервису. Для юнит-теста нам это не нужно — мы не хотим зависеть от сети, скорости ответа и доступности API. Вместо этого мы создаём Stub, который возвращает фиксированный курс.

С использованием библиотеки Moq это делается следующим образом:

csharp

[Fact] 
public void Convert_UsdToEur_ReturnsCorrectAmount() 
{ 
    // Arrange: создаём Stub 
    var stubRateProvider = new Mock<IExchangeRateProvider>();
    
    // Настраиваем: при вызове GetRate("USD","EUR") возвращать 0.85 
    stubRateProvider
        .Setup(provider => provider.GetRate("USD", "EUR"))
        .Returns(0.85m);

    var converter = new CurrencyConverter(stubRateProvider.Object);

    // Act 
    var result = converter.Convert(100m, "USD", "EUR");

    // Assert 
    Assert.Equal(85m, result); 
}

Ключевая деталь

Обратите внимание на ключевую деталь: в этом тесте мы не проверяем, что метод GetRate был вызван. Нам не важно, был ли он вызван один раз или десять. Мы проверяем только результат вычисления — состояние объекта после выполнения метода.

Именно это и отличает Stub от других видов тест-дублёров, которые используются в тестировании.

Stub не обязан создаваться через библиотеку Moq. Moq — это просто удобный инструмент. Вместо него можно написать ручную реализацию интерфейса:

csharp

// Ручной Stub (без Moq) 
public class StubExchangeRateProvider : IExchangeRateProvider 
{ 
    public decimal GetRate(string fromCurrency, string toCurrency) 
    { 
        return 0.85m; // всегда возвращаем фиксированный курс 
    } 
}

// Использование в тесте 
var stub = new StubExchangeRateProvider(); 
var converter = new CurrencyConverter(stub);

var result = converter.Convert(100m, "USD", "EUR");

Assert.Equal(85m, result);

Типичные примеры использования Stub

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

Типичные примеры: поставщик текущей даты и времени, генератор случайных чисел, внешний API с фиксированным ответом, настройки конфигурации.

§3.5 Mocks (Имитации) — проверка взаимодействий

Mock

— это вид тест-дублёра, который не только поставляет данные, но и проверяет, что методы были вызваны корректно: с правильными параметрами, нужное количество раз, в правильном порядке. Если Stub отвечает на вопрос «Что вернуть?», то Mock отвечает на вопрос «Как вызывали?».

Рассмотрим пример. Пусть у нас есть сервис обработки заказов, который после успешного оформления отправляет клиенту письмо-подтверждение:

csharp

public interface IEmailSender 
{ 
    void Send(string to, string subject, string body); 
}

public class OrderService 
{ 
    private readonly IEmailSender _emailSender;

    public OrderService(IEmailSender emailSender) 
    { 
        _emailSender = emailSender; 
    }

    public void ProcessOrder(Order order) 
    {
        // Бизнес-логика: проверка заказа, расчет суммы, сохранение...
        //...
        
        // Отправка подтверждения 
        _emailSender.Send(
            order.CustomerEmail, 
            "Заказ подтвержден", 
            $"Ваш заказ №{order.Id} принят. Спасибо за покупку!"
        ); 
    } 
}

В этом коде метод ProcessOrder ничего не возвращает (void). Единственный способ проверить его корректность — убедиться, что он вызвал Send с правильными параметрами. Для этого используется Mock.

Создание Mock через Moq:

csharp

[Fact] 
public void ProcessOrder_ValidOrder_SendsConfirmationEmail() 
{ 
    // Arrange: создаём Mock 
    var mockEmailSender = new Mock<IEmailSender>(); 
    var service = new OrderService(mockEmailSender.Object); 
    var order = new Order 
    { 
        Id = 123, 
        CustomerEmail = "customer@example.com" 
    }; 
    
    // Act 
    service.ProcessOrder(order); 
    
    // Assert: проверяем, что Send был вызван 
    // ровно 1 раз с правильными параметрами 
    mockEmailSender.Verify(
        sender => sender.Send(
            "customer@example.com",
            "Заказ подтвержден",
            "Ваш заказ №123 принят. Спасибо за покупку!"
        ), 
        Times.Once
    ); 
}

Ключевое отличие от Stub

Обратите внимание на ключевое отличие от Stub: мы не просто настраиваем возвращаемое значение (здесь его вообще нет), а проверяем факт вызова через метод Verify. Это и есть суть Mock — проверка взаимодействия между объектами.

Mock особенно полезен в следующих ситуациях:

  • Метод ничего не возвращает (void), но должен выполнить побочное действие (отправка письма, логирование, запись в БД).
  • Важно проверить не только факт вызова, но и аргументы (например, правильное форматирование письма).
  • Нужно убедиться, что метод не вызван при некорректных входных данных.

Пример проверки «отрицательного» сценария:

csharp

[Fact] 
public void ProcessOrder_NegativeTotal_DoesNotSendEmail() 
{ 
    var mockEmailSender = new Mock<IEmailSender>(); 
    var service = new OrderService(mockEmailSender.Object); 
    var invalidOrder = new Order 
    { 
        Id = 999, 
        CustomerEmail = "test@test.com", 
        Total = -100 
    };

    // Act 
    // предположим, метод выбрасывает исключение 
    service.ProcessOrder(invalidOrder);

    // Assert: проверяем, что Send НЕ был вызван 
    mockEmailSender.Verify(
        sender => sender.Send(
            It.IsAny<string>(), 
            It.IsAny<string>(), 
            It.IsAny<string>()
        ), 
        Times.Never 
    ); 
}

Здесь Times.Never проверяет, что метод Send не вызывался ни разу — что корректно для невалидного заказа, который не должен отправлять подтверждение.

Метод It.IsAny<T>()

В примере выше мы использовали конструкцию:

csharp

It.IsAny<string>()

Это специальный метод из библиотеки Moq, который означает «любое значение данного типа».

Вместо того чтобы указывать конкретный email, тему или текст письма, мы говорим: «Мне всё равно, что именно передавалось — проверяю только факт вызова».

Это особенно полезно в двух случаях:

  1. Когда точное значение не важно. Например, мы хотим проверить, что письмо было отправлено, но не хотим жестко привязываться к тексту письма (который может измениться).
  2. Когда значение генерируется внутри метода. Например, если письмо содержит текущую дату или номер заказа, мы не можем предсказать его заранее в тесте.

Сравнение подходов:

csharp

// Жесткая проверка — важен конкретный email 
mockEmailSender.Verify(
    sender => sender.Send(
        "customer@example.com", 
        "Заказ подтвержден",
        "Текст..."
    ), 
    Times.Once 
);

// Гибкая проверка — важно только, что вызвали с любым email 
mockEmailSender.Verify(
    sender => sender.Send(
        It.IsAny<string>(), 
        "Заказ подтвержден",
        "Текст..."
    ), 
    Times.Once 
);

// Проверка только факта вызова — параметры вообще не важны 
mockEmailSender.Verify(
    sender => sender.Send(
        It.IsAny<string>(), 
        It.IsAny<string>(), 
        It.IsAny<string>()
    ), 
    Times.Never 
);

В последнем примере мы используем It.IsAny<string>() для всех трех параметров, потому что нам важно лишь отсутствие вызова, а не то, с какими параметрами он мог бы произойти.

Другие полезные методы Moq для проверки параметров:

МетодЗначение
It.IsAny<T>()Любое значение типа T
It.IsNotNull<T>()Любое не-null значение
It.IsInRange<T>(min, max)Значение в диапазоне
It.Is<T>(x => x > 0)Значение, удовлетворяющее условию

§3.6 Stub vs Mock: в чем разница и когда что использовать

После того как мы разобрали по отдельности Stub и Mock, возникает закономерный вопрос: «Какой из них использовать в конкретной ситуации и чем они принципиально отличаются?». Ответ кроется в том, что именно мы проверяем в тесте.

Ключевое различие можно сформулировать так:

  • Stub проверяет состояние — нас интересует результат вычисления, который вернул тестируемый метод.
  • Mock проверяет поведение — нас интересует, были ли вызваны зависимые методы с правильными параметрами.
ХарактеристикаStubMock
Основная задачаПоставлять данные для тестаПроверять взаимодействие между объектами
Что проверяет тестСостояние (результат метода)Поведение (вызовы методов)
Типичный сценарийМетод возвращает значениеМетод void, но выполняет побочное действие
Основной метод в Moq.Setup().Returns().Verify()
ПримерПолучение курса валют, текущей датыОтправка письма, логирование, сохранение в БД

Правило выбора

Правило выбора предельно простое:

  • Если тестируемый метод возвращает значение (не void) — используйте Stub. Вам нужно проверить, что результат вычисления корректен.
  • Если тестируемый метод ничего не возвращает (void) — используйте Mock. Единственный способ проверить его работу — убедиться, что он вызвал нужные методы с правильными параметрами.

Однако на практике эти подходы часто комбинируются в одном тесте. Рассмотрим пример, где есть и возвращаемое значение, и побочный эффект:

csharp

public class OrderProcessor 
{ 
    private readonly IPriceCalculator _calculator; 
    private readonly ILogger _logger;

    public OrderProcessor(IPriceCalculator calculator, ILogger logger) 
    { 
        _calculator = calculator; 
        _logger = logger; 
    }

    public decimal ProcessOrder(Order order) 
    { 
        // Расчет стоимости (возвращает значение) 
        var total = _calculator.Calculate(order);

        // Логирование (побочный эффект, void) 
        _logger.Log($"Заказ №{order.Id} обработан. Сумма: {total}");

        return total; 
    } 
}

Тест, комбинирующий Stub и Mock:

csharp

[Fact] 
public void ProcessOrder_ValidOrder_CalculatesAndLogs() 
{ 
    // Arrange: Stub для данных 
    var stubCalculator = new Mock<IPriceCalculator>(); 
    stubCalculator
        .Setup(c => c.Calculate(It.IsAny<Order>()))
        .Returns(1500m); // Stub возвращает данные

    // Arrange: Mock для проверки взаимодействия 
    var mockLogger = new Mock<ILogger>(); 
    var processor = new OrderProcessor(
        stubCalculator.Object, 
        mockLogger.Object
    ); 
    var order = new Order { Id = 42 };

    // Act 
    var result = processor.ProcessOrder(order);

    // Assert: проверка состояния (Stub) 
    Assert.Equal(1500m, result);

    // Assert: проверка поведения (Mock) 
    mockLogger.Verify(
        l => l.Log("Заказ №42 обработан. Сумма: 1500"), 
        Times.Once
    ); 
}

Здесь мы одновременно:

  1. Используем Stub (stubCalculator) — чтобы подменить расчет и вернуть фиксированную сумму.
  2. Используем Mock (mockLogger) — чтобы проверить, что логирование было выполнено корректно.

Вывод

Выбор между Stub и Mock — это не вопрос «что лучше», а вопрос «что мы проверяем». Если в тесте важен результат — используйте Stub. Если важны побочные эффекты — используйте Mock. А в реальных проектах чаще всего вы будете использовать их вместе.

§3.7 Внедрение зависимостей (Dependency Injection) как архитектурная основа тестируемости

В предыдущих параграфах мы научились создавать Stub и Mock, но остался открытым вопрос: «Как физически подставить эти объекты-заменители в тестируемый код?». Ответ — через Внедрение зависимостей (Dependency Injection, DI).

DI

— это паттерн проектирования, при котором объект получает свои зависимости извне, а не создает их самостоятельно. Вместо того чтобы внутри класса писать new SmtpClient(), мы передаем готовый экземпляр через конструктор, метод или свойство.

Рассмотрим наглядный пример.

Код без DI (жесткая связь):

csharp

public class OrderService 
{ 
    private readonly SmtpClient _smtp = new SmtpClient("smtp.gmail.com");

    public void ProcessOrder(Order order) 
    {
        // Бизнес-логика...
        _smtp.Send(order.Email, "Заказ принят", "Спасибо!"); 
    } 
}

Проблема

Мы не можем подменить _smtp в тесте, потому что он создается внутри класса.

Код с DI (через конструктор):

csharp

public interface IMessageSender 
{ 
    void Send(string to, string subject, string body); 
}

public class OrderService 
{ 
    private readonly IMessageSender _messageSender;

    // ↓ зависимость приходит извне 
    public OrderService(IMessageSender messageSender) 
    { 
        _messageSender = messageSender; 
    }

    public void ProcessOrder(Order order) 
    {
        // Бизнес-логика...
        _messageSender.Send(order.Email, "Заказ принят", "Спасибо!"); 
    } 
}

Теперь в тесте мы можем передать всё что угодно:

csharp

var mockSender = new Mock<IMessageSender>(); 
// ↓ подстановка заглушки 
var service = new OrderService(mockSender.Object);

Существует три способа внедрения зависимостей, и у каждого своя область применения:

СпособОписаниеКогда использовать
Constructor InjectionЗависимость передается через конструктор и сохраняется в полеОсновной способ. Для обязательных зависимостей, без которых класс не может работать.
Property InjectionЗависимость устанавливается через публичное свойство после создания объектаДля опциональных зависимостей (например, логгер). Используется реже.
Method InjectionЗависимость передается непосредственно в методКогда зависимость нужна только для одного метода (например, валидатор для конкретной операции).

Рассмотрим Property Injection на примере. Предположим, у нас есть сервис, который может использовать логгер, но его отсутствие не должно останавливать работу:

csharp

public interface ILogger 
{ 
    void Log(string message); 
}

public class OrderService 
{ 
    private readonly IMessageSender _messageSender;

    // Основная зависимость (обязательная) — через конструктор 
    public OrderService(IMessageSender messageSender) 
    { 
        _messageSender = messageSender; 
    }

    // Опциональная зависимость — через свойство 
    public ILogger Logger { get; set; } // ← Property Injection

    public void ProcessOrder(Order order) 
    {
        // Основная логика...
        _messageSender.Send(order.Email, "Заказ принят", "Спасибо!");

        // Логирование только если логгер установлен 
        if (Logger != null) 
        { 
            Logger.Log($"Заказ {order.Id} обработан"); 
        } 
    } 
}

В тесте мы можем либо подставить логгер (если нам нужно проверить логирование), либо не подставлять (если логирование не важно для теста):

csharp

[Fact] 
public void ProcessOrder_WithLogger_LogsMessage() 
{ 
    // Arrange 
    var mockSender = new Mock<IMessageSender>(); 
    var mockLogger = new Mock<ILogger>();

    var service = new OrderService(mockSender.Object); 
    // ↓ Property Injection в тесте 
    service.Logger = mockLogger.Object;

    // Act 
    service.ProcessOrder(new Order { Id = 123, Email = "test@test.com" });

    // Assert 
    mockLogger.Verify(l => l.Log("Заказ 123 обработан"), Times.Once); 
}

Method Injection используется, когда зависимость нужна только для одного метода и нет смысла хранить её в полях класса. Рассмотрим пример:

csharp

public class ReportGenerator 
{ 
    // Метод принимает зависимость как параметр 
    public string GenerateReport(
        IReportFormatter formatter,  // ↑ Method Injection
        DateTime start, 
        DateTime end
    ) 
    { 
        var data = FetchData(start, end); 
        return formatter.Format(data); 
    } 
}

В тесте мы можем передать заглушку напрямую в вызов метода:

csharp

[Fact] 
public void GenerateReport_CallsFormatter() 
{ 
    var stubFormatter = new Mock<IReportFormatter>(); 
    stubFormatter
        .Setup(f => f.Format(It.IsAny<Data>()))
        .Returns("Отчет");

    var generator = new ReportGenerator();

    // Method Injection: передаем заглушку как аргумент метода 
    var result = generator.GenerateReport(
        stubFormatter.Object, 
        DateTime.Now.AddDays(-7), 
        DateTime.Now
    );

    Assert.Equal("Отчет", result); 
}

Иерархия предпочтений в реальных проектах

  1. Constructor Injection — используйте всегда, когда зависимость обязательна.
  2. Property Injection — используйте только для опциональных зависимостей (когда класс может работать и без неё).
  3. Method Injection — используйте только когда зависимость нужна для одного конкретного метода.

Все обязательные зависимости класса должны быть явно видны в его конструкторе. Это делает код честным и прозрачным. Если класс принимает в конструкторе 5 интерфейсов — это сигнал, что он делает слишком много и нарушает принцип единственной ответственности (SRP). Если класс не принимает ничего, но внутри создает зависимости через new — это нетестируемый код.

§3.8 Принцип инверсии зависимостей (DIP) и его роль в тестировании

В предыдущем параграфе мы рассмотрели Dependency Injection как конкретный паттерн передачи зависимостей. Однако за этим паттерном стоит более глубокий принцип проектирования — Dependency Inversion Principle (DIP), один из пяти принципов SOLID. Понимание этого принципа позволяет осознанно проектировать архитектуру, а не просто механически использовать DI.

Формулировка DIP:

Dependency Inversion Principle

  1. Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций.
  2. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

На практике это означает, что бизнес-логика не должна знать, как именно сохраняются данные, генерируются отчеты или выполняются внешние запросы. Она должна знать только интерфейс для этих действий.

Рассмотрим нарушение DIP на примере класса, который генерирует отчет и сохраняет его в базе данных:

csharp

public class ReportService 
{ 
    private readonly SqlConnection _connection = new SqlConnection("Server=...");
    // ↑ Зависимость от конкретной реализации

    public void GenerateAndSaveReport(DateTime period) 
    { 
        // 1. Генерация отчета 
        var report = $"Отчет за {period:yyyy-MM}";
        //... логика формирования...

        // 2. Сохранение в базу 
        using var cmd = new SqlCommand("INSERT INTO Reports...", _connection); 
        cmd.ExecuteNonQuery(); 
    } 
}

Проблема

ReportService жестко привязан к SqlConnection. Если мы захотим сохранять отчеты в файл, в облачное хранилище или просто проверить логику генерации в тесте без реальной базы данных — мы не сможем этого сделать без переписывания кода.

Исправление — соблюдение DIP:

csharp

// Абстракция (интерфейс) 
public interface IReportStorage 
{ 
    void Save(string reportContent); 
}

// Деталь №1 — сохранение в базу данных 
public class DatabaseReportStorage : IReportStorage 
{ 
    private readonly string _connectionString;

    public DatabaseReportStorage(string connectionString) => 
        _connectionString = connectionString;

    public void Save(string reportContent) => 
        // Реальное сохранение в БД
}

// Деталь №2 — сохранение в файл 
public class FileReportStorage : IReportStorage 
{ 
    private readonly string _path;

    public FileReportStorage(string path) => _path = path;

    public void Save(string reportContent) => 
        File.WriteAllText(_path, reportContent); 
}

// Бизнес-логика зависит от абстракции 
public class ReportService 
{ 
    // ↓ Зависимость от интерфейса 
    private readonly IReportStorage _storage;

    public ReportService(IReportStorage storage) => 
        _storage = storage;

    public void GenerateAndSaveReport(DateTime period) 
    { 
        // Генерация отчета 
        var report = $"Отчет за {period:yyyy-MM}";
        //... логика формирования...

        // Сохранение через абстракцию 
        _storage.Save(report); 
    } 
}

Теперь ReportService не знает, куда именно сохраняется отчет — в базу данных, в файл или в облако. Он знает только, что у объекта есть метод Save. Это делает код гибким: мы можем легко добавить новый способ сохранения, просто реализовав интерфейс IReportStorage.

Именно благодаря DIP мы можем подставить Stub или Mock в тесты. Если бы ReportService зависел от конкретного SqlConnection, мы не смогли бы его протестировать без реальной базы данных. Но поскольку он зависит от интерфейса IReportStorage, мы в тесте передаем заглушку:

csharp

public class StubReportStorage : IReportStorage 
{ 
    public string SavedContent { get; private set; }

    public void Save(string reportContent) 
    { 
        // Сохраняем в память, а не в базу 
        SavedContent = reportContent; 
    } 
}

[Fact] 
public void GenerateAndSaveReport_CreatesCorrectContent() 
{ 
    // Arrange 
    var stubStorage = new StubReportStorage(); 
    var service = new ReportService(stubStorage); 
    var period = new DateTime(2026, 1, 1);

    // Act 
    service.GenerateAndSaveReport(period);

    // Assert 
    Assert.Contains("Отчет за 2026-01", stubStorage.SavedContent); 
    // Проверяем логику генерации, не заботясь о сохранении 
}

DI vs DIP: в чем разница?

Понятия DI и DIP часто путают. Проведем четкую границу:

  • DIP (Dependency Inversion Principle) — это принцип. Он отвечает на вопрос «Как правильно спроектировать зависимости между классами?». Ответ: зависеть от интерфейсов, а не от конкретных классов.
  • DI (Dependency Injection) — это паттерн. Он отвечает на вопрос «Как физически передать зависимость в объект?». Ответ: через конструктор, свойство или метод.

Простая аналогия:

  • DIP — это закон дорожного движения: «Ездить по правой стороне».
  • DI — это руль: инструмент, который позволяет этот закон соблюдать.

DIP говорит «завись от абстракций», а DI дает инструмент — «передавай эти абстракции через конструктор».

§3.9 Контейнеры IoC: автоматизация управления зависимостями

Мы выяснили, что Dependency Injection — это мощный паттерн, делающий код тестируемым. Однако в реальных проектах, особенно крупных, ручное создание объектов через конструктор превращается в утомительную рутину. Рассмотрим типичный пример создания сервиса с несколькими зависимостями:

csharp

var logger = new FileLogger("app.log"); 
var repository = new OrderRepository(connectionString); 
var emailSender = new SmtpEmailSender(smtpConfig); 
var discountCalculator = new DiscountCalculator(); 

var orderService = new OrderService(
    repository, 
    emailSender, 
    discountCalculator, 
    logger
);

В проекте с сотней классов такое ручное связывание превращается в кошмар: каждый раз, когда меняется конструктор одного класса (добавляется новая зависимость), приходится править код создания во всех местах. Именно для решения этой проблемы существуют IoC-контейнеры (Inversion of Control Containers).

IoC-контейнер

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

Популярные IoC-контейнеры в .NET:

КонтейнерОписание
Microsoft.Extensions.DependencyInjectionВстроенный в .NET Core/ASP.NET Core. Стандарт для современных проектов.
AutofacГибкий и мощный, поддерживает множество расширений (декораторы, модули).
NinjectПростой и легковесный, популярен в старых проектах.
Castle WindsorМощный функциональный контейнер, но достаточно тяжеловесный.

Пример регистрации и использования (Microsoft.Extensions.DependencyInjection):

csharp

using Microsoft.Extensions.DependencyInjection;

// 1. Создаём коллекцию сервисов (регистрируем зависимости) 
var services = new ServiceCollection();

// Регистрируем: при запросе IReportStorage вернуть FileReportStorage 
services.AddScoped<IReportStorage, FileReportStorage>(); 

// Регистрируем сам сервис (контейнер сам поймёт, что ему нужен IReportStorage) 
services.AddScoped<ReportService>();

// 2. Строим провайдер (контейнер) 
var serviceProvider = services.BuildServiceProvider();

// 3. Получаем готовый объект со всеми зависимостями 
var reportService = serviceProvider.GetRequiredService<ReportService>(); 
// Контейнер сам создал FileReportStorage и 
// передал его в конструктор ReportService

Жизненные циклы (Scope) в контейнерах

При регистрации важно указать, как долго должен жить объект:

Метод регистрацииЖизненный циклКогда использовать
AddTransient<T>()Создается каждый раз при запросеДля легковесных, не имеющих состояния объектов
AddScoped<T>()Один экземпляр на запрос (в веб-приложении)Для объектов, которые должны жить в рамках одного HTTP-запроса
AddSingleton<T>()Один экземпляр на всё приложениеДля тяжелых объектов (кеши, конфигурация)

Пример с разными жизненными циклами:

csharp

// Создаётся каждый раз 
services.AddTransient<IEmailSender, SmtpEmailSender>(); 

// Один на запрос 
services.AddScoped<IOrderRepository, OrderRepository>(); 

// Один на всё приложение 
services.AddSingleton<IConfiguration, AppConfiguration>();

Важное различие: Production vs Тесты

Это критически важный момент, который нужно запомнить – это граница ответственности между production-кодом и тестами:

СредаЧто используемПочему
Production-кодIoC-контейнерАвтоматизирует создание объектов, упрощает поддержку
ТестыРучной DI (передача Stub/Mock через конструктор)Нужен полный контроль над зависимостями; контейнеры добавляют лишнюю сложность

В тестах мы не используем контейнер, потому что:

  1. Нам нужно подставлять Stub и Mock — контейнер этого не умеет делать (он работает с реальными реализациями).
  2. Каждый тест должен иметь изолированное окружение — общий контейнер нарушил бы независимость тестов.
  3. Тесты должны быть максимально простыми и понятными — скрытая магия контейнера только запутывает.

В production-коде:

csharp

// Program.cs 
var services = new ServiceCollection(); 
services.AddScoped<IReportStorage, FileReportStorage>(); 
services.AddScoped<ReportService>(); 

var provider = services.BuildServiceProvider(); 
var service = provider.GetRequiredService<ReportService>();

В тестах:

csharp

// ReportServiceTests.cs 
[Fact] 
public void GenerateReport_ValidData_ReturnsCorrectResult() 
{ 
    // Мы сами создаём зависимости — никакого контейнера 
    var stubStorage = new StubReportStorage(); 
    var service = new ReportService(stubStorage); // ← ручной DI

    var result = service.GenerateReport(...);

    Assert.Equal(expected, result); 
}

Итог

IoC-контейнеры — это инструмент для production-кода, упрощающий управление зависимостями в больших системах. Однако для тестирования мы всегда используем ручной DI, потому что нам нужен полный контроль над каждым объектом. Это разделение ответственности — признак профессионального подхода и понимания архитектуры приложения.