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

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

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

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

Информация

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

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

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

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

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

Информация

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

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

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

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

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

csharp

```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

```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

```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

```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

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

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

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

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

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

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

Идея

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

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

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

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

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

Важно

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

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

csharp

```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

```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

```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

```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

```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:

| Характеристика | REST | gRPC |
| --- | --- | --- |
| **Формат данных** | Текстовый (JSON, XML) | Бинарный (Protocol Buffers) |
| **Скорость** | Медленнее (текст парсить дольше) | Быстрее (бинарные данные легче) |
| **Человекочитаемость** | Да (можно посмотреть JSON в браузере) | Нет (бинарные данные нечитаемы) |
| **Протокол** | HTTP 1.1 или HTTP 2 | HTTP 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

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

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

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

csharp

```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

```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

```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

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

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

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

Правильно:

csharp

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

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

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

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

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

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

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

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

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

csharp

```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

```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

```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

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

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

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

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

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

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

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

csharp

```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

```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

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

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

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

Правильно:

csharp

```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

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

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

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

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

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

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

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

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

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

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

csharp

```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

```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

```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

```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 БД** | Опасно и нестабильно | Используем изолированное окружение |
| **Дублирование кода** | Сложно поддерживать | Используем общие фикстуры |
| **Нет отката транзакций** | Тесты влияют друг на друга | Всегда откатываем изменения |
