Юнит-тесты и интеграционные

Пропустив через себя API-first подход с полноценным повсеместным внедрением OpenAPI в компании, стало сильно яснее, что у нас происходит с тестами.
Опытные программисты вообще любят тесты, потому что без них
совсем плохо и больно. Что такое автоматический тест? Это
симуляционный прогон в моделированных условиях.
По сценариям тестов прогоняют софт (это очень дешево), реальные
вещи (перед полетом, самолету крылья выкручивают наверх и
завязывают бантиком, чтобы убедиться в их прочности), а заодно и
цифровые двойники реальных
вещей.
Программисты довольно условно, но разделяют тесты на юнит-тесты и
интеграционные (тут пропущена бездна материала, так что упростим
пока до этого деления). Интеграционные проверяют что софтина в
комплексе, как черный ящик ведет себя так, как от неё это
ожидают.
Интеграционные тесты
Грубо говоря задача интеграционных тестов не допустить:

Но в сложном софте такие тесты начинают долго бегать. Наш набор тестов на ноутбуке давно уже не прогнать (займет больше рабочего дня точно), а у многих других это занимает и того больше. При этом интеграционные тесты реально мало проверяют. Только то, что где-то из точки А в точку Б есть хотя бы одна счастливая дорожка.
Весь современный софт внутри довольно недетерминирован в силу того, что он почти всегда работает с сетью. Раз сеть, то пакеты могут не прийти, может сработать таймаут, а значит прогон теста может провалиться просто потому, что хотя бы перегрузился компьютер на котором его гоняли.
В каждом шмотке кода, который участвовал в создании
результирующей ценности, есть много разных ветвлений, сценариев и
т.п. Если по пути выполнения какого-то интеграционного теста было 3
модуля с 10-ю вариантами поведения, то неплохо бы проверить тысячу
интеграционных тестов, чтобы убедиться, что все варианты приводят к
хорошему результату. Причем несложно догадаться, что на прогоне
1000 тестов, чудовищный объём вычислений будет бесцельно
дублироваться.
Итого, интеграционные тесты:
- позволяют проверить весь софт целиком
- очень дорогие в исполнении
- нестабильные, часто мигают
- их нужно очень много для полной проверки кода (опустим целую бездну математики о том, что такое полная проверка)
Вопрос: зачем же они вообще такие нужны и какие есть альтернативы? Альтернатива довольно простая: это модульные, юнит-тесты.
Юнит-тесты
Есть довольно простой способ начать писать тесты на модули: берем и проверяем, что функции из какого-то шмотка кода ведут себя так, как мы задумали. Такие тесты пишутся легко, непринужденно и так же бессмысленно. Доходит до проверок, что 2+2 всё ещё равняется 4. Т.е. вокруг модуля создается обвязка, которая проверяет, что он ведет себя согласно ожиданиям.
В чём подвох юнит-тестов? В том, что их быстро и часто удаляют.
Обычный интеграционный тест — это контракт, который может жить
очень и очень долго. У нас есть те, что живут по 10 лет, потому что
проверяют то, что не поменялось за эти 10 лет (какой-нибудь rtmp).
А вот юнит тест проверяет только функции модуля. Захотели сделать
рефакторинг — тесты в помойку. А ведь за каждым тестом кроется
какая-то проверка и какие-то оцифрованные знания, баг репорт или
чуйка предыдущего автора.
Получится ли сохранить ньюансы юнит-тестов при рефакторинге кода?
Практика показывает, что редко.
Но юнит-тесты могут быть в 1000 раз быстрее, и в примере выше будет прогон не 1000 тестов, а лишь 30: по 10 на каждый модуль из 3-х штук.
Итого, такие тесты часто удаляются, с ними теряются знания и опыт, которые осели в виде кода. Дорого и обидно. Да и зачастую бессмысленно, погуглите «two unit tests, zero integration». Без интеграционных тестов совсем грустно.
Изменение подхода
В чём я у нас смог поменять подход? В том, где же проводить границу модулей. При должном развитии продукта можно начать формулировать и фиксировать контракты различных подсистем.
Т.е. не нужно писать юнит-тесты на модуль rtmp_decoder (ну или
очень и очень частично в совсем таких специфичных местах). Нужно
обрисовать апи всей подсистемы rtmp, договориться о нём с самими
собой и принять, что он не будет меняться очень и очень долго. Т.е.
по сути речь идет об интеграционных тестах подсистемы.
Например, в эрланге есть апи вокруг TCP-сокетов: https://www.erlang.org/doc/man/gen_tcp.html
Послать данные, прочитать, принять подключение.
По аналогии с ним в OTP team сделали такое же апи для SSL. Очень удобно и уместно: https://www.erlang.org/doc/man/ssl.html
Соответственно мы делаем такое же апи для RTSP и RTMP протоколов и получаются такие же функции listen, accept, connect, send, recv, но только работающие не с потоком байт, а с потоком сообщений, имеющих четкую и внятно описанную структуру.
Зафиксированность контракта позволяет следующий по цепочке модуль тестировать не трогая предыдущий вообще. Ведь мы зафиксировали контракт, а значит в случае проблем, надо разбираться:
- нарушение слева
- нарушение справа
- неправильно сформулированный контракт
Получаются может не совсем юнит-тесты, но всё же это сильно ниже, чем общие интеграционные.
Самый важный здесь момент: надо научиться и быть уверенным в том, что сможешь спроектировать хорошое, долго живущее API, которое будет удобно и полезно длительное время. Сделать такое с первого раза как правило нельзя, нужен некоторый опыт и знание предметной области.
Мы сейчас встаем на дорогу переделывание тестов на такой манер. Это очень долго и сложно, но нужно.
Что дальше?
Планируем инструмент для описания и валидации таких внутренних контрактов в реальном времени и в тестах.
|
</> |