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

Лекция №6. Интеграционное тестирование сложных распределенных систем. Тестирование слоев взаимодействия с СУБД.

5 419 слов6 разделов0 иллюстраций
↓ Скачать Markdown

§6.1. Юнит-тесты и интеграционные тесты: границы ответственности

В первом семестре вы освоили юнит-тестирование. Давайте вспомним ключевую идею:

Информация

Юнит-тест проверяет один маленький кирпичик (один класс или даже один метод) в изоляции от всего остального.

Почему это хорошо:

  • Тесты выполняются за доли секунды.
  • Они стабильные — не падают из-за проблем с сетью или базой данных.
  • Их легко писать и поддерживать.

Но в реальной жизни программист пишет не изолированные методы, а систему. Классы в системе общаются друг с другом, ходят в базу данных, отправляют HTTP-запросы на другие сервисы.

И вот тут возникает неприятный сюрприз:

Информация

Юнит-тесты не могут гарантировать, что вся система работает.

Почему? Потому что:

  • Юнит-тест проверяет: «Может ли метод А вызвать метод Б?» (через заглушку).
  • Интеграционная проблема: «А правильно ли метод А передал данные методу Б? И сможет ли метод Б корректно обработать эти данные в реальной базе данных?».

Юнит-тест «думает», что всё хорошо, потому что заглушка всегда отвечает как надо. А в реальности — база данных падает с ошибкой SQL.

Давайте посмотрим на проблему в коде. Вот у нас есть простой сервис, который сохраняет заказ:

csharp

// Репозиторий (работает с реальной базой данных)
public class OrderRepository
{
    public void Save(Order order)
    {
        // Здесь выполняется SQL-запрос INSERT в таблицу Orders
        // Например: INSERT INTO Orders (Id, CustomerName, Total) 
        // VALUES (...)
        // Если поле Total не может быть null, 
        // а мы передали null — будет ошибка!
    }
}

// Сервис (содержит бизнес-логику)
public class OrderService
{
    private readonly OrderRepository _repository;
    
    public OrderService(OrderRepository repository)
    {
        _repository = repository;
    }
    
    public void CreateOrder(string customerName, decimal? total)
    {
        var order = new Order
        {
            CustomerName = customerName,
            Total = total  // Внимание! Здесь может прийти null
        };
        _repository.Save(order);
    }
}

Теперь два сценария:

Юнит-тест сказал «всё хорошо», а программа упала. Ошибка находится не внутри метода CreateOrder (он написан правильно), и не внутри Save (он тоже правильный). Ошибка — в связке между ними: сервис передает данные в неверном формате, а репозиторий не проверяет это заранее.

Интеграционное тестирование

— это проверка того, как наши модули работают вместе с реальными внешними системами.

Главная цель

Проверить маршрут данных — от вызова метода в сервисе до реального сохранения в базе данных (или реального запроса во внешний API).

Ключевое правило интеграционного теста

Базу данных и внешние сервисы мы НЕ мокаем. Мы работаем с ними по-настоящему

Почему? Потому что нас интересует именно взаимодействие. Если мы замокаем базу данных, мы снова проверим только логику метода, а не факт того, что SQL-запрос написан правильно.

Чтобы вам было проще запомнить разницу, используйте простое правило:

Юнит-тестИнтеграционный тест
Проверяет логику внутри одного методаПроверяет связи между несколькими модулями
Все зависимости подменены заглушкамиЗависимости реальные (БД, API)
Быстрый (миллисекунды)Медленный (секунды)
Можно запускать сотни раз подрядТребует подготовленного окружения
Падает из-за ошибки в кодеПадает из-за ошибки в коде, SQL, сети, настроек

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

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

Самая частая ошибка новичка

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

Обычно все новички рассуждают так: «Я же тестирую сервис, зачем мне реальная БД? Поставлю Mock репозитория и всё проверю».

Но это неправильно. Потому что такой тест не проверяет интеграцию. Он проверяет только то, что сервис вызвал метод Save. Но он не проверяет:

  • Правильно ли сформирован SQL-запрос?
  • Есть ли нужные таблицы и столбцы в базе?
  • Не нарушены ли ограничения внешних ключей?
  • Пройдет ли запрос в реальной СУБД?

Важное правило для запоминания

Если в тесте есть Mock для базы данных или внешнего API — это не интеграционный тест. Это юнит-тест с другим названием.

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

§6.2. Специфика тестирования слоя доступа к данным

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

Давайте разберем три главные проблемы, с которыми вы обязательно встретитесь при работе с интеграционным тестированием.

Проблема №1. Состояние (State) — данные никуда не исчезают

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

С базой данных так не работает.

Представьте ситуацию:

  1. Вы запускаете тест №1. Он создает пользователя «Иван» в таблице Users.
  2. Тест успешно завершается. Пользователь «Иван» остается в базе.
  3. Вы запускаете тест №2. Он проверяет, сколько пользователей в таблице, и ожидает, что их 0 (потому что тест начинает с чистого состояния).
  4. Тест №2 падает, потому что в таблице уже есть «Иван» из первого теста.

