Сама идея написания тестов мне всегда казалась избыточной. Не то, чтобы я отрицал их пользу, однако большую часть времени они были сущей профанацией: проверялись какие-то случаи, которые с высокой степенью вероятности будут проверены на правильность ещё во время разработки, либо писались для откровенно фантастических случаев. Я уж не говорю о том, что те, кто эти тесты писал, нередко, также как и я не понимали зачем тестировать "это"? Оттого, тесты часто и получались лишь для галочки. Но однажды всё поменялось.
Немного про не особо полезные тесты
Наверное самые бесполезные тесты, которые мне попадались - это тесты пользовательского интерфейса на одном из проектов. И из-за особенностей разработки, окружения, скорости запуск - они выполнялись не всегда.
По большому счёту, это всё отличный повод для проверки работоспособности, вот только если тесты выполняются 9 из 10 раз (и всё, что нужно сделать, чтобы тест выполнился, запустить его ещё раз), автоматически нивелирует значительную долю пользы от них.
Переписывать 10-20% тестов, иерархически зависящих друг от друга никому не улыбалось.
И хотя несколько лет эти тесты несколько раз-таки позволили выявить нарушение поведения, трудозатраты на их написание и поддержание явно превышали ту пользу, которую они давали.
Впрочем, это были проблемы тестов именно для того проекта, однако проблема, нивелирующая пользу тестов, довольно широко известна.
Структурная модель прежде всего
Хотя я писал про пользовательский интерфейс, в случае игры мы сталкиваемся с определённой трудностью. Допустим, персонаж взаимодействует с окружением: как это проверить? В первую очередь приходит в голову тестировщик. Ещё могут быть какие-нибудь ИИ-фреймворки, позволяющие тестировать правильность работы. Но всё это сложный и избыточный путь. Тесты должны помогать разрабатывать лучший вариант приложения, а не превращаться в отдельный проект-в-проекте.
К счастью, способ провести тестирование существует. Оговорюсь сразу, тестировать таким образом можно не всё; хотя не всё и тестировать нужно.
Тестировщика, выявляющего аномалии поведения или пробующего нестандартные действия это всё равно не заменит.
Смысл в том, чтобы все алгоритмы и состояния сделать изначально тестируемыми и проверять их, исходя из предположения, что если составные части системы работают верно, то и вся система работает верно. Либо же использовать разные уровни тестов, проверяющие работу приложения как на уровне низкоуровнего компонента, так и на уровне высокоуровневых бизнес-правил (всё как любит Роберт Мартин).
Как это работает: допустим, у нас есть пользовательский интерфейс с окном на экране, которое открывается кликом на кнопку 1, имеет цвет 2 и закрывается кликом на кнопку 3. Если исходить из соображения, что у нас есть состояние экрана и функции-обработчики кнопок, то мы можем провести это тестирование исключительно на уровне функций или классов, отслеживая показатели состояния:
- вызвали метод кнопки 1
- взяли структуру окна и проверили, что поле цвета соответствует цвету 2
- вызвали обработчик кнопки 3
- убедились, что состояние видимости окна изменилось
Вроде все просто, но опытный разработчик уже заметил определённую сложность такого подхода.
Архитектурые требования для тестов
Увы, чтобы использовать такой подход, необходимо всегда отделять данные и правила от реализаций. Легко протестировать функцию, к которой есть прямой доступ, но что делать, если это вложенная функция вложенной функции? Она банально недоступна.
Именно поэтому и появляется первая сложность: разработка приложения изначально должна осуществляться с прицелом на будущее тестирование.
Вторая же сложность: необходимо проведение чётких архитектурных границ между логикой и деталями реализации (например, между поведения экрана пользовательского интерфейса и данными состояния).
Это накладывается довольно серьёзные ограничения на простой и привычный многим (в том числе мне) подход, когда код делится на компоненты исходя из удобства написания кода, а отнюдь не тестирования.
Впрочем, плюсов от такого, немного избыточного, подхода, тоже предостаточно.
Тесты для отладки
Если структуры данных и методы, управляющие ими, можно выделить в отдельные модули (или компоненты) - они становятся более удобны для тестирования. И, более того, это даёт значительный простор для обнаружения своих собственных ошибок.
Случай с неправильными вычислениями
Был у меня случай - писал несложную игрушку: дело несложное, привычное. Код, постепенно, разрастался.
Но когда всё было готово и заработало - всё работало неверно. Код выглядел правильным, собирался без проблем, но результат был неудовлетворительным.
Тестов у меня тогда не было, т.к. торопился и хотел сэкономить время. Посидев с отладчиком несколько часов, в конце концов, я сдался и написал тесты (суммарно, это заняло не более часа) и нашёл довольно большое количество мелких оплошностей, каждая из которых отдельно не создавала бы проблемы, а лишь при очень редком совпадении обстоятельств создавала бы аномалию, но все вместе приводили к неверному поведению в ряде случаев.
Короче говоря, тесты, на которых я хотел сэкономить время, в конечном итоге сэкономили время мне. Дополнительный приятный момент заключался в том, что исходный код стал более прозрачным и понятным.
Тесты для проверки правильности работы
Для конечного пользователя есть непреложная истина: работающая программ и правильно работающая программа - это совсем не одно и то же. Для разработчиков, зачастую, это не так. Помните эту фразу "у меня всё работает, проблема на вышей стороне"? Кто из нас её не говорил?
Тесты тут, к счастью, тоже могут помочь, только тестировать уже надо не отдельные функции или классы, а целый компоненты. А это значит, что архитектура компонентов должна быть составлена таким образом, чтобы были "входы и выходы" для тестирования.
Думаю, вы уже поняли, что масштаб проблемы сильно больше, нежели просто тесты; тут речь идёт о глубокой проработке структуры данных, состояний, конечных автоматов приложения и многого другого.
Затея непростая, однако если стоит задача выпустить стабильный продукт, неизбежная.
Когда можно не писать тесты
Самая большая проблема тестов - это то, что во многих командах их буквально заставляют писать, оценивая работу разработчика по процентам покрытия его кода тестами, что, разумеется, лишь ещё больше подталкивает к профанации.
Лично я убеждён, что тесты не всегда нужны. Например, не имеет смысла писать тесты для очевидных случаев, который можно проверить в процессе разработки и которые при этом не будут меняться с высокой долей вероятности. Например, диалоговое окно, требующее подтверждения удаления.
Также можно не тестировать код, неспособный оказать критическое влияние на результат программы, либо ошибка в котором будет сразу же заметна с чётким понимаем точки отказа. Например, выдающий в консоль ошибку с указанием места проблемы и "роняющую" программу.
Ещё можно пропустить для тех областей, которые будут однозначно проверяться тестировщиками, но не содержат при этом каких-либо непредсказуемых моделей поведения. Например, вход в приложение, который нельзя пропустить.
Хотя это, конечно, обобщённые советы. Руководствоваться надо, разумеется, здравым смыслом и уместностью тестов.
Заключение
Итак, тесты - это хорошо, если они не превращаются в повинность и профанацию, так как:
- Они позволяют обнаруживать и отлаживать ошибки в своём коде ещё до того, как он начал неправильно работать в составе всего приложения (TDD);
- Они позволяют проверять правильность работы приложения, а не только кода;
- Они вынуждают делать более качественной архитектуру приложения (либо компонента приложения), ориентируя её на тестирование;
- Они входят в архитектурный контур приложения и являются её составной частью, вынуждая чётче проводить архитектурные границы;
- Они нужны там, где нужны, а не везде, где можно протестировать;
- Всё охватить тестами нельзя и, к счастью, это не требуется - тестировать надо лишь то, где ошибка наиболее вероятна.