Тестирование в C#
Тест — не ритуал и не строка в отчёте, а исполняемая спецификация: единственный код, который утверждает, что остальной код делает то, что вы думаете. В .NET эта спецификация опирается на вполне конкретную машинерию — раннер находит тесты по атрибутам и решает, переиспользовать ли экземпляр класса; контейнер DI позволяет подменить зависимость на дубль; провайдер EF Core решает, дойдёт ли ваш LINQ до настоящего SQL. Пока вы не понимаете эту машинерию, вы пишете тесты, которые зелены и при этом ничего не гарантируют.
Именно поэтому тема так легко вырождается, и именно на вырождении ловят на собеседовании. Перемоканный набор проверяет взаимодействия — какие вызовы были и в каком порядке, — то есть закрепляет реализацию: он переживает неверную логику и ломается от безобидного рефакторинга. In-memory провайдер EF Core — это LINQ по объектам в памяти: он молча пропускает запрос, который настоящий PostgreSQL отвергнет. 100% покрытия означает лишь, что строки исполнились: удалите из набора все Assert — цифра не изменится. А DateTime.Now, прочитанный прямо из кода, делает тест заложником календаря. Каждый слой ниже разбирает один механизм и называет ловушку, на которой он ломается.
Карта темы
- Уровни тестов — модульный, интеграционный и end-to-end различаются охватом, стоимостью и тем, что именно они доказывают.
- Тест-фреймворки —
xUnit,NUnit,MSTestи то, почемуxUnitпересоздаёт класс теста на каждый тест. - Arrange-Act-Assert — три блока тела теста и правило одного Act, из-за которого падение указывает ровно на одно поведение.
- Параметризованные тесты —
[Theory]с[InlineData]/[MemberData]: один сценарий на набор данных, каждый набор — отдельный отчётный тест. - Подготовка тестовых данных —
Test Data BuilderиObject Motherдержат Arrange коротким, когда валидному объекту нужно много полей. - Моки и fake — мокайте роли и чужие границы, а не типы; перемоканный набор проверяет вызовы, а не поведение.
- Тестируемое время —
TimeProviderиFakeTimeProviderвместо глобальной заморозки часов, которая протекает между параллельными тестами. - Интеграционные тесты —
WebApplicationFactory<T>поднимает настоящий конвейерProgramin-memory; настоящую базу даёт SQLite илиTestcontainers. - Snapshot-тестирование — сериализовать результат и сравнить с утверждённым файлом; уместно для крупного стабильного вывода.
- Покрытие кода — line против branch, почему 100% ничего не доказывает и что вместо этого измеряет mutation testing.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Называть модульным тест, который ходит в настоящую базу | Стоимость и хрупкость интеграционного теста при обещаниях модульного — набор медленный и флаки |
Искать [SetUp]/[TearDown] в xUnit | Их там нет: настройку играет конструктор, очистку — IDisposable, и класс создаётся заново на каждый тест |
| Опираться на порядок выполнения тестов или на состояние, оставленное предыдущим | Раннер не даёт гарантий порядка и гоняет классы параллельно — тест зелёный в одиночку и красный в наборе |
| Втискивать несколько Act в один тест | Падение перестаёт называть одно поведение — приходится читать тест, чтобы понять, что сломалось |
Ставить параметры метода на [Fact] | xUnit отвергает такой тест ещё на обнаружении — нужен [Theory] |
| Мокать всё подряд, включая объекты-значения и внутренние зависимости | Тест проверяет вызовы, а не результат: переживает неверную логику и падает от рефакторинга |
Верить, что Moq перехватит DateTime.Now | Статическое свойство не перехватывается — время надо внедрять как зависимость (TimeProvider) |
| Подменять время изменяемой статикой (глобальная заморозка часов) | Состояние протекает между параллельными тестами и убивает параллельный прогон |
| Доверять in-memory провайдеру EF Core как «базе» | Ни уникальных ограничений, ни каскадных удалений, ни трансляции в SQL — он пропускает запросы, которые настоящая база отвергнет |
Мокать DbContext и DbSet<T> | Проверяется LINQ по списку, а не то, во что EF Core транслирует ваш запрос |
| Переутверждать упавший snapshot, не читая diff | Регрессия тихо утверждается как новая норма |
| Снимать snapshot с вывода, где зашиты метка времени или GUID | Тест флаки по построению — расхождение при каждом прогоне |
| Читать покрытую строку как проверенную | Покрытие фиксирует исполнение, а не проверку: набор вообще без Assert показывает те же 100% |
| Ставить порог покрытия как планку качества | Перемоканные тесты берут порог, ничего не доказывая; выжившие мутанты остаются незамеченными |
| Жать Retry на флаки-тесте | Настоящая гонка или зависимость от порядка прячется за зелёным прогоном и однажды прорвётся в бой |
Значение для собеседований
Тесты спрашивают почти на любом C#-собеседовании среднего уровня, но проверяют не синтаксис атрибутов. Проверяют, отличаете ли вы тест, который что-то доказывает, от теста, который просто исполняется. Кандидат, который на вопрос про покрытие отвечает «100% значит, что строки исполнились, а не что кто-то их проверил», закрывает тему одной фразой.
Что обычно проверяют:
- Границу между модульным, интеграционным и end-to-end тестом — и что именно каждый доказывает.
- Модель
xUnit: свежий экземпляр класса на тест, конструктор как настройка,IDisposableкак очистка. [Fact]против[Theory]и то, что каждый[InlineData]отчитывается отдельным тестом.- Что стоит мокать, а что нет: чужие границы — мок, внутрипроцессное состояние — fake.
- Как тестировать код, читающий
DateTime.Now, не замораживая часы глобально. - Что даёт
WebApplicationFactory<T>и почему in-memory провайдер EF Core — не база. - Чем branch-покрытие отличается от line-покрытия и что измеряет mutation testing.
Типичный неверный ответ: «У нас 100% покрытие, значит код протестирован». Это открывает разговор о том, что покрытие — метрика исполнения, а не проверки; что набор без единого Assert даёт те же 100%; и что вопрос «поймает ли тест дефект» измеряется внесением дефектов (mutation testing), а не процентом строк.