В чем проблема? Тесты перестали быть независимыми. Один тест влияет на результат другого. Это называется «тесты влияют друг на друга» (flaky tests — нестабильные тесты).

Как это выглядит на практике:

csharp

// Тест №1 (успешно создает пользователя)
[Test]
public void CreateUser_ShouldAddUserToDatabase()
{
    var user = new User { Name = "Иван" };
    repository.Add(user);
    var found = repository.FindByName("Иван");
    Assert.IsNotNull(found); // Тест проходит
}
// После этого теста в БД есть Иван

// Тест №2 (проверяет, что пользователей нет)
[Test]
public void GetUsers_ShouldReturnEmptyList_WhenNoUsers()
{
    var users = repository.GetAll();
    Assert.AreEqual(0, users.Count); // А здесь уже 1! Тест падает
}

При тестировании работы с БД мы обязаны как-то контролировать состояние данных, чтобы тесты не мешали друг другу. Как именно — разберем в §1.3.. Пока просто запомните: это главная головная боль.

Проблема №2. Окружение — на чьей машине мы запускаем тесты?

Вторая проблема возникает, когда вы пишете тесты на своем компьютере, а потом они запускаются на сервере сборки (CI/CD) или в среде коллеги.

Суть проблемы: не все базы данных одинаковые.

  • У вас на ноутбуке стоит PostgreSQL 15.
  • У коллеги — PostgreSQL 13 (старее).
  • На сервере сборки — PostgreSQL 16 (новее).

Казалось бы, мелочь. Но между версиями есть различия:

  • По-разному работают некоторые функции (например, DISTINCT ON или оконные функции).
  • По-разному сортируются строки в зависимости от локали.
  • В новой версии могут появиться новые типы данных, а в старой их нет.

Пример из жизни:

sql

-- Запрос, который отлично работает на PostgreSQL 14
SELECT * FROM orders 
WHERE created_at::date = '2024-01-01';

-- На PostgreSQL 12 этот же запрос может выдать ошибку
-- или работать по-другому из-за особенностей приведения типов

Ваш интеграционный тест на ноутбуке проходит «зеленым» (успешно). Вы отправляете код в репозиторий. На сервере сборки тест падает, хотя вы ничего не меняли. Причина — разные версии СУБД.

Нам нужен способ, чтобы все разработчики и сервер сборки тестировали код на одинаковой версии базы данных. Чтобы не было сюрпризов «у меня работает, а на сервере нет».

Проблема №3. Ловушка In-Memory Database

В мире C# и Entity Framework есть соблазнительная штука — In-Memory Database. Это база данных, которая существует только в памяти компьютера и не сохраняет данные на диск.

Microsoft предлагает ее для тестирования. Выглядит заманчиво:

csharp

// Это НЕПРАВИЛЬНЫЙ подход для интеграционных тестов!
var options = new DbContextOptionsBuilder<MyDbContext>()
    .UseInMemoryDatabase(databaseName: "TestDb")
    .Options;
var context = new MyDbContext(options);
// Теперь можно писать тесты, и они будут быстрыми!

Почему это ловушка? На первый взгляд — идеально:

  • Быстро работает (не нужно поднимать реальную БД).
  • Не требует установки PostgreSQL/SQL Server.
  • Тесты не влияют друг на друга (база в памяти очищается).

Но есть три фатальные проблемы, из-за которых In-Memory НЕЛЬЗЯ использовать для интеграционных тестов:

Итоговое правило, которое нужно запомнить

In-Memory Database

— это инструмент для юнит-тестирования, а не для интеграционного

Если вы пишете интеграционный тест для слоя доступа к данным, вы должны использовать реальную СУБД (ту же, что стоит в продакшене). Без вариантов.

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

§6.3. Современные подходы к изоляции БД в тестах

В предыдущем параграфе мы выяснили две главные проблемы при тестировании слоя доступа к данным:

1. Состояние: тесты влияют друг на друга, потому что данные остаются в БД.

2. Окружение: на разных машинах могут быть разные версии СУБД.

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

Подход А. Транзакционная изоляция (Rollback)

Идея

Каждый тест оборачивается в транзакцию базы данных. В начале теста транзакция открывается, в конце — откатывается (выполняется Rollback). Все изменения, которые сделал тест, как бы «стираются» после его завершения.

Как это выглядит на практике:

1. Тест начинает транзакцию (например, BEGIN TRANSACTION).

2. Тест добавляет, изменяет, удаляет данные.

3. Тест выполняет проверки (Assert).

4. В конце выполняется ROLLBACK — все изменения отменяются.

5. База данных возвращается в исходное состояние.

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

В C# это часто делается через TransactionScope:

csharp

using (var transaction = new TransactionScope())
{
    // 1. Тест что-то делает с БД
    // 2. Проверяет результат
    // 3. Не вызываем transaction.Complete()
    // При выходе из using автоматически выполняется Rollback
}

Плюсы этого подхода:

  • Быстро. Не нужно поднимать и опускать базу данных заново. Транзакции работают быстро.
  • Просто. Не требует дополнительных инструментов (Docker, контейнеры). Работает «из коробки» с любой СУБД.
  • Изолирует тесты друг от друга. Каждый тест стартует с чистого состояния.

Минусы этого подхода:

Вывод по подходу А заключается в следующем. Это хороший и быстрый метод, но подходит в основном для простых тестов, которые не затрагивают структуру базы данных (не создают таблицы, не выполняют миграции).

Подход Б. Эфемерные контейнеры (Testcontainers)

Идея

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

Как это выглядит на практике:

1. Перед запуском тестов (в коде) поднимается Docker-контейнер с чистой СУБД.

2. Тесты подключаются к этому контейнеру и работают с ним как с полноценной базой.

3. После завершения всех тестов контейнер удаляется.

Важно

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

Для C# есть библиотека Testcontainers, которая позволяет делать это программно:

csharp

// Примерная идея (упрощенно)
var container = new PostgreSqlBuilder()
    .WithImage("postgres:15")
    .WithDatabase("testdb")
    .WithUsername("postgres")
    .WithPassword("password")
    .Build();
await container.StartAsync(); // Поднимаем контейнер
// Теперь все тесты подключаются к этой базе
var connectionString = container.GetConnectionString();
// После всех тестов
await container.StopAsync(); // Останавливаем контейнер

Плюсы этого подхода:

Минусы этого подхода:

Вывод по подходу Б заключается в следующем. Это современный, надежный и промышленный стандарт. Да, он требует Docker и чуть больше времени на настройку, но он дает 100% уверенность, что ваши тесты проходят в окружении, максимально близком к боевому.

В профессиональной практике сложилось два основных подхода. У каждого есть свои сильные и слабые стороны. Давайте сравним их в одной таблице, чтобы вы могли принять осознанное решение.

ХарактеристикаТранзакционная изоляция (Rollback)Эфемерные контейнеры (Testcontainers)
СкоростьБыстро (миллисекунды)Медленнее (секунды на поднятие)
ИзоляцияТесты изолированы (данные откатываются)Абсолютная изоляция (новая БД для каждого запуска)
Работа с миграциямиПроблематично (не все изменения откатываются)Отлично (можно накатывать что угодно)
Приближение к реальной БДВысокое (работает с реальной СУБД)Максимальное (та же версия, что в продакшене)
ТребованияТолько СУБД (должна быть установлена)Docker (должен быть установлен)
Одинаковость окруженияЗависит от установленной версии СУБДГарантирована (все используют один Docker-образ)

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

Неправильно:

csharp

[Test]
public void Test1()
{
    var container = new PostgreSqlBuilder().Build();
    await container.StartAsync(); // Поднимаем для каждого теста
    // ... тест ...
    await container.StopAsync(); // Останавливаем после теста
}

[Test]
public void Test2()
{
    var container = new PostgreSqlBuilder().Build();
    await container.StartAsync(); // Снова поднимаем!
    // ... тест ...
    await container.StopAsync();
}

Это будет очень медленно. Контейнер должен подниматься один раз (например, в конструкторе класса или в методе [ClassInitialize]), а все тесты внутри класса используют его.

§6.4. Интеграция с внешними сервисами

До этого параграфа мы говорили о тестировании работы с базой данных. Но в реальных распределенных системах ваш код общается не только с БД. Он также отправляет запросы к другим сервисам по сети: платежным шлюзам, сервисам погоды, API соцсетей, системам отправки email и т.д.

Это называется интеграцией с внешними сервисами (или интеграцией по сети). И тестировать такие взаимодействия — отдельная большая задача.

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

Когда вы пишете интеграционный тест для этой логики, вы сталкиваетесь с проблемами:

Прямая интеграция с реальным внешним API в тестах — это плохая идея. Нам нужен способ эмулировать внешний сервис.

Для эмуляции внешнего сервиса используется стратегия заглушек (Stub / Mock).

Суть стратегии проста

Вместо реального внешнего сервиса мы подставляем заглушку — локальный HTTP-сервер, который мы полностью контролируем.

Как это работает:

1. В тесте мы поднимаем локальный HTTP-сервер (заглушку).

2. Программируем его поведение: «Если к тебе придет запрос на /api/payment, ответь статусом 200 и верни JSON: { "status": "success" }».

3. Наш код отправляет запрос не на реальный платежный шлюз, а на локальный адрес заглушки.

4. Заглушка отвечает так, как мы запрограммировали.

5. Мы проверяем, правильно ли наш код обработал этот ответ.

Плюсы этого подхода:

  • Быстро: Локальный сервер отвечает за миллисекунды.
  • Стабильно: Заглушка всегда работает (в отличие от реального API).
  • Бесплатно: Не нужно платить за тестовые вызовы.
  • Гибко: Мы можем запрограммировать ЛЮБОЙ ответ: 200, 400, 500, таймаут, пустой ответ, битые данные — всё, что угодно. Это позволяет проверить все сценарии поведения нашего кода.

В мире C# есть несколько популярных библиотек для создания HTTP-заглушек:

  • WireMock.NET — мощный инструмент с гибкими настройками. Позволяет задавать правила: «если пришел запрос с таким-то телом и заголовком, ответь так-то».
  • MockHttp — более легкая библиотека, проще в освоении.

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

Пример с WireMock (упрощенно):

csharp

// 1. Запускаем заглушку
var server = WireMockServer.Start();

