Где провести границу между юнит-тестами и интеграционными тестами

· · Тестирование, Юнит-тесты, Интеграционные тесты, Проектирование тестов, Разработка Windows, C# / .NET

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

Опасны здесь две крайности.

  • Хочется быстрого цикла — и всё превращается в юнит-тесты
  • Хочется близости к реальности — и всё превращается в интеграционные тесты

Первая крайность обрастает mock-ами и легко упускает именно те точки, что ломаются в проде; вторая обычно превращается в медленный и хрупкий набор тестов. На практике оси, на которые стоит смотреть, гораздо понятнее.

  • Вы хотите проверить свою логику или связь с внешним миром?
  • Если подставить in-memory fake, сохранится ли смысл проверки?
  • Реальное поведение БД / файлов / HTTP / DI / конфигурации / фреймворка / ОС — это и есть предмет проверки?
  • Хотите ли вы быстро прогонять большое число входных вариантов?

Как только эти четыре пункта видны, границу между юнит-тестами и интеграционными тестами провести становится намного проще.

В этой статье мы разбираем границу между юнит-тестами и интеграционными тестами с практическим уклоном, опираясь на открытые материалы Microsoft Learn и Мартина Фаулера, доступные по состоянию на март 2026 года.123

1. Сначала вывод

Если сформулировать довольно грубо, но удобно для практики, получится так.

  1. Чистая логика — в юнит-тесты
  2. Соединения, связи, преобразования и различия среды — в интеграционные тесты
  3. Если проверить можно на любом уровне — начинайте с юнит-теста
  4. Интеграционные тесты лучше не делать широкими и тяжёлыми, а сужать до границы

Одной фразой: юнит-тесты — это «тесты решений», интеграционные тесты — это «тесты связей».

Вещи, чей смысл самодостаточен без внешних ресурсов, — расчёт суммы, переходы состояний, валидация ввода, условия одобрения, классификация исключений, — быстрее, устойчивее и позволяют гораздо плотнее прогонять варианты входных данных, если их сдвинуть в сторону юнит-тестов. С другой стороны, вещи, которые «подводят именно в момент соединения», — выполнение SQL, сериализация JSON / CSV, маршрутизация, привязка модели, регистрация в DI, файловые блокировки, права доступа, регистрация COM, разрядность 32/64 бит, STA / MTA, — безопаснее размещать на стороне интеграционных тестов.

В материале Microsoft Learn Integration tests in ASP.NET Core тоже рекомендуется сужать интеграционные тесты до важных инфраструктурных сценариев и выбирать юнит-тесты там, где их достаточно.

2. Что в этой статье называется юнит-тестом и интеграционным тестом

Здесь термины используются так.

Уровень Что проверяет Типичная конфигурация
Юнит-тест Корректность одной изолированной ответственности Использует fake / mock / stub, отсекает внешние ресурсы
Интеграционный тест Связи между несколькими компонентами и поведение, включающее инфраструктуру и фреймворки Реальная БД, реальные файлы, реальный сериализатор, реальный хост, реальный конвейер и т. п.
E2E / функциональный тест Пользовательский поток через всё приложение Развёрнутое приложение, несколько сервисов, реальный браузер или реальный процесс

В материалах .NET по юнит-тестированию хороший юнит-тест описывается как fast / isolated / repeatable — не зависящий от внешних факторов вроде файловой системы или БД. Подробнее об этом понятно рассказано в Unit testing best practices for .NET.

Кроме того, интеграционный тест — это не обязательно только «тяжёлый тест, который непременно использует другой процесс или другой сервер». Даже в пределах одного процесса, если вы соединяете несколько реальных компонентов и проверяете подлинное поведение фреймворка или инфраструктуры, это уже тяготеет к интеграционному тесту.

Например, при юнит-тестировании controller action в ASP.NET Core официальная рекомендация — сузить предмет проверки до решений внутри самого action, а взаимодействие с фреймворком вроде routing, model binding, filters обрабатывать интеграционными тестами. Подробный разбор есть в Unit test controller logic in ASP.NET Core.

3. Таблица решений на одном листе

Сначала — таблица, наиболее удобная на практике.

