Практика написания Android-тестов. Лекция Яндекса
С праздниками, друзья! Если вы не против научиться на каникулах чему-то новому, прочитайте лекцию Кирилла Борисова — разработчика систем авторизации Яндекса. Кирилл объясняет, как построить процесс тестирования Android-приложений, знакомит с современными инструментами и спецификой их использования.
— Прежде чем двинуться вперед, давайте устроим небольшой соцопрос. Кто из вас знает, что такое тесты? Кто пишет тесты? А кто знает, зачем он пишет тесты? Примерно одни и те же люди.
Тем, кто с тестами знаком понаслышке и не прикладывал к ним руки, хочу представить пример простейшего теста.
Как видите, ничего страшного. Это самый простой тест, который проверяет, что законы математики еще не изменились и 2 + 2 по-прежнему равно 4. Это всё. У вас перед глазами полноценный тест.
На самом деле тест — это просто функция на каком-то языке программирования. В нашем с вами случае это скорее будет Java, хотя может быть Kotlin и т. д.
Тест запускается неким программным пакетом, тестовым фреймворком, который берет на себя всю черную работу по их обнаружению, запуску, обработке результатов и т. д. Самым частым является пакет jUnit, который дальше будет рассматриваться в нашей лекции, но ничто не останавливает вас от использования каких-то других пакетов или написания своего собственного.
Тесты группируются в классы. Порядочная функция должна жить в классе. Затем эти классы разбиваются по смысловому признаку на различные категории тестов, которые мы рассмотрим позже.
Тесты — это благодать и польза. Во-первых, они позволят освободить ваших тестировщиков от рутинных задач по проверке уже исправленных багов, так называемой регрессии, и, в свою очередь, помогут им воспроизводить сложные случаи, которые требуют большого набора действий и поддаются автоматизации.
Но вы скажете: погодите, какие тестировщики? Я простой независимый разработчик, я один пишу приложение, один выкладываю, один зарабатываю деньги. Не хочу вас расстраивать, но вы — тот самый тестировщик, просто потому что вам все равно рано или поздно придется проверять, как работает приложение, тыкать основные сценарии его использования и т. д. И этому бедному тестировщику очень помогут автотесты.
Во-вторых, они повысят у вас, разработчика, уверенность в вашем коде. Знание о том, что написанное в вашем коде какой-то бездушный компьютер проверяет, что это все работает так, как ожидается, поможет вам не заботиться об этом и свободно разрабатывать код. Как только что-то непредсказуемым образом изменится, тут же вскачет красный флажок, и вы поймете, что нужно чинить.
На самом деле это приводит к тому, что у вас повышается качество кода. По мере того, как вы будете покрывать свой код тестами, по мере того, как вы будете перерабатывать его, чтобы можно было его тестировать, вы неожиданно заметите, что код становится все более приятным глазу, все более легким для прочтения и понимания. Вы даже сможете привлекать других программистов, которые также будут читать этот код и понимать, что это замечательное произведение программистского искусства.
Но самое главное — они помогают вам сохранять совместимость. По мере того, как ваше приложение будет расти и цвести, вы так или иначе будете задевать другие участки кода, на которые могут полагаться другие люди. Представим, что вы не один в команде, у вас есть такие же собратья разработчики, и они пишут свои модули, ориентируясь на то, как работают ваши модули. Если вы покрыты тестами, если вы обеспечили себе проверку того, что оно все еще работает так, как вы это задумали, как только вы что-то сломаете, вы об этом узнаете сразу же. Тем самым вы поможете им не думать, что вдруг он сегодня что-то сломал, пойду перепроверю. По сути, вы возлагает все заботы на бездушный компьютер, оставляя себе просто для творчества.
К сожалению, как и все в этом мире, ничего не приходит просто так. У всего есть своя цена. Думаю, очевидно, что у вас будет уходить еще больше времени на разработку. От этого никуда не деться. Тесты вам придется писать, вам придется о них думать, придется их тестировать, и все это занимает драгоценное время. Но как и вложение сил во что-то хорошее, оно рано или поздно окупится.
Во-вторых, вам придется повысить навыки. У всех нас есть пространство для роста, в том числе в области технических навыков. По мере того, как вы будете писать тесты, вам придется изучать новые инструменты и методики. Но все это идет вам на пользу.
А самое страшное, что может испугать не только вас, но и вашего менеджера, может понадобиться рефакторинг. Страшное слово, которое вселяет ужас в любого человека, планирующего выпуск ПО. К сожалению, тесты — не волшебная палочка-выручалочка. Чтобы они могли работать, вам придется перерабатывать свой программный код, к примеру, чтобы использовать какие-то программные интерфейсы, которые были ранее недоступны, либо сделать его более модульным, чтобы было проще тестировать. К сожалению, все это все требует времени, денег и усилий — самого ценного в нашей индустрии.
И в конце концов в сумме все это усложняет выпуск кода. Когда вы раньше могли взять и просто скомпилировать код, отправить в Play Store и пойти есть пиццу, в этот раз у вас уже это не получится. Скорее всего, у вас появятся такие процессы, как запуск кода, проверяющего ваш код, просмотр отчета о тестах, отправка это на сервер Continuous Integration и т. д. К сожалению, это цена, которую придется заплатить, но как и все предыдущее, оно в будущем окупится.
Самый заковыристый вопрос, связанный с тестами, который слышу чаще всего, когда пытаюсь протолкнуть эту идею:, а как мне убедить моего менеджера? Потому что эти люди не понимают, зачем нам нужны тесты. Они думают, что опять эти программисты что-то придумали, они хотят какие-то тесты, нам нужно делать фичи, выпускать.
К сожалению, аргументов на это достаточно мало, но есть проверенный список, который вам всегда поможет.
Во-первых, их очень радует, когда в вашем конечном продукте становится меньше багов. По мере того, как вы будете покрывать ваш код тестами, количество ошибок, в том числе глупых, которые проскальзывают совершенно случайно, будет уменьшаться. Ведь вы будете их обнаруживать и своевременно исправлять. Именно это приводит к тому, что у вас происходит ускорение поиска причин ошибок, и как только к вам прибегает менеджерами с криками: «Шеф, все пропало», вы тут же смотрите на список пройденных и заваленных тестов, понимаете, где и что сломалось, исправляете и спасаете день.
Все это приводит к тому, что экономятся деньги и время. Это радует практически всех. По мере того, как у вас уходит меньше усилий на поиск ошибок в вашем коде, меньше усилий на переработку этого кода и исправление его, вы можете тратить больше времени на действительно интересные вещи: разработку новых фич, приносить больше денег за меньшие промежутки времени. В теории. Не знаю, как это работает конкретно в вашем случае, но должно работать примерно так.
А главное, вдумайтесь, вы приходите в новую компанию и спрашиваете, есть ли у вас тесты? Они говорят: «Да, у нас 100% покрытие, все проверяется». И вы думаете, что я пришел в правильную компанию. Согласитесь, очень круто. И когда к вам уже придут молодые гордые разработчики, вы скажете —, а у нас тут тесты, 97% покрытия, мы все тут тестируем. И они будут смотреть и понимать, что да, это крутая компания, крутая команда разработки, хочу с ними остаться.
Переходим к конкретной теории. Посмотрим на уже упомянутые тесты в разрезе.
Перед вами скелет практически любого типового теста в вакууме. Он состоит из нескольких блоков, которые обязательно отмечены желтым цветом, и серое — для тех, кому это понадобится.
Самое важное — название теста. Я встречал много людей, которые думают, зачем давать тестам какие-то осмысленные названия? Тест1, Тест2, Тест3 это уже хорошо, различаются и ладно.
На самом деле название теста как название книги. Это что-то, что должно в очень короткий промежуток уместить как можно больше смысла. Это то, что вы увидите в вашем отчете, то, что будет светиться в вашем редакторе кода. Вы должны по одному названию теста уже получить примерное представление о том, что же он проверяет, что же в нем происходит. Поэтому стоит приложить усилия и подумать о том, как вложить в одном предложении из трех-четырех слов смысл того, что вы проверяете.
Далее их обязательных блоков идет совершение действия. Чтобы что-то проверить, нам нужно что-то сделать. В этом блоке выполняется какое-то воздействие на вашу систему. К примеру, дергаете функцию, запускаете сервис, открываете окошко. Получив результат этого действия, вы переходите к главной, самой сладкой части любого теста — проверке результатов. Именно здесь находится сердце теста. Именно здесь вы проверяете, что мир изменился таким образом, как вы от него ожидало. Что открылось окошко, а не удалились все файлы с устройства. Что у вас запустилась видеозапись, а не произошло стирание памяти и т. д.
А что же несут в себе серые блоки, отмеченные здесь, подготовка окружения и освобождение ресурсов? На самом деле, это те скучные повторяющиеся части, которые рано или поздно начнут появляться в вашем коде.
Если у вас все ваши тесты связаны, к примеру, с файлами, если вы в каждом тесте создаете один и тот же файл, открываете одни и те же файлы, потом закрываете их, удаляете, зачем все это носить с собой из теста в тест? Можно просто воспользоваться любым из инструментов вашего тестового фреймворка, и вынести их в отдельную небольшую функцию, которая будет вызываться перед вашим тестом и после него.
На самом деле, это может пригождаться не всегда. Ваш тест вполне может обходиться и без этих необязательных блоков. Но если что-то случится, знайте, что это не беда, вы просто выносите их в отдельную функцию, и все работает.
Таким образом ваш тест остается исключительно из обязательных частей, простой и элегантный.
Мы поняли, что такое тест и как его написать. А что мы будем тестировать?
Есть несколько теорий о том, что надо тестировать с помощью автотестов. Практически все сходятся к тому, что есть ряд таких типичных случаев. Во-первых, первое — happy path, типовой путь выполнения вашего кода. Это то, что вы знаете, что я нажму на кнопочку, и появится окошко. Вы сначала проверяете его, что действительно вы нажали на кнопку, и появилось окошко. Или ввели ваше имя, и оно подсветилось особым образом. Все, вы знаете, как это должно работать, вы ожидаете, что это будет так работать, но на всякий случай пишете на это тест. Потому что если вдруг это сломается, будет печально.
Затем вы проверяете все возможные краевые случаи. К примеру, если человек введет свое имя японскими иероглифами или если вдруг в графу возраста он введет эмодзи. Что я буду делать в таком случае? Справится ли мой код с этим?
Вы пишите на каждый такой случай отдельный тест, который проверяет, что ваше приложение будет действовать определенным образом, к примеру, выкинет окно с ошибкой, либо же просто завершится и больше откажется запускаться — все на ваш выбор.
Затем вы переходите к самому банальному. Что будет, если я начну запихивать null в любое место своего кода, куда только подумаю. Ага, а у меня есть функция — отправлю null. У меня есть опциальный аргумент — отправлю null. и т. д. Ваш код, по-хорошему, должен быть устойчив к тому, что к одному из ваших аргументов неожиданно придет пустота. И в самую последнюю очередь, думаю, стоит коснуться сценария, когда не работает ничего. Ваше приложение, предназначенное для того, чтобы отсылать ваши фоточки в Instagram каждые пять секунд, неожиданно понимает, что у него нет сети. И нет камеры. Что делать? По-хорошему, надо, чтобы ваше приложение каким-то осмысленным образом дало понять пользователю, что, извините, я не буду работать. Именно это вам стоит протестировать, что в случае, когда все пошло не так, ваше приложение по-прежнему работает хоть как-то ожидаемым образом. Ничто так не огорчает пользователя как окошечко NullPointerException с ошибкой от Android или что-то в этом роде, страшно подумать.
Когда все это тестировать? Как только начинаем разработку или когда уже закончили? На этот счет нет единого мнения, но есть набор устоявшихся концепций. Во-первых, нет смысла писать тесты, когда код вашего приложения меняется буквально каждый час. Если вы с вашими друзьями находитесь в плену музы, и буквально каждый час меняется концепция, вы меняется всю структуру программы, архитектура плывет, если к этому еще будете писать тесты, у вас будет больше времени уходить на то, чтобы постоянно переписывать эти тесты вслед за вашей творческой мыслью.
Хорошо, у вас код уже более-менее устоялся, но ваш дизайнер тоже попал в тиски этой страшной женщины (музы) и начинает дергать ваш UI каждую минуту — о боже мой, новый дизайн, мы поддерживаем новую концепцию.
Писать тесты, которые взаимодействуют c UI, в этот момент тоже не очень хорошая идея, потому что вам также придется их переписывать практически с нуля.
Как только у вас стабилизировался код приложения, UI уже не скачет по экрану, стоит ли писать тесты дальше? Да, хотя бы потому, что рано или поздно в вашем приложении обнаружатся так называемые регрессии, в простонародье баги. Это что-то, что обозначает нарушение работы вашего приложения. К примеру, ваше имя отображается справа налево, потому что мы случайно подумали, что мы в стране с арабской клинописью и т. д. Это регрессия, это баг, но нужно написать тест, чтобы проверить, что в будущем в этих условиях ваше приложение будет работать все-таки ожидаемым образом.
Вот три случая, когда точно следует писать код. Когда он не плывет, когда не плывет UI, и когда у вас обнаружится уже какой-либо баг. С этого вы начнете свой путь.
Тесты делятся на несколько категорий. Такая пирамида тестов показывает примерный порядок того, в каком плане надо начинать писать тесты в вашем приложении. В основе этой пирамиды лежат так называемые юнит-тесты. Это самый низкий уровень, соль земли. Это тесты низкоуровневые, когда вы тестируете отдельные юниты в изоляции друг от друга.
Но что такое юнит?
Ученые до сих пор спорят по этому вопросу, пишут научные работы. Каждый решает сам для себя, что является юнитом в его приложении. Чаще всего в качестве юнита выбирается какой-либо класс, и тестируется функция этого класса, методы этого класса, различные условия его взаимодействия. Мы предполагаем, что класс — это некая замкнутая в себе сущность, к примеру, класс, вычисляющий длину строки или класс шифрования и т. д. Обычно он не связан с другими какими-то классами в относительно явном смысле, и его можно протестировать.
Этих тестов у вас будет большинство. Именно эти тесты у вас будут запускаться чаще всего. Если у вас будет привычка запускать каждые пять минут — это нормально. Мы так делаем, и все идет хорошо.
Юнит-тесты предназначены для того, чтобы в первую очередь контролировать вас в процессе написания вашего кода, именно потому что они самые низкоуровневые и должны проходить как можно быстрее.
По мере того, как вы их запускаете, к примеру, нажали Ctrl + S, и тут же у вас прогнались тесты, и вы тут же заметили, что что-то поломалось. Согласитесь, лучше обнаружить ошибку, пока она еще не успела проникнуть куда-то еще.
Рассмотрим пример такого юнит-теста. Рассмотрим любимый нами класс статических утилит. Есть класс, содержащий ровно одну функцию, которая проверяет наше гипотетическое приложение, ввел ли пользователь сильный пароль, взломают ли его хакеры или нет. Эта простейшая функция содержит три основных условия, три основных инварианта про то, что наш сильный пароль не должен содержать меньше семи символов, должен содержать в себе хотя бы одну заглавную латинскую букву, и как минимум одну цифру. Иначе это курам на смех.
Если все эти три условия проходят, мы возвращаем, что все хорошо, регистрируйте пользователя, мы идем дальше.
Как же мы это будем тестировать? Нашим юнитом мы здесь выбираем эту функцию isStrongPassword, и будем тестировать каждый из этих трех случаев отдельно.
Начнем с первого условия, что в наши функции должны передаваться строки длиной больше 6 символов, чтобы они были признаны успешными. В нашем первом тест-кейсе мы проверяем, что если мы передаем строчки, у которых меньше семи символов, то наша функция вернет false. За это отвечает функция assertFalse, которая вскинет руки в панике и остановит весь процесс тестирования, если ей вместо false внезапно придет true в качестве аргумента.
В таком же духе мы проверяем наши основные случаи, и проверяем один контрпример, что если мы все-таки передадим нашей функции длиной больше, чем 6 символов, она же вернет true. Такой тест-кейс в вакууме. Мы проверили какие-то условия, вызывающие падение нашей функции. Мы проверили, что если мы передаем ей ожидаемые параметры, она отвечает ожидаемым образом. И в таком же духе мы тестируем все остальное.
У нас отдельный тест-кейс на условие проверки того, что в нашем пароле есть хотя бы одна цифра. У нас есть отдельный тест-кейс на проверку того, что в нашем пароле есть хотя бы одна буква. И вы спросите, где же четвертый тест-кейс? У нас же там было четыре пути выхода из функции. На самом деле, мы в предыдущих трех тест-кейсах уже проверили, что если мы передаем пароль, который отвечает всем нашим этим условиям, то мы так или иначе вернемся true.
Давайте посмотрим на главную звезду этих тестов, на функции, начинающиеся со слова assert. Они принадлежат классу функций, называемых ассертами. Эти функции являются всего лишь вспомогательными инструментами, представляемыми тестовым фреймворком jUnit, которые просто помогают вам выразить ваши намерения. К примеру, если у вас вызывается функция assertEquals, вы говорите, что я ожидаю, что эти два параметра должны быть равны. Если нет — все сломалось, все пропало, завершайте проверку. Если assert = null, not null и т. д.
Эти функции и являются вашими инструментами проверки в тестах. Пока они получают на входе ожидаемое условие, тест не прерывается. Как только он прерывается, значит, у вас есть проблема.
Если вам кажется, что ассерты из предыдущих слайдов сложно читаемые, к вашим услугам специальные инструменты, написанные нашим комьюнити.
Одним из самых популярных является AssertJ.
Он является попыткой сделать проверку результатов выполнения вашего кода, более читаемым, приблизить его к стандартному английскому языку. Простейший пример — ассерт That (count).isGreaterThan (original). Это читается намного проще, чем assert true a < b. У вас практически текст на английском языке, и Шекспир был бы ему рад.
Если вам нужно что-то еще более сложное в качестве проверки, то AssertJ придет вам на помощь и в этом. Представьте, что у вас есть массив абстрактных объектов, в которых есть поле count. Это какие-то счетчики. Вы хотите проверить, что массив счетчиков, вернувшийся из вашей функции, содержит в себе только числа 1, 3, 4. Ничто не посчиталось два раза. С помощью AssetJ вы можете записать это достаточно простым декларативным образом: assertThat (counters).extracting («count») .contains (1, 3, 4) и .doesNotContain (2). Думаю, это читается намного проще, чем сложный цикл, достающий элементы, запихивающий в другой массив, проверяющий на соответствие. Чем проще читается тест, тем он понятнее.
Если же стиль AssertJ вам не нравится, то существует еще один инструмент, выполненный в подобном духе.
Hamcrest является фаворитом многих моих знакомых автотестировщиков. Он выполняет примерно ту же функцию — пытается сделать ваш код более читаемым, просто делает это иным образом. В отличие от AssertJ, где код пишется как последовательность вызовов у билдера, здесь используется иерархическое дерево матчеров. За этим страшным названием скрывается просто тот факт, что у вас функции проверки иногда бывают вложены друг в друга, чтобы выразить более-менее сложное условие, а в результате все равно получается читается текст.
Тот же пример с текстом, что какой-то счетчик меньше оригинального значения, читается также. С counters то же самое, хотя и менее читаемое. Это примерно одно и то же, а главное, примерно так же читаемо.
Дальше по пирамиде были интеграционные тесты.
С точки зрения кода это примерно те же самые юнит-тесты, но чуть-чуть другие.
В отличие от юнит-тестов, которые направлены на то, чтобы проверить работу одного конкретного изолированного компонента, интеграционные тесты предназначены для того, чтобы выполнять взаимодействие нескольких компонент. Вы спросите, зачем? Класс А протестирован, класс Б, юнит-тест есть, все норм. Они же должны нормально работать.
На самом деле жизнь преподносит нам неожиданные сюрпризы, и впервые задействовав их в приложении, вы неожиданно заметите, что ваш класс Б запускает поток, который ожидает что-то, что ожидает и поток Б. По отдельности они работали замечательно, а вместе неожиданно начали ломаться, вешать ваше приложение и доставлять ему головную боль.
Именно поэтому необходимо писать интеграционные тесты, проверяющие интеграцию разных компонентов. Этих тестов должно быть меньше, чем юнит-тестов. Основной вашего блока тестов должны стать именно юнит-тесты, потому что они гоняются быстрее, их быстрее запускать, и они вам больше пригождаются непосредственно в процессе разработки.
Интеграционный тест — это тест, который запускается, когда вы отправляете ваш код в общий репозиторий исходного кода. И перед тем, как вы хотите отправить и увековечить то, что вы сделали, хорошо бы проверить, что в итоге это работает. Что ваша гигантская махина из различных компонент может взаимодействовать между собой.
К сожалению, они бывают ощутимо сложнее в реализации, потому что эти тесты могут по сложности приближаться иногда к вашему основному коду. В то время как раньше вы просто тестировали изолированно отдельные функции, здесь вам придется показать какие-то ресурсы, открывать соединение с БД, устанавливать соединение с сервером, создавать файлы и т. д. Но это стоит того. По этой причине они и запускаются обычно реже, чем юнит-тесты, потому что их запуск может занимать больше времени.
А в самой вершине нашей пирамиды находится самый благородный класс тестов — UI-тесты. Это тесты, которые настолько благородны, что в отличие от юнит-тестов и интеграционных тестов, они вообще не знают, как работает наше приложение изнутри. Для них оно черный ящик, как и для обычного пользователя. Ведь они предназначены для того, чтобы проверять основные сценарии работы с нашим приложением.
Подумайте сами, у вас хорошо протестированный код, компоненты работают бок о бок, локоть об локоть, и казалось бы, все хорошо. Вы отправляете приложение в Play Store, и вам прилетает первый же отчет о том, что «у меня на экране ничего нет, ни одной кнопки, как им пользоваться». И вы неожиданно понимаете, что все это время вы увлеченно тестировали код, зная, как он работает, ваши тесты проходили, а вы забыли добавить в интерфейс кнопку, которая запускает весь этот процесс. Печаль. Что делать?
Для этого существуют UI-тесты, предназначенные для проверки с точки зрения пользователей основных сценариев взаимодействия с вашим приложением. Не так много цены вашего приложению, если оно не сможет отправлять фоточки на сервер Instagram, даже если оно может это делать. Главное, чтобы это смог сделать пользователь.
В отличие от предыдущих тестов, которые залезали по локоть в кишки вашего приложения, они работают как и обычный пользователь, такими же инструментами. Они вводят текст, нажимают на кнопки, скролят экран и т. д. Они и запускаются гораздо реже, обычно перед самым релизом приемочные тесты, хотя бы потому, что они требуют определенной подготовки, запуска на устройстве, и занимают достаточно много времени. Если бы вы их запускали как юнит-тесты, при каждом сохранении файла, вы могли бы уходить пить чай очень много раз за день, что плохо сказывается на здоровье и скорости разработки.
Мы разобрались, какие бывают тесты, как они выглядят изнутри. Давайте посмотрим, какие они бывают плохие.
Есть такая вещь, как запахи тестов.
Какая польза от теста, если он сообщил, что есть ошибка, но непонятно, как она произошла? Какая польза от теста, который то работает, то не работает? и т. д. Это все может затруднять поддержку кода, ведь дурно пахнущие тесты не вызывают желания к ним прикасаться.
Какими должны быть тесты, чтобы они были признаны хорошими.
Во-первых, повторяющийся код должен быть выделен из тестов. Если вы раз за разом повторяете одни и те же операции, то нет смысла держать их в каждом экземпляре теста. Во-первых, это затруднит ваше понимание, ваши тесты неожиданно превратятся в гигантские комки из повторяющегося кода, у вас появляется копипаста — фу.
Также ваш код не должен проверять сразу все подряд. Желательно разделять код так, чтобы каждый тест проверял одну конкретную вещь. Не надо делать тест, который называется «самый сложный ест» или «полноценный тест», который проверяет все начиная с того, как у вас сохраняются файлы, заканчивая тем, как они шифруются, если на небе полная луна.
Чем меньше ваши тесты, тем проще они будут для понимания. Суть теста также должна быть понятна из его кода. Очень сложно искать причину ошибки, почему упал тест, когда он представляет собой нагромождение странных вещей, когда у вас происходит там много непонятных операций, которые вроде бы связаны с тестом, а может, и не связаны. Чуть позже мы рассмотрим один из примеров такого теста.
Перейдем к главному постулату хорошего теста. Он должен быть воспроизводим. Тест для того и существует, что в определенном наборе данных условий, когда он запускается и проходит, значит, все работает ожидаемым образом.
Если ваш тест будет зависеть от того, какое сегодня число или температура на улице — серьезно, я видел такие тесты, — то работать, то не работать — он не будет давать вам никакой уверенности. Мой код работает? Кажется, да. Тест красный. Не работает. Печаль. Стоп, снова зеленый.
Тест должен иметь воспроизводимый результат. Он не должен быть хрупким, он не должен ломаться от внешних независимых условий.
Рассмотрим три основных запаха тестов, которые встречаются мне очень часто, как и моим коллегам, которые заставляют нас порой то вырывать волосы из головы, то пускаться в пространные объяснения.
Во-первых, условия в тестах. Казалось бы, совершенно нормальный тест. Есть действие, проверка, что файл существует, а если вернулась ошибка — assertThatNoFileDownloaded.
Что же в этом плохого? Это кажется безобидным, но задумайтесь, знаете ли вы, как пройдет тест в определенный момент? Как он себя поведет, если его запустить два раза подряд? Условия в тестах являются избыточными по той причине, что ваш тест — это уже проверка какого-то условия. Ваш тест должен идти сверху вниз, желательно единственным возможным путем, чтобы когда он поломается, вы точно понимали, что такая последовательность шагов привела к тому, что мой тест поломался.
Когда в вашем тесте неожиданно появляется условие, вам неожиданно приходится держать в уме еще одну переменную. «Погоди, сегодня среда, 2017 год, наверное, он пойдет по этому пути, значит, он поломался здесь». А что будет, если придет другой код ошибки? Что тут вообще проверяется?
Правильно это должно выглядеть примерно так.
Мы все так же проверяем два конкретных случая, когда приходит ошибка от сервера и когда приходит нормально ответ. В то же время это выделено в два коротеньких теста, которые проверяют каждый конкретный случай. У них есть отдельные имена, описывающие, что в них проверяется. Гораздо проще для понимания, гораздо быстрее прогонять, и когда вы почините ваш баг, вы сможете запустить всего лишь один из этих тестов, не заботясь о том, что, а вдруг он не заработает. Это будет печально.
Другой чуть менее безобидный запах, но очень милый сердцу любого программиста, это циклы в коде. Вы скажете, что такого-то? Это циклы, я просто хотел меньше копипасты сделать. У меня же есть массив, в нем несколько элементов. Если я буду писать ассерт на все это, это же много места, много времени занимает.
На самом деле, не все так страшно, как кажется. Но вдумайтесь: у вас есть for, он выполняет какую-то проверку. Неожиданно на втором элементе проверка падает. Ага, все нормально, тест выполнил свою функцию и обнаружил ошибку. Но можете ли вы быть уверены, что все оставшиеся элементы, до которых for не дошел, также не вызовут ошибки?
Вы починили эту ошибку, запускаете тест, он снова проходит — снова падает. И так вам придется сидеть и раз за разом запускать этот тест, ожидать, когда он наконец дойдет до конца массива.
Более правильно будет использовать специальные инструменты, которые позволят вам декларативно написать примерно то же самое, но более понятным образом, а главное, с более правильным пониманием того, что происходит.
На этом примере for заменен на конструкцию из упоминавшегося пакета Hamcrest, которая читается примерно так: проверьте, что все имена в этом массиве соответствуют установленным пакетам в системе. Проверка проходит по всем элементам, выполняется для каждого из них, и в конце вы видите, что конкретно эти элементы вызвали false. Вы разом исправляете, разом перезапускаете — все работает. Согласитесь, гораздо проще, приятнее, а главное — ласкает глаз.
Самое страшное и непотребное — «толстые» тесты.
Это тесты, которые содержат в себе много всего. Когда человек решил пойти по пути наибольшей наглядности, раскрыл все операции теста и запихнул их в одну функцию.
Давайте навскидку, что тут происходит с первого взгляда?
У нас есть класс конвейерной ленты, на которую загружаются посылки с определенными номерами. После того, как мы загрузили, что-то пишется в лог. После мы проверяем, что после запуска функции обработки посылок на ленте остаются посылки с номером меньше 10. Да, это не так очевидно, просто потому что ваш глаз замыливается и проскальзывает — слишком много кода. Поэтому в подобных случаях необходимо выносить не имеющие определенного значения для конкретно этого теста код. В данном случае я бы вынес код создания конвейерной ленты, загрузки в него посылок. Потому что мы не это проверяем, мы проверяем, как они сортируются, а не как загружаются.
Поэтому я создал отдельную функцию под называнием «создать загруженный конвейер», которая берет на себя все функции по созданию объекта, загрузки посылок на него и т. д. Мой тест становится очень простым. У меня есть массив с номерами посылок, я создаю с ними конвейер, я запускаю и проверяю.
Здесь мы видим, что мы обрабатываем посылки, и что результатом этой обработки должно стать отсутствие обработки посылок с номерами 1 и 3, которые меньше, чем 10, как и было сказано в названии теста.
Мы разобрались с теорией тестов в вакууме. Давайте перейдем к специфике тестирования под Android.
Не все так просто. Android — это среда, которая вносит свои коррективы в ваш обычный процесс написания кода на Java. Одна из этих корректив — добавление еще двух категорий тестов, которые ортогональны нашей пирамиде, которую мы видели ранее.
Это деление на локальные тесты и инструментируемые тесты. Инструментируемые оставляют за собой право называть их таковыми.
Локальные тесты — это тесты, которые могут запускаться на компьютере самого разработчика. Как правило, это Java-код, который не взаимодействует ни с чем из Android, к примеру, код, который считает число Пи до миллионного знака после запятой. Он вполне может прогоняться как под Windows, так и под Linux, так и на Android. Гораздо проще и быстрее запустить его на вашей локальной машине, прямо из IDE, и оно будет работать.
Инструментируемые тесты вынуждены запускаться на каком-либо устройстве с Android. На эмуляторе, к примеру, на живом телефоне. Это связано с тем, что данные тесты дергают определенный API Android. Они не могут быть запущены на обычной ОС, просто потому что она не содержит ничего, с чем бы они могли взаимодействовать. Если такой тест запустить на обычном компьютере, не пометив его соответствующим образом, то выкинется исключение. Скажут, зачем вы дергаете методы ОС Android? Здесь их нет.
Проблема достаточно актуальна. Связана с тем, что инструментируемые тесты хорошо, вы можете запускать это на вашем телефоне,