// 2. Программируем поведение
server.Given(
    Request.Create().WithPath("/api/payment").UsingPost()
)
.RespondWith(
    Response.Create()
        .WithStatusCode(200)
        .WithBody("{ \"status\": \"success\" }")
);

// 3. Настраиваем наш сервис, чтобы он стучался в заглушку
var httpClient = new HttpClient();

// Подключаемся к локальной заглушке
httpClient.BaseAddress = new Uri(server.Url); 

var paymentService = new PaymentService(httpClient);

// 4. Тест
var result = await paymentService.ProcessPayment(order);

// 5. Проверяем результат
Assert.True(result.IsSuccess);

// 6. Останавливаем заглушку
server.Stop();

Разберём данный код по шагам:

  • Мы подняли фейковый сервер на локальном порту для дальнейшей работы.
  • Сказали ему: «На POST-запрос по адресу /api/payment отвечай статусом 200 и JSON-объектом».
  • Наш сервис отправил запрос на этот локальный адрес, а не в реальную платежную систему. Данная процедура помогает правильно оценить работоспособность сервиса.
  • Мы проверили, что наш сервис правильно обработал ответ и продолжил выполнение своей работы.

Пример с MockHttp выглядит примерно следующим образом. Предположим, у нас есть простой сервис, который вызывает внешнее API для получения данных о пользователе, который продемонстрирован ниже на языке программирования C#.

csharp

public class UserService
{
    private readonly HttpClient _httpClient;

    // HttpClient передается через конструктор (Dependency Injection)
    public UserService(HttpClient httpClient)
        => _httpClient = httpClient;
    public async Task<string> GetUserNameAsync(int userId)
    {
        // Отправляем GET-запрос к внешнему API
        var response = await _httpClient.GetAsync(
           $"https://api.example.com/users/{userId}"
        );

        // Если статус не 200 OK — выбрасываем исключение
        response.EnsureSuccessStatusCode();

        // Читаем тело ответа как строку (допустим, приходит просто имя)
        return await response.Content.ReadAsStringAsync();
    }
}

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

Теперь создадим тест, который проверяет, что сервис правильно обрабатывает успешный ответ от внешнего API.

csharp

using RichardSzalay.MockHttp;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;

public class UserServiceTests
{
    [Fact]
    public async Task GetUserNameAsync_WhenUserExists_ReturnsUserName()
    {
        // 1.1 Создаем обработчик-заглушку
        var mockHttp = new MockHttpMessageHandler();

        // 1.2 Настраиваем поведение заглушки
        // Говорим: "Когда придет GET-запрос по адресу .../users/123, 
        // ответь статусом 200 и верни строку 'Alice'"
        mockHttp.When(HttpMethod.Get, 
                      "https://api.example.com/users/123")
                .Respond(HttpStatusCode.OK, new StringContent("Alice"));

        // 1.3 Создаем HttpClient, который использует наш обработчик
        var httpClient = mockHttp.ToHttpClient();

        // 1.4 Создаем экземпляр тестируемого сервиса, внедряя в него подготовленный HttpClient
        var service = new UserService(httpClient);

        // Вызываем тестируемый метод сервиса
        var result = await service.GetUserNameAsync(123);

        // Проверяем, что сервис вернул ожидаемое имя
        Assert.Equal("Alice", result);

        // Дополнительно: можно проверить, что запрос действительно был отправлен
        // mockHttp.VerifyNoOutstandingExpectation(); // (опционально)
    }
}

Что тестировать с помощью заглушек? Когда мы используем заглушку, мы не тестируем работу внешнего API (это не наша ответственность, мы не владеем тем сервисом). Мы тестируем нашу логику обработки ответов.

Что мы проверяем в интеграционных тестах:

1. Успешный сценарий (200 OK): Приходит корректный ответ → наш код правильно обрабатывает данные, сохраняет их в БД и т.д.

2. Ошибка клиента (400 Bad Request): Неправильный запрос → наш код падает с понятной ошибкой (не просто вылетает, а корректно обрабатывает ситуацию).

3. Ошибка сервера (500 Internal Server Error): Внешний сервис упал → наш код логирует ошибку, не падает, возвращает понятный статус клиенту.

4. Таймаут: Внешний сервис слишком долго отвечает → наш код не висит бесконечно, а прерывает ожидание через определенное время и обрабатывает таймаут.

5. Некорректный формат данных: Внешний сервис вернул JSON с неожиданной структурой → наш код не крашится, а адекватно реагирует.

Важный принцип

Мы пишем интеграционный тест, который проверяет, что наш код адаптивно и корректно реагирует на разные ответы внешнего сервиса

До сих пор мы говорили о REST API — это когда мы отправляем HTTP-запросы с JSON или XML.

Но существует и другой популярный протокол — gRPC.

gRPC

— это современный протокол для связи между сервисами, разработанный Google. В отличие от REST, где мы отправляем простые текстовые JSON-сообщения, в gRPC данные передаются в бинарном формате (Protocol Buffers).

Чем gRPC отличается от REST:

ХарактеристикаRESTgRPC
Формат данныхТекстовый (JSON, XML)Бинарный (Protocol Buffers)
СкоростьМедленнее (текст парсить дольше)Быстрее (бинарные данные легче)
ЧеловекочитаемостьДа (можно посмотреть JSON в браузере)Нет (бинарные данные нечитаемы)
ПротоколHTTP 1.1 или HTTP 2HTTP 2
ТипизацияСлабая (можно отправить любые поля)Строгая (контракт описан в .proto-файлах)
Пример использованияВеб-приложения, публичные APIВнутреннее общение микросервисов

Стоит ли тестировать gRPC отдельно? Если ваша система использует gRPC для общения с внешними сервисами — да, принцип тот же самый.

Вы точно так же используете заглушки (для gRPC есть специальные библиотеки, например, Grpc.Testing).

Главное правило интеграционных тестов с внешними сервисами

В интеграционных тестах мы тестируем нашу логику обработки ответов внешнего API, а не работу самого внешнего API

Внешний API

— это «черный ящик». Мы не контролируем его. Мы не знаем, какие у него баги. Наша задача — сделать наш код устойчивым к любым ответам этого «черного ящика».

Мы используем заглушки, чтобы симулировать все возможные сценарии и убедиться, что наш код:

  • Не падает при ошибках.
  • Корректно обрабатывает успешные ответы.
  • Логирует проблемы.
  • Возвращает клиенту понятные сообщения.

§6.5. Структура интеграционного теста

В первом семестре вы уже знакомились с паттерном AAA (Arrange-Act-Assert) при написании юнит-тестов. В интеграционных тестах этот паттерн остается тем же самым, но наполняется новым смыслом, потому что мы работаем с реальной базой данных и внешними сервисами.

Напомню структуру:

  • Arrange (Подготовка): настраиваем всё, что нужно для теста.
  • Act (Действие): вызываем тестируемый метод.
  • Assert (Проверка): проверяем, что результат соответствует ожиданиям.

Кажется, что ничего сложного. Но именно в интеграционных тестах новички чаще всего ошибаются, особенно на этапе Assert. Давайте разберем каждый шаг подробно.

Шаг №1. Arrange (Подготовка).

Это самый важный и самый объемный этап в интеграционном тесте. Здесь мы готовим окружение так, чтобы тест был изолированным и предсказуемым.

Что нужно сделать в Arrange:

1. Очистить базу данных от «мусора»

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

  • Откат транзакции (см. §1.3) — самый простой способ.
  • Пересоздание схемы БД перед каждым тестом (медленно, но надежно).
  • Удаление конкретных записей вручную (для каждого теста своя очистка).

Если вы не очистите БД, тесты начнут влиять друг на друга (см. §1.2). Это самая частая причина падения интеграционных тестов.

2. Подготовить тестовые данные (Seed Data)

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

  • В методе инициализации тестового класса (выполняется один раз для всех тестов).
  • В самом тесте перед вызовом тестируемого метода.

3. Настроить заглушку внешнего сервиса (если есть)

Если ваш метод обращается к внешнему API, вы настраиваете MockHttp или WireMock на нужный ответ (см. §1.4). Это тоже часть подготовки.

4. Убедиться в чистоте

Хорошая практика — перед Arrange проверить, что база действительно пустая (или содержит только то, что должно быть). Это защита от «мусора».

Шаг 2. Act (Действие).

На этом этапе мы вызываем тот метод, который тестируем. Здесь всё просто:

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

Важный момент

Act должен быть максимально простым и коротким. Один тест — один вызов метода. Если вы вызываете несколько методов в Act — вы проверяете слишком много за раз. Это усложняет отладку.

Шаг №3. Assert (Проверка) — здесь чаще всего ошибаются.

Вот тут начинается самое интересное. В юнит-тестах проверка обычно выглядит так:

csharp

// Юнит-тест: проверяем возвращаемое значение
var result = service.CalculateDiscount(user);
Assert.Equal(10, result); // Просто проверили число — и всё

В интеграционном тесте так делать НЕЛЬЗЯ. Почему? Потому что интеграционный тест проверяет не только логику метода, но и факт взаимодействия с базой данных. Главное в интеграционном тесте — убедиться, что данные действительно оказались в БД в правильном виде.

Правильный подход в интеграционном тесте:

csharp

// ИНТЕГРАЦИОННЫЙ тест: проверяем, что данные сохранились в БД
// 1. Вызываем метод, который сохраняет заказ
service.CreateOrder(userId, productId, quantity);

// 2. Делаем запрос в реальную БД
var savedOrder = dbContext.Orders.FirstOrDefault(o => o.UserId == userId);

// 3. Проверяем, что данные есть и они правильные
Assert.NotNull(savedOrder);
Assert.Equal(productId, savedOrder.ProductId);
Assert.Equal(quantity, savedOrder.Quantity);

Запомните это правило:

В интеграционном тесте Assert должен касаться источника данных (БД), а не только возвращаемого значения метода

Для того, чтобы понять почему это так важно, разберем типичную ошибку, которые обычно совершают новички.

Неправильный интеграционный тест (ошибочный):

csharp

[Test]
public void CreateOrder_ShouldReturnOrderId_WhenOrderValid()
{
    // Arrange
    var order = new Order { 
       UserId = 1, 
       ProductId = 100, 
       Quantity = 2 
    };

    // Act
    // Метод возвращает ID
    var orderId = orderService.CreateOrder(order); 

    // Assert (ОШИБКА!)
    // Проверяем только возвращенный ID
    Assert.NotEqual(0, orderId); 
}

В чем ошибка? Тест проверяет только то, что метод вернул какой-то ID (не ноль). Но он НЕ ПРОВЕРЯЕТ, что заказ действительно сохранен в базе данных. Может быть так:

  • Метод вернул ID, но данные не сохранились (ошибка находится в транзакции).
  • Метод вернул ID, но сохранились не все поля (например, Quantity не сохранился).
  • Метод вернул ID старого заказа, а новый не создался, что является грубейшей ошибкой.

Юнит-тест такое бы пропустил. Интеграционный — должен такую ошибку поймать.

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

csharp

[Test]
public void CreateOrder_ShouldSaveOrderToDatabase_WhenOrderValid()
{
    // Arrange
    var order = new Order { 
       UserId = 1, 
       ProductId = 100, 
       Quantity = 2 
    };

    // Act
    var orderId = orderService.CreateOrder(order);

    // Assert (ПРАВИЛЬНО!)
    var savedOrder = dbContext.Orders.Find(orderId);
    Assert.NotNull(savedOrder); // Проверяем, что заказ есть в БД

    // Проверяем все поля
    Assert.Equal(order.UserId, savedOrder.UserId); 
    Assert.Equal(order.ProductId, savedOrder.ProductId);
    Assert.Equal(order.Quantity, savedOrder.Quantity);
}

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

При работе с БД:

  • Данные сохранены (есть запись в таблице).
  • Все поля сохранены правильно (значения совпадают).
  • Связанные данные сохранены (например, если сохраняется заказ со списком товаров — проверяем, что товары тоже сохранились).
  • Количество записей изменилось (например, был 1 заказ, стало 2).
  • Ограничения не нарушены (внешние ключи, уникальность).

При работе с внешним сервисом (через заглушку):

  • Метод вернул правильный HTTP-статус (200, 400, 500).
  • Метод правильно обработал ответ (извлек данные, преобразовал формат).
  • Метод корректно обработал ошибку (не упал, залогировал, вернул понятный клиенту ответ).

Чего НЕ нужно проверять в интеграционном тесте:

  • Внутреннюю логику метода (это задача юнит-тестов).
  • Работу внешнего API (это не наша ответственность).
  • Производительность (для этого есть нагрузочное тестирование).

§6.6. Основные антипаттерны интеграционного тестирования

В предыдущих параграфах мы разобрали, как правильно писать интеграционные тесты. Теперь давайте поговорим о том, как НЕ надо делать.

Опыт показывает, что новички (и даже опытные разработчики) часто совершают одни и те же ошибки, которые сводят на нет всю пользу от интеграционного тестирования.

Запомните

Интеграционный тест, написанный с ошибками, хуже, чем его отсутствие. Потому что он создает ложное чувство уверенности.

Антипаттерн №1. Тест-слоупок (Проверка всего и вся)

Суть проблемы

Один тест пытается проверить слишком много всего сразу: логику, работу с БД, внешний API, кеш, авторизацию и еще что-нибудь.

Почему это плохо:

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

Пример плохого теста (антипаттерн):

csharp

[Test]
public void CreateOrder_EverythingTest()
{
    // Проверяем авторизацию
    // Проверяем логику расчета скидки
    // Проверяем сохранение в БД
    // Проверяем отправку email-уведомления
    // Проверяем обновление кеша
    // Проверяем запись в лог
    // ... и всё это в одном тесте!
}

Как должно быть правильно

Принцип «один тест — одна проверяемая связь». Если вы тестируете сохранение в БД — проверяйте только сохранение. Если тестируете работу с внешним API — проверяйте только обработку ответов. Каждый тест должен фокусироваться на одной интеграционной точке.

Правильно:

csharp

[Test]
public void CreateOrder_ShouldSaveOrderToDatabase() 
{ /* только БД */ }

[Test]
public void CreateOrder_ShouldCallPaymentApi() { /* только API */ }

[Test]
public void CreateOrder_ShouldSendEmailNotification() 
{ /* только email */ }

Антипаттерн №2. Тесты, зависимые от порядка выполнения

Суть проблемы

Тесты написаны так, что они должны запускаться строго в определенной последовательности. Например, Test1 создает пользователя, а Test2 проверяет этого пользователя. Если запустить тесты в другом порядке или отдельно — они падают.

Почему это плохо:

  • Тесты становятся хрупкими и нестабильными.
  • Нельзя запустить один тест отдельно — он упадет, потому что не будет данных от другого теста.
  • При параллельном запуске (на сервере сборки) тесты гарантированно упадут.
  • Сложно понять, какой тест на самом деле сломался.

Пример плохого теста (антипаттерн):

csharp

// Тест №1 (должен запускаться первым)
[Test]
public void Step1_CreateUser()
{
    var user = new User { Name = "Иван" };
    repository.Add(user);
    // Тест проходит, пользователь создан
}
// Тест №2 (должен запускаться после первого)
[Test]
public void Step2_CheckUser()
{
    var user = repository.FindByName("Иван");
    Assert.NotNull(user); // Ожидаем, что пользователь уже есть
}
// Если запустить только Test2, он упадет!

Как должно быть правильно

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

Правильно:

csharp

[Test]
public void CheckUser_ShouldFindUser_WhenUserExists()
{
    // Arrange: сам создаем пользователя для этого теста
    var user = new User { Name = "Иван" };

    repository.Add(user);
    
    // Act
    var found = repository.FindByName("Иван");
    
    // Assert
    Assert.NotNull(found);
}
// Этот тест самодостаточен. Его можно запускать отдельно.

Антипаттерн №3. Тест смотрит не туда (Mock-нуто всё подряд)

Суть проблемы

Новичок пишет интеграционный тест, но использует моки для всего подряд. Особенно часто мокают базу данных (In-Memory Database) или весь внешний сервис целиком.

Почему это плохо:

  • Такой тест перестает быть интеграционным. Это просто юнит-тест в красивой обертке.
  • Он не проверяет реальное взаимодействие с БД (SQL-запросы, транзакции, ограничения).
  • Он не ловит ошибки, связанные с внешним API (неправильные форматы, таймауты).

Вспоминаем правило из §1.1

Если в тесте есть Mock для базы данных или внешнего API — это не интеграционный тест. Это юнит-тест с другим названием.

Пример плохого интеграционного теста (антипаттерн) представлен ниже:

csharp

[Test]
public void CreateOrder_ShouldWork()
{
    // Mock-нули репозиторий БД
    var mockRepo = new Mock<IOrderRepository>();
    mockRepo.Setup(r => r.Save(It.IsAny<Order>())).Returns(1);
    
    // Mock-нули внешний сервис
    var mockPayment = new Mock<IPaymentService>();
    mockPayment.Setup(p => p.Pay(It.IsAny<decimal>())).Returns(true);
    
    // Создаем сервис с моками
    var service = new OrderService(mockRepo.Object, 
                                   mockPayment.Object);
    
    // Act
    var result = service.CreateOrder(new Order());
    
    // Assert
    // Тест прошел, но он ничего не проверяет по-настоящему
    Assert.True(result); 
}

Как должно быть правильно

В интеграционном тесте мы используем реальные репозиторий (с реальной БД) и реальный клиент для внешнего API (который ходит в заглушку, но не в мок). Мы проверяем интеграцию, а не код внутри методов.

Правильно:

csharp

[Test]
public void CreateOrder_ShouldSaveToRealDatabase()
{
    // Используем реальную БД (через контекст)
    // Используем реальный HttpClient, 
    // который стучится в WireMock заглушку
    // Проверяем, что данные физически сохранились в БД
}

Антипаттерн №4. Игнорирование таймаутов (Проверяем только успех)

Суть проблемы

Новичок настраивает заглушку внешнего API только на успешные ответы (200 OK) и не проверяет сценарии с таймаутами или ошибками.

Почему это плохо:

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

Пример плохого теста (антипаттерн):

csharp

[Test]
public void CreateOrder_WhenPaymentWorks_ShouldSucceed()
{
    // Настроили только успешный ответ
    mockHttp.When(HttpMethod.Post, "/payment")
           .Respond(HttpStatusCode.OK, "{ \"status\": \"success\" }");
    
    var result = orderService.CreateOrder();
    Assert.True(result.Success); // Тест прошел
}
// А что будет, если платежный шлюз не отвечает 30 секунд?
// Мы не проверили. В бою приложение зависнет.

Как должно быть правильно

Всегда проверяйте сценарии с ошибками и таймаутами. Это не менее важно, чем успешные сценарии.

Правильно:

csharp

[Test]
public void CreateOrder_WhenPaymentTimeout_ShouldHandleGracefully()
{
    // Настраиваем таймаут (заглушка отвечает очень медленно)
    mockHttp.When(HttpMethod.Post, "/payment")
            .Respond(c => Task.Delay(5000)); // Ждем 5 секунд
    
    // Устанавливаем таймаут в HttpClient (например, 2 секунды)
    // Проверяем, что наш код не висит, 
    // а выбрасывает понятное исключение
    var exception = Assert.ThrowsAsync<TimeoutException>(
        async () => await orderService.CreateOrder()
    );
}

Антипаттерн №5. Тестирование через UI (или через браузер)

Суть проблемы

Вместо того чтобы напрямую вызывать методы репозитория или сервиса, новичок пытается проверить работу базы данных через пользовательский интерфейс (UI) или через контроллеры API.

Почему это плохо:

  • Это уже не интеграционное, а сквозное (E2E) тестирование.
  • Такие тесты медленные, хрупкие и сложные в настройке.
  • Если тест упал, непонятно, что именно сломалось: UI, контроллер, сервис, БД или сеть.

Пример плохого подхода (антипаттерн):

csharp

[Test]
public void CreateUser_ShouldWork()
{
    // Открываем браузер
    // Вводим данные в форму
    // Нажимаем кнопку "Сохранить"
    // Проверяем, что в таблице появилась запись
    // (Это уже E2E, а не интеграционный тест!)
}