Что хотите проверить Основной тест Примечание
Расчёт суммы, скидки, переходы состояний, валидация ввода Юнит-тест Хочется плотно прогонять варианты ввода
Классификация исключений, выбор текста ошибки, решение о повторе Юнит-тест Смысл самодостаточен без реального I/O
SQL / ORM-преобразование в Repository, транзакции Интеграционный тест Предмет — поведение реальной БД или реального провайдера
Сериализация / десериализация JSON / XML / CSV Интеграционный тест Расхождения в wire-формате трудно найти через fake
Маршрутизация, привязка модели, фильтры, middleware Интеграционный тест Проверка соединения с фреймворком
Переходы состояний ViewModel или Presenter в WPF / WinForms Юнит-тест Осмысленно даже без поднятия UI
Реальный Binding, Dispatcher, жизненный цикл control, message loop Интеграционный тест или UI-тест Предмет — поведение фреймворка и потоков
Пути к файлам, права доступа, блокировки, общие папки, символы конца строки, кодировки Интеграционный тест Нужно реальное поведение ОС и файловой системы
Регистрация COM, разрядность 32/64 бит, STA / MTA, источник загрузки DLL Интеграционный тест Предмет — различия среды и границы процессов
Запуск приложения целиком, сквозная проверка основных сценариев использования E2E / smoke Достаточно небольшого числа тестов

Секрет чтения этой таблицы — спрашивать, какой тест ближе всего к «причине сбоя в проде». Решать, ориентируясь на неопределённость, которую хочется снизить, а не на расположение кода в проекте, — надёжнее.

4. Что должно быть в юнит-тестах

Юнит-тестам подходит ответственность, чей смысл сохраняется даже после удаления внешнего мира.

Например, это:

  • бизнес-правила;
  • ветвления;
  • переходы состояний;
  • валидация ввода;
  • классификация ошибок;
  • решение о политике повторов;
  • изменения состояния ViewModel / Presenter;
  • сама логика преобразования.

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

Например,

  • купон есть / нет;
  • товар в наличии / нет;
  • первый заказ / повторный заказ;
  • администратор / обычный пользователь;
  • корректное значение / граничное значение / некорректное значение —

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

Кроме того, в юнит-тестах важно держать внешние факторы под контролем.

  • Внедрять текущее время
  • Делать GUID и случайные числа заменяемыми
  • Не ждать через sleep
  • Не трогать реальную БД или реальные файлы
  • Не выходить в реальную сеть

Если это соблюдается, тесты становятся заметно стабильнее.

4.1. Когда в юнит-тесте становится слишком много mock-ов

Если при попытке написать юнит-тест оказывается, что

  • нужно 7 mock-ов;
  • setup получается длинным;
  • arrange длиннее самого тела теста;
  • непонятно, что вообще хотелось проверить, —

обычно верно одно из двух:

  1. у этого класса слишком много ответственностей;
  2. в юнит-тест затолкали связи, которые на самом деле нужно проверять интеграционным тестом.

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

5. Четыре границы, которые стоит поднять до интеграционных тестов

Места, которые стоит поднимать до интеграционных тестов, в целом делятся на четыре: форматы, связи, среда и время.

5.1. Граница форматов

Сюда относятся такие вещи:

  • JSON / XML / CSV
  • схема БД и mapping
  • nullable / точность / часовой пояс
  • сериализация enum и дат
  • кодировки символов и BOM
  • символы конца строки

Мартин Фаулер тоже относит границы, включающие сериализацию / десериализацию, к кандидатам на интеграционный тест. Подробнее об этом можно прочитать в The Practical Test Pyramid.

Например, такие дефекты, как

  • у DTO после сериализации в JSON оказались другие имена полей,
  • сломались кавычки или переносы строк в CSV,
  • decimal округлился,
  • обращение с DateTimeOffset в БД поехало,
  • null и пустая строка обработались не так, как ожидалось, —

легко проскакивают мимо одних только юнит-тестов.

5.2. Граница связей

К границе связей относятся, например, такие части:

  • регистрация в DI
  • привязка конфигурации
  • маршрутизация
  • привязка модели
  • фильтры
  • middleware
  • запуск хоста
  • связывание событий
  • Binding и привязка команд в WPF

Здесь предмет — не «правильна ли моя функция», а правильно ли соединены между собой несколько реальных компонентов.

