Monday, 13 August 2012

JUnit неумолимо стремится в направлении к TestNG. Но оочень медленно. Так, появились TestRule, ClassRule, Categories, что подозрительно смахивает на Test Listeners, Method Interceptors, Test Groups, которые есть в TestNG уже миллион лет.

Friday, 10 August 2012

Что такое модульное тестирование и что им не является

Довольно давно я заинтересовался таинственной аббревиатурой TDD. Как известно, наверное, каждому разработчику, расшифровывается она как Test-Driven Development, то есть - разработка через тестирование. Чем-то меня эта идея зацепила, и с тех пор, вот уже более 5 лет, я не оставляю попыток изучить и овладеть этой методикой. Сразу скажу, что я ни разу не работал в команде которая её применяла, более того, я даже ни разу не работал в команде систематически и успешно применяющей автоматизированное тестирование. Хотя с несистематическим успешным применением автоматизированного тестирования и лично моим, на мой взгляд, успешным применением TDD время от времени я сталкиваюсь. Хотелось бы рассказать о тех шишках, что я набил, о заблуждениях и прочих вещах, которые меня сопровождали на пути изучения.

Сразу скажу, что TDD - это сложно и требует немалых усилий. Но я считаю, что в конечном счёте эти усилия окупаются. Всё написанное представляет моё ИМХО, но подкреплённое большим количеством прочитанных статей, книг, и практическим опытом.

Зачастую ставится знак равенства между TDD и модульными тестами (Unit Tests). Это неверно, но почему мы поговорим в другой раз. Модульные тесты в любом случае являются очень важной составляющей TDD. И если у вас есть TDD, ограничивающееся модульными тестами, то это, скорее всего, лучше, чем если у вас нет автоматизированного тестирования вообще (скорее всего =) ).

Давайте разберёмся, что же такое модульные тесты. Это, на самом деле, не так-то просто. Определения можно давать по-разному. Так, мне приходилось сталкиваться с термином "Unitary Testing", которое выглядит подозрительно похожим на Unit testing. Но на практике это было ручное тестирование целой отдельно стоящей системы.

Распространено следующее определение: "Модульный тест - тест, проверяющий работу модуля". Примерно такое определение даёт википедия. Заметьте, что об автоматизации тут не говорится ни слова. Действительно, если вы написали метод и прогнали его в дебаггере, интерпретаторе или просто через пользовательский интерфейс, то это - модульное тестирование. Если вы его прогнали, увидели, что не работает; дописали код, увидели, что работает; проверили другой случай, увидели, что не работает; дописали ещё код, увидели что работает - то это TDD. Но это не то TDD, которым нам хотелось бы заниматься. Почему? Да очень просто. Допустим, тестеры нашли баг в этой функциональности, и вам его необходимо исправить. Вы воспроизводите баг, поправляете код, прогоняете (вручную, не забывайте) ещё раз и видите, что всё исправлено. Всё отлично!? Нет! Вспомните вашу предыдущую сессию ручного "TDD" - вы уверены, что всё те тесты до сих пор проходят после вашего изменения? Вы, может быть, и уверены, но неплохо бы проверить. Какие есть варианты? Можно внимательно посмотреть на код и проанализировать его в уме. Сделав это, вы составите определённое мнение - либо новое изменение ничего не портит, либо портит, либо неочевидно. Если портит, то надо проверить, убедиться, поправить и ещё раз проверить. Если неочевидно, то надо проверить и мы приходим к одному из двух предыдущих вариантов.

Проблема в том, что рассуждение о коде в уме - не смотря на то, что это очень полезное занятие - весьма подвержено ошибкам. Мы, как известно, можем одновременно помнить и рассуждать о 7 +- 2 вещах на одном уровне абстракции одной предметной области. Код, к сожалению, далеко не всегда написан на одном уровне абстракции, кроме того, в нём нередко смешаны понятия совершенно разных предметных областей, а ещё есть такое явление как "протекающие абстракции" (Джоэль Спольски). Ну и самих объектов кода нередко больше семи, а если уж вспомнить о количестве их состояний.. В общем, правило 7+-2 нарушается постоянно и очень сильно - в сторону +. Поэтому, наши мы не можем надёжно рассуждать о поведении кода. Если вы можете, то либо вы гений, либо ошибаетесь) Конечно, нужно стремиться к тому, чтобы то, что мы пишем, подчинялось этому правилу, но это совершенно отдельная тема. Кроме того, нам часто приходится работать с чужим кодом, и совсем не факт, что его авторы вообще знают об этом правиле.

