Два способа сделать надежные юнит-тесты
Есть мнение, что юнит-тесты не нужны. Что в них скрыта только половина правды. И что подлинная информация о поведении программы раскроется только тогда, когда мы соберем их в интеграционный тест.
В этом есть резон, но так ли уж неполны юнит-тесты и можно ли сделать их надежнее? Сколько вообще причин их неполноты?
Предположим, у нас есть два покрытых юнит-тестами компонента, Caller и Callee. Caller вызывает Callee с аргументом и как-то использует возвращаемый объект. У каждого из компонентов есть свой набор зависимостей, которые мы мокаем.
Сколько сценариев, при которых эти компоненты поведут себя неожиданно при интеграции?
Первый сценарий — это внешняя по отношению к обоим компонентам проблема. Например, они оба зависят от состояния базы данных, авторизации, переменных среды, глобальных переменных, куки, файлов и тп. Судить об этом достаточно просто, поскольку даже в очень больших программах обычно ограниченное число таких contention points.
Разрешать проблему можно, очевидно, либо через редизайн с уменьшением зависимостей,
либо прямо моделируем возможную ошибку в сценарии верхнего уровня, то есть вводим компонет CallingStrategy (OffendingCaller, OffendedCallee) {}, и имитируем падение Callee и обработку ошибки в CallingStrategy. Для этого интеграционные тесты не требуются, но требуется понимание, что определенное поведение одного из компонентов представляет риск для другого компонента, и этот сценарий хорошо бы выделить в компонент.
Второй сценарий: проблема в интерфейсе интегрируемых объектов, т.е. ненужное состояние одного из объектов просочилось в другой.
Фактически, это недостаток интерфейса, который это допускает. Решение проблемы тоже довольно очевидно — типизация и сужение интерфейсов, ранняя валидация параметров.
Как мы видим, обе причины чрезвычайно банальны, но хорошо бы внятно сформулировать, что никаких других — нет.
Таким образом, если мы проверили наши классы на предмет 1) внутреннего состояния и 2) внешних зависимостей, то причин сомневаться в надежности юнит-тестов у нас нет.
(Где-то в углу тихо плачет функциональный программист со словами «i told you so», но щас не об этом).
Но ведь мы можем просто забыть или пропустить какую-то зависимость!
Можно оценить грубо. Предположим, в каждом компоненте десять сценариев. Мы пропускаем один сценарий из десяти. Например, Callee внезапно возвращает null, а Caller внезапно получает NullPointerException. Нам нужно ошибиться дважды, значит вероятность падения где-нибудь 1/100. Трудно представить, что интеграционный сценарий для двух элементов это отловит. Для множества последовательно вызванных компонентов внутри интеграционного теста вероятность отлова какой-то из ошибок растет, из чего следует, что чем длиннее стек интеграционного теста, и чем больше сценариев, тем он более оправдан.
(Реальная математика накопления ошибок, разумеется, сильно сложнее, но результат не очень варьирует).
В процессе выполнения интеграционного теста, правда, можно ожидать значительного роста шума от сломанных зависимостей и существенных временных затрат на поиск бага, они тоже пропорциональны длине стека.
То есть получается, что интеграционные тесты нужны в том случае, если юнит-тесты плохи или отсутствуют. Например, когда в каждом из юнит тестов проверяется только валидный сценарий, когда используют слишком широкие интерфейсы и не проводят анализ на общие зависимости.