Два способа сделать надежные юнит-тесты

?v=1

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

В этом есть резон, но так ли уж неполны юнит-тесты и можно ли сделать их надежнее? Сколько вообще причин их неполноты?

Предположим, у нас есть два покрытых юнит-тестами компонента, Caller и Callee. Caller вызывает Callee с аргументом и как-то использует возвращаемый объект. У каждого из компонентов есть свой набор зависимостей, которые мы мокаем.

Сколько сценариев, при которых эти компоненты поведут себя неожиданно при интеграции?

Первый сценарий — это внешняя по отношению к обоим компонентам проблема. Например, они оба зависят от состояния базы данных, авторизации, переменных среды, глобальных переменных, куки, файлов и тп. Судить об этом достаточно просто, поскольку даже в очень больших программах обычно ограниченное число таких contention points.

Разрешать проблему можно, очевидно, либо через редизайн с уменьшением зависимостей,
либо прямо моделируем возможную ошибку в сценарии верхнего уровня, то есть вводим компонет CallingStrategy (OffendingCaller, OffendedCallee) {}, и имитируем падение Callee и обработку ошибки в CallingStrategy. Для этого интеграционные тесты не требуются, но требуется понимание, что определенное поведение одного из компонентов представляет риск для другого компонента, и этот сценарий хорошо бы выделить в компонент.

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

Фактически, это недостаток интерфейса, который это допускает. Решение проблемы тоже довольно очевидно — типизация и сужение интерфейсов, ранняя валидация параметров.

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

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

(Где-то в углу тихо плачет функциональный программист со словами «i told you so», но щас не об этом).

Но ведь мы можем просто забыть или пропустить какую-то зависимость!

Можно оценить грубо. Предположим, в каждом компоненте десять сценариев. Мы пропускаем один сценарий из десяти. Например, Callee внезапно возвращает null, а Caller внезапно получает NullPointerException. Нам нужно ошибиться дважды, значит вероятность падения где-нибудь 1/100. Трудно представить, что интеграционный сценарий для двух элементов это отловит. Для множества последовательно вызванных компонентов внутри интеграционного теста вероятность отлова какой-то из ошибок растет, из чего следует, что чем длиннее стек интеграционного теста, и чем больше сценариев, тем он более оправдан.

(Реальная математика накопления ошибок, разумеется, сильно сложнее, но результат не очень варьирует).

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

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

© Habrahabr.ru