Как должно быть правильно

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

Правильно:

csharp

[Test]
public void CreateUser_ShouldSaveToDatabase()
{
    // Прямой вызов метода репозитория
    var user = new User { Name = "Иван" };
    userRepository.Add(user);
    
    // Прямая проверка в БД
    var saved = dbContext.Users.FirstOrDefault(u => u.Name == "Иван");
    Assert.NotNull(saved);
}

Антипаттерн №6. Использование «боевых» конфигураций (Production БД)

Суть проблемы

Новичок подключает интеграционные тесты к реальной боевой базе данных (или даже к общей тестовой БД, где работают все разработчики).

Почему это «смертельно» опасно:

  • Тесты могут удалить или изменить боевые данные.
  • Это нарушает безопасность.
  • Боевая БД может быть недоступна для разработчика (политики безопасности).
  • Другие разработчики тоже используют эту БД, и тесты будут мешать друг другу.

Пример плохого подхода (антипаттерн):

csharp

// СТРОГО ЗАПРЕЩЕНО!
var connectionString = "Server=production-server;Database=RealDB;User Id=admin;Password=secret;";
var dbContext = new MyDbContext(connectionString);

Как должно быть правильно

Всегда используйте изолированное тестовое окружение:

  • Локальную БД на вашем компьютере.
  • Docker-контейнер (Testcontainers).
  • Отдельную тестовую БД, которая создается заново перед каждым прогоном тестов.

Антипаттерн №7. Дублирование кода инициализации

Суть проблемы

В каждом тесте повторяется один и тот же код для настройки БД, запуска контейнера, создания контекста.

Почему это плохо:

  • Много дублирования → сложно поддерживать.
  • Если нужно изменить настройку, придется править во всех тестах.
  • Тесты становятся громоздкими.

Пример плохого подхода (антипаттерн):

csharp

[Test]
public void Test1()
{
    var container = new PostgreSqlBuilder().Build();
    container.Start();
    // ... тест ...
    container.Stop();
}

[Test]
public void Test2()
{
    var container = new PostgreSqlBuilder().Build();
    container.Start();
    // ... тест ...
    container.Stop();
}
// Огромное дублирование!

Как должно быть правильно

Используйте общий фикстуру (Fixture) для настройки окружения один раз для всех тестов в классе. В xUnit это делается через ClassFixture или CollectionFixture.

Правильно:

csharp

public class DatabaseFixture : IDisposable
{
    public MyDbContext Context { get; private set; }
    
    public DatabaseFixture()
    {
        var container = new PostgreSqlBuilder().Build();

        container.Start();

        Context = new MyDbContext(container.GetConnectionString());
    }
    
    public void Dispose()
    {
        Context.Dispose();

        // Останавливаем контейнер
    }
}

[Collection("Database tests")]
public class UserRepositoryTests : IDisposable
{
    private readonly DatabaseFixture _fixture;
    
    public UserRepositoryTests(DatabaseFixture fixture)
    {
        _fixture = fixture;
    }

    // Все тесты используют один экземпляр контейнера
}

Антипаттерн №8. Игнорирование отката транзакций

Суть проблемы

Тест вносит изменения в БД, но не откатывает их. Данные остаются в базе и влияют на следующие тесты.

Почему это плохо:

  • Тесты начинают влиять друг на друга.
  • Следующие тесты падают, потому что видят «лишние» данные, что является ошибкой.
  • База данных засоряется после каждого прогона тестов.

Пример плохого подхода (антипаттерн):

csharp

[Test]
public void CreateUser_ShouldWork()
{
    var user = new User { Name = "Иван" };
    repository.Add(user);
    // Нет отката! Данные остаются в БД.
}

[Test]
public void GetUsers_ShouldReturnEmpty_WhenNoUsers()
{
    var users = repository.GetAll();
    // Здесь уже есть "Иван"! Тест падает.
    Assert.AreEqual(0, users.Count); 
}

Как должно быть правильно

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

Правильно (с транзакцией):

csharp

[Test]
public void CreateUser_ShouldWork()
{
    using (var transaction = dbContext.Database.BeginTransaction())
    {
        var user = new User { Name = "Иван" };
        repository.Add(user);
        
        // Проверяем...
        var saved = repository.FindByName("Иван");
        Assert.NotNull(saved);
        
        // Откатываем транзакцию
        transaction.Rollback();
    }
}

Итоговое резюме по антипаттернам выглядит следующим образом:

АнтипаттернГлавная проблемаЧто делать
Тест-слоупокПроверяет слишком многоОдин тест — одна связь
Жесткий порядокТесты зависят друг от другаКаждый тест самостоятельный
Всё замоканоТест не проверяет интеграциюРаботаем с реальной БД
Игнорирование таймаутовНе проверяем ошибкиТестируем все сценарии (200, 400, 500, timeout)
Тестирование через UIЭто E2E, а не интеграцияТестируем слой данных напрямую
Production БДОпасно и нестабильноИспользуем изолированное окружение
Дублирование кодаСложно поддерживатьИспользуем общие фикстуры
Нет отката транзакцийТесты влияют друг на другаВсегда откатываем изменения