В ASP.NET Core официальная рекомендация — сужать юнит-тесты controller action до решений самого action, а routing, model binding и filters смотреть на стороне интеграционных тестов. Вне веба логика та же: в десктопном приложении переходы состояний ViewModel относятся к юнит-тестам, а поведение с реальным XAML Binding или Dispatcher — уже тяготеет к интеграционным тестам.

5.3. Граница среды

В разработке под Windows это имеет очень большое значение.

  • права доступа к файлам
  • общие папки
  • блокировки файлов
  • переименование из временного файла
  • права администратора
  • права на запуск службы
  • регистрация COM
  • разрядность 32/64 бит
  • STA / MTA
  • источник загрузки DLL

Здесь главные действующие лица — сами условия ОС и среды выполнения. В in-memory fake смысл этого сильно теряется, поэтому безопаснее покрывать такие случаи интеграционными тестами.

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

5.4. Граница времени

Ещё одна вещь, которую легко упустить, — время и параллелизм.

  • таймауты
  • отмена операций
  • реальное поведение повторов
  • обработка, управляемая таймером
  • остановка фоновой обработки
  • гонки (race condition)
  • порядок завершения при shutdown

Здесь важно разделять решение и реальное поведение.

Например,

  • сколько раз повторять попытку;
  • какие исключения считать поводом для повтора —

вполне достаточно проверить юнит-тестами. А вот

  • действительно ли срабатывает таймаут;
  • распространяется ли отмена;
  • не ломается ли всё при столкновении таймера с асинхронной обработкой;
  • аккуратно ли закрываются хендлы и задачи при завершении —

тяготеет к интеграционным тестам.

6. Частые ошибки в решениях

6.1. Замокать Repository и успокоиться на этом

Даже если всё вокруг Repository проходит через mock, вы всё равно не знаете,

  • корректен ли SQL;
  • срабатывают ли транзакции;
  • соответствует ли это схеме;
  • не расходится ли mapping;
  • не ломаются ли кодировки и точность.

Repository чаще является не столько объектом тестирования логики, сколько точкой соединения на границе. В этом случае реальности лучше соответствует смещение веса в сторону интеграционных, а не юнит-тестов.

6.2. Попытка увидеть фреймворк в юнит-тесте контроллера / endpoint

В юнит-тесте controller action хочется увидеть примерно вот что:

  • условные ветвления;
  • выбор возвращаемого значения;
  • какой из зависимых сервисов вызывается.

А вот

  • срабатывает ли маршрут;
  • проходит ли привязка модели;
  • срабатывает ли фильтр;
  • как выглядит результат после прохождения middleware —

это уже сторона интеграционных тестов. Если это смешать, становится трудно понять, что именно сломалось.

6.3. Перебор всех входных вариантов в интеграционных тестах

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

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

6.4. Обращение из CI напрямую к продакшн-версии внешних сервисов

Этого лучше избегать.

В интеграционных тестах важна «реалистичность», но это не значит, что нужно каждый раз обращаться к продакшн SaaS или продакшн API. Фаулер тоже рекомендует поднимать внешние сервисы локально, размещать fake или использовать выделенный тестовый экземпляр.

На практике удобно сочетание:

  • локальной БД;
  • временных директорий;
  • тестового хоста;
  • выделенной тестовой среды;
  • fake-сервиса с зафиксированным контрактом.

7. Рекомендуемая структура на практике

Абсолютно правильного соотношения не существует. Но достаточно универсально применима такая структура из трёх слоёв.

Слой Основной инструмент Что туда помещать
Слой ядра Плотные юнит-тесты Бизнес-правила, переходы состояний, валидация ввода, классификация ошибок
Слой границ Узкие интеграционные тесты БД, файлы, HTTP, сериализатор, DI, конфигурация, COM, права доступа
Слой целого Небольшое число smoke / E2E тестов Проверка запуска, основные потоки, предотвращение повторения серьёзных инцидентов

Интуитивно: юнит-тесты набирают плотность количеством, интеграционные тесты — плотностью покрытия границ.

Рекомендуемый порядок действий такой.

  1. Сначала перечислить границы приложения
  2. Привести логику к виду, отделимому от внешнего мира
  3. На каждой границе разместить «минимум один happy path» и «представительный сценарий сбоя»
  4. Свести число сквозных прогонов к минимуму
  5. При появлении бага добавить тест на том слое, где этот баг воспроизводится с минимальными затратами