Каков же выход? Довольно очевидный - внеся изменения, повторить все тесты, чтобы убедиться, что всё работает как прежде, и наше исправление тоже работает как ожидается. Да, для этого ещё необходимо помнить какие же мы там тесты выполняли ранее. Из этого следует необходимость тесты автоматизировать.

Вышесказанное касается не только модульных тестов, но и любых других. Здесь и далее подразумевается, что когда мы говорим о тестах, которые выполяняют разработчики - мы говорим об автоматизированных тестах. Включать в определение мы это не будем, так как неавтоматизированные тесты всё равно необходимы, и даже многие модульные тесты появляются как неавтоматизированные. Пример: тестировщик нашёл баг, разработчик его воспроизвёл, а потом написал тест для воспроизведения, возможно, что не с первого раза. Наш изначальный тест существует сначала в голове тестировщика, потом в виде описания как его воспроизвести, потом только он становится автоматизированным. Важно, чтобы в итоге эти модульные тесты стали автоматизированными.

С автоматизацией разобрались, что ещё можно сказать относительно нашего определения? Что такое модуль, например? Это вопрос субъективный, как уже было сказано выше, под ним можно подразумевать и отдельную систему которая входит как часть в какой-то сложный программный комплекс. Но обычно под модулем (unit) подразумевается функция, метод, класс или небольшой набор классов, которые в совокупности решают некоторую задачу. С подобным определением все, в основном, согласны, но, тем не менее, оно может вызывать ряд вопросов.

Предположим, мы тестируем функцию main. Является ли данный тест модульным? Мы же тестируем одну функцию. Правда, она может запускать HTTP веб-сервер, который, обрабатывая запросы, запускает задачи в Hadoop кластере. С другой стороны, наш main (всё приложение, то есть) может просто запрашивать у пользователя два числа и складывать их. А этот тест - модульный? В первом случае main вызывает всю систему. Вряд ли в ней "небольшой набор классов" (пусть небольшой - это всё те же 7+-2). А второй? Класс один. Наш класс один. Но мы запрашиваем данные и выводим их. А это вовлекает стандартную библиотеку. А в ней, даже если говорить только о том пути по которому идут наши данные, наверное ,не такой уж и "небольшой набор классов". Впрочем, мы не знаем сколько и должны это проанализировать, если есть исходный код. А если нет? В общем, определение несколько размыто.

Уточним его: "Модульный тест - тест, проверяющий работу модуля в изоляции". Кстати, википедия тоже говорит об изоляции, но не включает её в определение. Что значит в изоляции? Перечислим наиболее распространённые варианты:
  • Не использует третьесторонние интерфейсы, кроме собственно тестового фреймворка и API, либо специально предназначенных для тестирования вроде Hamcrest или mocking-фреймворков (JMock, Mockito и другие), либо "простые" вспомогательные API вроде Guava Functional/Collections, Apache Commons Lang. Их отличительной чертой является то, что в основном они устраняют недостатки языка Java и его стандартной библиотеки, являются Pure-Java и не используют и не предоставляют какой-бы то ни было иной функциональности - особенно упомянутой в следующих пунктах. 
  • Не использует файловую систему 
  • Не использует сеть 
  • Не использует базу данных 
  • Не использует многопоточность, многопроцессность и иные ипостаси конкурентных, распределённых и параллельных вычислений. 
В общем, похоже, что вообще ничего использовать нельзя. Почему? Как быть?

Начнём с вопроса "почему". Модульные тесты - это самая базовая система безопасности вашего проекта, Это сторожевая собака во дворе. Или даже забор. У вас может быть ещё и сигнализация в доме, но я думаю, что вы бы предпочли, чтобы злоумышленники до её активации не добрались. Эта система безопасности должна проверять каждое изменение и как можно быстрее. Здесь можно долго рассуждать важности быстрой обратной связи, но (пока?) примем за аксиому - чем раньше мы получаем обратную связь - тем лучше. Если мы что-то написали и оно не работает или ломает что-то, то лучше узнать об этом до коммита изменений, хуже после коммита и вообще плохо если система уже в эксплуатации и используется тысячами пользователей. Как узнать? Запустить тесты. И чем чаще и чем больше тестов мы запустим - тем больше наша уверенность в том, что наши изменения корректны. А если они некорретны, то мы об этом узнаем быстро. Но, допустим, тесты всей системы занимают полчаса. Никто не будет их запускать после каждого изменения. А если наш тест ломится в базу и что-то там долго делает, то мы очень быстро придём именно к этому. Кроме того, у базы могут измениться настройки. Может измениться схема. Она может вообще быть недоступна из-за обновления сервера. На диске может кончится место. Может измениться интерфейс удалённой системы. Ваш сетевой кабель могла перегрызть мышь, или просто он разболтался и плохо держится. После очередного обновления операционной системы могли измениться настройки файервола. Ваш коллега может качать торрент South Park, из-за чего сеть работает слишком медленно. Третьесторонняя библиотека может содержать ошибки или сама ломиться на диск, в базу и в другие места. И так далее и тому подобное. Всё это, во-первых, замедляет выполнение тестов; во-вторых, приводит к случайным ошибкам тестов. В результате, мы либо запускаем тесты реже, либо запускаем меньшее количество тестов, либо вынуждены запускать из по нескольку раз, либо всё сразу. Все эти ситуации мы тоже должны учитывать. Но вспомните про 7+-2 вещи. Это совсем другая область и другие вещи. Сейчас мы работаем над функцией и хотим, чтобы она вела себя правильно. Когда мы этого добъёмся - мы проверим, что всё работает в целом и изменим, то, что необходимо. Но сейчас мы работаем над нашим кодом, который должен демонстрировать определённое поведение. И мы не хотим отвлекаться на час, звонить DBA, который в отпуске, а потом с трудом вспоминать, на чём же мы остановились. И это в лучшем случае! А в худшем мы вообще не сможем продолжить работу над задачей, пока DBA не выйдет из отпуска. Итак, мы убедились, что модульные тесты должны быть изолированы.

Следующий вопрос: "Как быть?". Надо писать код так, чтобы его можно было тестировать в изоляции. Поскольку система в целом в изоляции работать не может, то очевидно, что все тесты не могут быть модульными. Так или иначе необходимо тестирование всей системы, а также связок системы с внешними сущностями и ресурсами. Хорошо, если эти тесты тоже автоматизированны. Но это другой класс тестов и надо это понимать. Для них действуют другие правила: они запускаются реже, работают дольше и могут иногда случайно выдавать ошибки. Мы не запускаем их после каждого изменения. Чем большая часть системы может быть протестирована в изоляции - тем лучше. Выглядят они обычно тоже не так, как модульные.

Как понять, что тест, с которым вы имеете дело - не модульный? Есть несколько признаков:
  • Нарушение любого из пунктов изоляции. В том числе, использование Spring Runner, логирования, применение HSQL (даже в оперативной памяти). 
  • Вовлечение большого количества классов. 
  • Скриптовые тесты - вы выполняете ряд действий над системой, чтобы провести её через ряд состояний, проверяете что-то, делаете дальнейшие действия, проверяете ещё что-то. 
  • Частный, но распространённый случай предыдущего - множество проверок. Впрочем, это может быть и показатель плохого качества кода - тестового или/и рабочего. 
  • Зависимость тестовых методов друг от друга 
  • Использование статических данных. Случайные ошибки тестов. 
Всё вышеперечисленное может присутствовать в тестах. И эти тесты могут быть очень полезны. Просто они не будут модульными. Это нужно понимать, потому что, не имея модульных тестов, вы лишаетесь максимально быстрой и надёжной обратной связи. Это может быть разумным компромиссом, но если вы не понимаете разницы, то этот компромисс неосознан (и неразумен). А неосознанный компромисс в программном проекте, как и любое неосознанное решение, может быть бомбой с часовым механизмом.

PS: Если вы используете JUnit - это не значит, что вы используете модульные тесты. JUnit предназначен для модульного тестирования, тем не менее, можно его использовать и для других видов тестирования, хоть он и не слишком для них удобен. Кроме того, новые версии постепенно включают в себя дополнительные функции для других видов тестирования.