Последний, пятый пункт — самый важный.

  • Если это ошибка правила — добавьте юнит-тест
  • Если это ошибка SQL / привязки / конфигурации / прав доступа / регистрации — добавьте интеграционный тест
  • Если это сбой, включающий запуск или распространение приложения, — добавьте smoke- или E2E-тест

При таком способе роста ответственность тестов не расплывается.

8. Пять вопросов на случай сомнений

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

  1. Если подставить in-memory fake, сохранится ли смысл, который вы хотели проверить?
    • Если сохраняется — это тяготеет к юнит-тесту.
  2. Если что-то сломается, заподозрите ли вы скорее соединение или настройку, а не логику?
    • Если да — это тяготеет к интеграционному тесту.
  3. Не является ли предметом проверки БД / файлы / сериализатор / DI / маршрут / привязка модели / ОС / права доступа / разрядность / потоки?
    • Если является — это тяготеет к интеграционному тесту.
  4. Хотите ли вы быстро прогонять большое число входных вариантов?
    • Если да — это тяготеет к юнит-тесту.
  5. Когда этот тест падает, сразу ли понятно, что нужно исправить?
    • Если непонятно — слои тестов перемешаны.

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

9. Итог

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

Суть сводится к этим пяти пунктам.

  • Юнит-тесты — это тесты решений
  • Интеграционные тесты — это тесты связей
  • Перебор ветвлений — в юнит-тесты
  • Форматы, связи, среда и время — в интеграционные тесты
  • Сквозную проверку целого покрывайте небольшим числом smoke / E2E тестов

Больше всего стоит избегать трёх вещей:

  • считать, что mock доказал корректность соединения с реальным компонентом;
  • пытаться прогнать все ветвления через интеграционные тесты;
  • смешивать ответственность юнит-тестов и интеграционных тестов.

Если сомневаетесь, сначала спросите себя: этот дефект ломает «решение» или ломает «связь»? Один этот вопрос разрешает значительную часть случаев.

10. Похожие статьи

11. Справочные материалы

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

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

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

В чём разница между юнит-тестом и интеграционным тестом?
Юнит-тест проверяет корректность одной изолированной ответственности, используя fake, mock или stub, чтобы отсечь внешние ресурсы вроде баз данных и файлов. Интеграционный тест проверяет связи между несколькими компонентами и поведение, включающее реальную инфраструктуру или фреймворки, — реальную БД, реальные файлы, реальный конвейер хоста. Одной фразой: юнит-тесты — это тесты решений, интеграционные тесты — тесты связей.
Как решить, куда отнести проверку — к юнит-тесту или к интеграционному тесту?
Спросите себя, сохраняется ли смысл того, что вы хотите проверить, если подставить in-memory fake. Чистая логика вроде расчёта цены, переходов состояний, валидации ввода и классификации ошибок самодостаточна без внешних ресурсов и относится к юнит-тестам. Вещи, которые подводят именно в момент соединения — выполнение SQL, сериализация JSON или CSV, маршрутизация, регистрация в DI, файловые блокировки, права доступа, регистрация COM, — безопаснее проверять интеграционными тестами. Если проверить можно на любом уровне, начинайте с юнит-теста.
Что означает, если юнит-тесту требуется много mock-объектов?
Если юнит-тесту нужно семь mock-ов, длинная настройка, или раздел arrange длиннее самого теста, это обычно один из двух симптомов. Либо у класса слишком много ответственностей, либо вы затаскиваете в юнит-тест связи, которые на самом деле должен проверять интеграционный тест. Mock — это инструмент для отсечения внешнего мира, а не для доказательства того, что соединение с реальным компонентом работает правильно; спутав это, легко получить набор тестов, где всё зелёное, а прод падает.
Нужно ли интеграционным тестам покрывать все варианты входных данных?
Нет. Интеграционные тесты ближе к реальности и потому медленнее, поэтому перебор всех веток через них даёт медленный и хрупкий набор тестов. Практичное разделение — прогонять большое число входных вариантов быстро через юнит-тесты, а интеграционные тесты сужать до представительных сценариев на каждой границе, например один happy path и один характерный сценарий сбоя. Рекомендации Microsoft тоже советуют для базы данных и файловой системы не гонять все паттерны через интеграционные тесты, а сужать их до представительных сценариев чтения, записи, обновления и удаления.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог