С праздниками, друзья! Если вы не против научиться на каникулах чему-то новому, прочитайте лекцию Кирилла Борисова — разработчика систем авторизации Яндекса. Кирилл объясняет, как построить процесс тестирования 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? Здесь их нет.
Проблема достаточно актуальна. Связана с тем, что инструментируемые тесты хорошо, вы можете запускать это на вашем телефоне, на вашем эмуляторе, но они занимают гораздо больше времени, чем обычные локальные тесты. Это связано с тем, что в то время, как ваш тест запускается у вас на машине, эти тесты надо упаковать в отдельную APK, ее надо загрузить на ваше устройство, запустить там, потом собрать результат оттуда, перенести их на ваш компьютер снова, обработать и показать.
Если же таких тестов у вас будет много, то ваш test suite может прогоняться очень много времени. Это достаточно большая проблема, но она вносит дополнительные коррективы не только в это. Представьте, что у вас есть мифический Continuous Integration, что где-то у вас в компании есть сервер, который запускает эти тесты и проверяет их при каждом выпуске релиза, отправке кода в репозиторий и т. д.
Здесь появляется проблема. У меня где-то сервер, к примеру, на Windows или на Linux, как там будут запускать эти тесты? Хорошо, у разработчика есть телефон, подключили и запустили.
Надо что-то сделать с этой ситуацией. Есть различные подходы. Бывают очень экстремальные, к примеру, покупается 100 телефонов, подсоединяются к серверу и запускаются тесты на них. Я слышал, такое бывает, и возможно, вам когда-то придется с этим столкнуться. Но вполне может быть, что можно обойтись без этого.
Давайте попробуем посмотреть, как.
Одним из наиболее популярных решений являются моки, подражатели. Это чучела, имитация публичного интерфейса класса. Кто знаком с Python, у него есть мантра, что если оно ходит как утка, крякает как утка, то это, наверное, утка. Хотя это может быть селезень? Не знаю.
Если ваш класс и его публичный интерфейс соответствует каким-то ожиданиям функции, если готов принимать на вход класс определенного интерфейса, то ничто не мешает вам как бы сделать класс, который смотрит наружу теми же самыми функциями, но в то же время будет отдавать вместо реального кода и результатов вычисления реального кода заранее заданное значение. К примеру, вместо вычисления миллиона знаков после запятой в вашем тесте вы будете просто отдавать его, заранее вычисленное значение.
В нашем случае мы просто можем взять и подменить нужный нам класс из ОС Android, к примеру, PackageManager, один из самых частых примеров, на наше чучело, которое будет выдавать ожидаемый результат.
Они также обладают уникальным свойством — позволяют записывать вызовы методов класса. Иногда нам бывает интересно, а тот код, который просил на вход этот объект, он вообще им пользовался? Может, программист случайно забыл воспользоваться этим объектом, забыл убрать эту заглушку, возвращающую одно и то же значение и т. д. И случайно это значение совпало с тем, что мы ожидали. Честно, такое бывает, видел, страдал, исправлял.
В этом случае нам не помешало бы иногда проверять, что вызовы методов нашего класса вообще были сделаны.
В этом нам поможет популярный инструмент Mockito, он про моки. Он позволяет создавать «скелеты» классов вместо того, чтобы делать это вручную, он автоматизирует этот процесс. Просто даете ему некий класс, он возвращает некий другой класс. Усиленный, добавленный, возможно, более хитрый. Эта библиотека позволяет вам задавать ответы вызовов отдельных функций класса. К примеру, вы хотите сказать, что если у меня функция А, я ее вызываю — тогда отдавай 42. Если функция Б — отдавай null. и т. д.
Также она позволяет проверять факт вызова метода А и т. д.
Как обычно, все это имеет цену. У вас увеличивается сложность кода.
Посмотрим, как это выглядит на самом деле. Есть простейший пример простейшего теста. Есть некий абстрактный класс Restaurant, в котором есть два метода — getFreeSeats и getOccupiedSeats. Эти методы возвращают количество свободных мест и занятых мест.
По мере того, как код этого класса из внешних источников берет количество этих мест, к примеру, считывает их issued preferences, куда их записало какое-то другое приложение, к примеру, Airbnb и т. д. Если бы мы тестировали это обычным образом, нам потребовалось запускать это на телефоне, чтобы он мог получить в этих методах доступ к этим данным и т. д. Но нам неважно, откуда он получает эти данные. Нам важно, что тот код, который взаимодействует с классом Restaurant, это класс-информер, он просто получил ожидаемые нами данные, чтобы мы потом могли проверить, что результаты обработки этих данных соответствуют нашим ожиданиям.
Мы создаем некий мок, и говорим, что когда вызывается метод getFreeSeats — возвращай 42. Когда вызывается getOccupiedSeats — возвращай 56. И затем проверяем. Когда нашу классу-информеру скармливается это «чучело», когда мы вызываем определенный метод класса-информера, который и тестируем, мы смотрим, что результатом выполнения этой функции является строчка, где действительно содержатся наши числа — 42 и 56. На случай, если программист поленился, торопился или не подумал, мы проверяем, действительно ли вызывались эти методы.
С помощью функции verify мы проверяем, что они вызывались. Пример упрощенный, но можно также проверять, что эти методы были вызваны с ожидаемыми вами значениями, к примеру, если они принимают какие-то параметры.
Более приближенным к реальности случаем является такой простейший класс YandesPackageCounter, который считает, сколько приложений Яндекса на этом устройстве. Делает это очень изощренной эвристикой — он проверят, что в названии пакета есть слово “yandex”. Кстати, думаю, в обычной ситуации этого хватает. Этот класс подойдет для наших целей, он взят из моей маленькой программки. Этот класс обращается к методам класса PackageManager. Он ожидает его на входе, и в обычной ситуации мы должны были бы запустить его на устройстве, потому что PackageManager под виндой нет.
В нашем случае мы поступаем хитрее. Мы делаем мок этого класса PackageManager.
Мы для начала делаем результат выполнения этой функции. Если у нас вызывают получение списка пакетов, мы же должны что-то вернуть. Мы подготавливаем некоторое чучело класса ApplicationInfo, указываем там имена наших пакетов, которые хранятся где-то в массиве, и затем в тесте говорим, что делаешь мок из PackageManager, и просишь вернуть, когда его позовут, определенный массив.
Здесь же мы проверяем, что возвращаем это только тогда, когда запрашивают метод getInstalledApplications с тегом GET_META_DATA.
Тест дальше ничем особо не отличается от того, что мы видели ранее. Вызывается YandexPackageCounter, ему передается чучело, и он ничего не замечает, считает, что получил список пакетов, срабатывает сложнейшая эвристика, и мы получаем результат. Два пакета Яндекса в системе.
На всякий случай проверяем, что список пакетов вообще запрашивался. А то вдруг. Программисты.
ОС Android обширна, ее SDK обширна, существует множество интерфейсов, классов, которые можно оттуда позвать. Если будете руками готовить каждый раз чучело для каждого такого класса, будете готовить моки, рано или поздно у вас пропадет желание жить, писать тесты, и вообще уйдете в менеджеры или художники.
На этот случай собратья-программисты не бросили вас в беде и подготовили интересный инструмент с названием Robolectric. Он являет собой хорошую имитацию API Android. По сути, это уже целый лес уже готовых моков или не моков, теней существующих классов из SDK Android, которые пытаются делать вид минимально необходимым образом, что это у вас ОС Android, и она даже относительно работает.
Построено таким образом, что когда вы, к примеру, вызываете функции установки пакета в систему, Robolectric, конечно, не поставит его никуда, но добавит себе запись, что он тут пытался поставить пакет с таким именем.
Когда вы затем попросите у выданного Robolectric чучела PackageManager список пакетов, он посмотрит, что он ставил такой-то пакет, и отдаст его.
Он спасает от большого количества рутины и позволяет легко переводить инструментируемые тесты в категорию локальных тестов. Потому что сможете запускать их у себя на компьютере.
Но как обычно, ничего не бывает просто так. Обратная сторона, что инициализация всего этого великолепия теней с заготовленными SDK занимает достаточно много времени. На инициализацию одного такого теста, обернутого в Robolectric, может уходить до 100 мс и больше. Надо его применять с умом, это не серебряная пуля, а инструмент, который позволяет выпутаться из определенных ситуаций, сложных для решения.
Также он требует достаточно неочевидной настройки. Вы поймете это, если решите углубиться, это сложно передать в масштабах одной лекции.
Рассмотрим простейший случай.
Все тот же класс, но теперь он обернут в класс RobolectricTestRunner. Он инициализирует всё, подготавливает внутреннюю структуру, моки, подменяет эти вызовы. Когда вы в своем коде уже просто запускаете RuntimeEnvironment.application.getPackageManager, он возвращает вам объект ShadowPackageManager, ShadowPM. Под этим крутым названием скрывается все тот же класс мока PackageManager, но обернутый чуть более удобным набором функций, позволяющим вам, к примеру, вручную создавать пакет application info и т. д.
Мы также подготавливаем список пакетов, и наш тест ничем не отличается от того, как бы он выглядел, если бы был простым инструментируемым тестом. Мы получаем PackageManager от нашего Environment, отдаем его объекту counter, в список пакетов и т. д. Единственное, здесь не проверяется факт вызова функции получения списка пакетов, то можно сказать, это мы оставим как домашнее задание.
Следующей актуальной проблемой ОС Android является уже упоминавшееся тестирование UI приложений. Какие есть инструменты?
Во-первых, тесты, связанные с UI, очень хрупкие. Если раньше я знаю, что я вызываю функцию, есть лог, я на него повешусь, подожду, когда она завершится, и все будет хорошо. Здесь у вас этого нет, вы обычный пользователь, вы нажали на кнопку и у вас появился спиннер, к примеру, и ваш тест думает, что сейчас появится поле ввода, я введу туда данные. А поле ввода не появилось, потому что телефон ваш или эмулятор решил, что давайте я сейчас освобожу память или сделаю переиндексацию всех файлов. И поле ввода появляется буквально на секунду позже. А ваш тест не ожидал этого — ага, поле ввода не появилось, я падаю, хозяин, ошибка! В результате вроде бы код работает, и тест упал, и что делать?
Это связано с тем, что операции с UI не асинхронны. Вы не можете просто так положиться на то, что что-то появится с определенным интервалом или что-то работает за строго определенный промежуток времени. В не последнюю очередь надо понять, как я вообще буду имитировать действия пользователя, ведь я тыкаю руками, а код же не человек.
Попытку решить все это предпринимает инструмент Espresso. Знаком не понаслышке тем, кто решил вступить на эту скользкую дорожку. Он входит в состав Android Testing Support Library, представляет API для взаимодействия с интерфейсом. Он скрывает всю уродливую кухню ожидания, асинхронности и прочего под капотом достаточно простых функций. Главное, что он также еще очень хорошо расширяется. Поскольку наш UI не ограничивается простейшими кнопочками и полями ввода, к примеру, ваше приложение может посылать интенты или открывать WebView. К этому инструменту существует множество дополнений, расширяющих его функционал. Мы здесь рассмотрим только самые базовые применения. Но если вы решите воспользоваться им — знайте, впереди много приключений.
А мы займемся скукотой.
Есть простейшее приложение, старый друг по проверке силы пароля, все та же функция isStrongPassword, только теперь мы не можем вызвать ее напрямую и проверить true, false, значения и прочее. У нас есть интерфейс. Мы хотим сделать тест, когда мы вводим значение пароля, которое будет признано слабым, то отобразится это на UI. Нажимаем на кнопочку, и появляется слово «Слабый» в ужасном красном цвете. Цвет пока тестировать не будем, это вопрос вкуса.
Как выглядит тест подобного рода? Во-первых, есть множество дополнительных импортов. Мы статически импортируем из пакета Espresso дополнительные инструменты, которые нам пригодятся. Статические импорты здесь для того, чтобы не озабочиваться указанием полного пути каждый раз, и поскольку нам понадобится очень много всего из этого пакета.
Основная мякотка здесь. Это класс AndroidJUnit4, который обернул класс CheckerUITest. Он представляет из себя задание базовых констант, что мы ожидаем увидеть в нашем приложения. Приношу извинения, здесь они не вынесены в файл string.xml, как полагается по всем канонам, для нашей цели это подойдет.
Дальше одна из самых основных частей — аннотация Rule и некой переменной mActivityRule. Rule — это инструменты тестового фреймворка JUnit, которые предназначены для того, чтобы облегчить вашу жизнь. Как упоминалось ранее, задачи подготовки окружения, освобождения ресурсов могут быть достаточно тягомотные. ActivityRule в частности перед на себя всю нудную функцию по запуску вашей Activity, отображению ее на экране, потом удалению, освобождению ресурсов, связанных с ней. Зачем вам о таком думать? Вы просто хотите тестировать.
У нас есть достаточно простой тест — badPasswordDisplayed. Он гордо нам позволяет описать последовательность действий. Во-первых, говорим, что есть некая вьюшка, у которой мы знаем R.id.password. Пожалуйста, сделай с ней следующий набор действий. А дальше перечисление специальными операторами, что же вы хотите сотворить. В частности вы хотите ввести текст слабого пароля, и после этого закрыть вашу экранную клавиатуру, потому что она мешает, к примеру, нажать на кнопку. Не очень продуманный интерфейс, согласитесь.
После чего вы выполняете действие. Нажимаете на кнопочку «проверить пароль». У нее id.check_strength, и вы говорите — выполни метод click. Дальше идет основной блок проверки. Вы говорите, что есть такая вьюшка R.id.result, проверь, пожалуйста, что у нее задан текст, который соответствует нашим ожиданиям. И используем матчер из упоминавшегося Hamcrest. Здесь мы проверяем, что matches(withText(WEAK_STATUS)). Если там внезапно сильный, то этот тест провалится.
Он позволяет достаточно емко, кратко, а главное удобно описать базовое взаимодействие с интерфейсом. Никаких «найди мне в этом интерфейсе элемент с таким-то именем, а если не нашел или он равен null, то то-то…»
И главное, что Espresso при этом берет на себя все процесс ожидания, сам проверит, появился ли этот элемент или надо чуть подождать, и т. д.
Если же у вас нет времени прямо сейчас изучать Espresso, если надо прямо сейчас сделать UI тест, чтобы проверить самый базовый функционал, то для самых ленивых, ловких и умелых есть инструмент Espresso Test Recorder.
Относительный новичок в составе нашего инструментария, он входит в состав Android Studio и позволяет вам записать взаимодействие с вашим приложением. В то время, как раньше вы скрупулезно прописывали его сами, здесь вы просто запускаете эмулятор, протыкиваете мышкой, еще что-то, и оно тут пишет за вас. Он находится в меню Run рядом с любимыми пунктами Run и Debug, и по его запуску появится примерно такое окошко.
В нем вы можете видеть результат того, как я понатыкал в поле ввода, ввел в него определенные значения, нажал на кнопку, и снизу assert. Какой же код будет сгенерирован нашим помощником-роботом?
Этот тест мы не будем разбирать вот по какой причине:
Как вы можете понять, автоматизированная генерация тестов — это штука хорошая, но на выходе вы получаете нечто сложно поддерживаемое, просто потому что бездушная машина не обладает тем талантом писать емкий, выразительный код и старается перестраховаться по всем параметрам, написать кучу дополнительного кода проверок и т. д.
Такой код подойдет, если вам нужно что-то протестировать прямо здесь и сейчас, но использовать как стратегию генерации тестов, поддерживаемых в долгосрочной перспективе, не стоит. Вы сами выстрелите себе в ногу и посыплете ее солью. Не надо делать так.
В то же время он остается хорошим инструментом в вашей коробке. Если вам неожиданно что-то пригодится, скорее всего, это будет оно.
Как же глубока эта кроличья нора? Еще один вопрос всплывает, когда мы задаемся целью протестировать код на Android. Но у меня же есть сервис! С UI еще понятно, понятно с юнит-тестом. А как протестировать, что у меня сервис работает нормально? Ведь нам нужно позаботиться, чтобы запустить этот сервис, нужно оставить его, к нему присоединиться, а если к нему прибиндиться не получится и т. д. Все это требует инструментируемых тестов.
К сожалению, с инструментируемостью этого вам вряд ли смогут помочь обычные средства, возможно, сможет помочь Robolectric, но это требует определенного погружения в эту глубокую среду. А с первыми двумя пунктами поможет ServiceTestRule. Это также батарейка, входящая в состав стандартной Android Testing Support Library, он берет на себя всю черную кухню по запуску теста, остановке теста, получения соединения с тестом. Но к сожалению, он также ограничен в своих возможностях, и если ваш сервис — это не обычный, играющий музыку на фоне сервис, а какой-то интент-сервис, который реагирует на бродкасты и так далее, здесь он уже не поможет, придется выкручиваться самим.
Как этот более удобный инструмент поможет вам хоть немного скрасить вашу нелегкую жизнь?
Есть пример теста сервиса, взятый из странички, посвященной ему в Android Testing Support Library документации. Он тестирует сервис, который получает некое число и возвращает на выходе другое число. Это очень частый паттерн написания сервисов, когда вы получаете что-то и отдаете что-то. Здесь и будет протестирован.
Для начала наш старый друг Rule, в нашем случае ServiceTestRule, который инстанцируется и дальше говорит, что все, не волнуйся, я все беру на себя.
Уже в нашем тесте мы просто берем и как бы создаем Intent, который мы хотим использовать для запуска нашего сервиса. Затем помещаем туда какой-то определенный аргумент, и получаем ссылку на наш сервис.
Когда мы вызываем функцию getService, мы получаем экземпляр нашего сервиса, который уже подсоединился, и вызываем у него напрямую метод, проверяем с помощью упоминавшихся матчеров, что ответом стало любое случайное значение, но главное, что класса integer.
Пять строчек, и уже есть протестированный сервис. Другие случаи с тестированием сервиса будут более вовлеченными, оставим это для самостоятельного изучения.
Разобрались со спецификой тестов под Android. Узнали, как тестировать и что тестировать. Давайте посмотрим на более насущную важную вещь, как вообще жить с тестами? Как их писать, когда писать? Как их внедрить в ваш обычный процесс разработки.
Ответом на это, во-первых, становится обычный Continuous Integration. Это не просто вещь, программа или сервер, это подход к разработке вашего ПО. В нашем случае мы выделим один аспект.
Вы выделяете какой-то определенный сервер, на который ставите ПО, к примеру, Jenkins, TeamCity — неважно, и настраиваете таким образом, чтобы оно проверяло, что как только у вас приходит какой-то pull request в ваш github или еще куда-то, чтобы он прогонял тесты и проверял, что все проходит, тесты работают, можно мержить, разрешаю.
Это сделано для того, чтобы нерадивый программист, который торопится выпить пива с друзьями, не поддался искушению просто отправить код, запихнуть в ветку мастера, а дальше живи как живет. Наверное, оно заработает.
Continuous Integration — это страшно, что не позволяет испортить ваш код таким очевидным и нелогичным образом. Он просто не даст его замержить, если тесты не прошли. Конечно, мы все понимаем, что бывают ситуации, когда надо замержить, даже если тесты не проходят, просто мердж, все горит, нас взломали, все пропали. В этих случаях обычно существуют красные кнопки, которые позволяют вашему Continuous Integration серверу махнуть рукой и сказать, что ладно, так и быть, сегодня мержи, но дальше — ни-ни.
Все это необходимо интегрировать в ваши основные процессы разработки. Если этот Continuous Integration сервер будет просто стоять и ничего не делать, пусть и полностью настроенный, то пользы от этого никакой не будет. Ваши программисты, то есть вы сами в первую очередь, должны привыкнуть к мысли, что халява тут больше не пройдет, придется думать, делать и отвечать за свой код.
В этом вам также может помочь один из подходов разработки, притча во языцех всех, кто любит разрабатывать с тестами, — Test Driven Development. Это подход, который является одним из самых популярных, но в то же время не единственным доминирующим. Он также используется некоторыми командами в Яндексе с очень интересными результатами, я бы даже сказал, положительными результатами. Он заключается в очень интересной процедуре разработки. В то время, как раньше вы брали идею и начинали писать код, разовьем его, и потом напишем тесты. Честно, потом, обещаю. Здесь вы сначала пишите тесты. Это очень странно выглядит, ведь у нас нет кода, как мы тестировать будем? Что тестировать? В этом изюминка этого подхода. Сначала с помощью тестов вы описываете, что же вы ожидаете от вашего кода, как бы пишите, что есть функция упоминавшаяся isStrongPassword, она должна в такой-то ситуации с такой-то строчкой возвращать вот это, здесь возвращать вот это. Потом вы запускаете эти тесты, которые тут же проваливаются, потому что кода еще нет, чего их запускать? И понимаем, что кода нет, начинаем его писать. Написали достаточно кода, чтобы прошел один тест, потом написали, чтобы прошел второй тест. Неожиданно у вас начинают проходить все тесты. Отлично.
Вы совершаете круг и снова возвращаетесь к написанию тестов. Ведь у вас еще есть фичи в вашем приложении. Вы пишите тесты для них, пишете для них код, тесты проходят. Пишите еще тесты, еще код… Из этого цикла выхода особого и нет. Но на самом деле выход есть. В какой-то момент вы можете сказать, что всё. Здесь наш процесс разработки останавливается, баста, делаем релиз.
Это связано с тем, что очень легко можно увлечься и начать писать тесты ради того, чтобы написать под них код, потом опять тесты… Остановите меня когда-нибудь.
Главное выбрать для себя масштаб покрытия этим тестированием, определенный набор фич, которые будете покрывать тестами, и уже затем итеративно по этому циклу нарабатывать ровно столько, сколько вы себе отвели.
После чего вы занимаетесь другими вещами, делаете релиз, тестируете ручным образом и т. д. И уже некий следующий спринт, процесс разработки и прочее. Начинаете снова: написали тесты, тесты не проходят, код, тесты, код, тесты… И пока ваше приложение не станет максимально близким к абстрактному идеалу.
Хочу расстроить. В то время как вы, разработчики, настоящие герои своего труда, от которых зависит это приложение, без которых ничего этого бы не было, к сожалению, вы одни не справитесь с написанием тестов нормальным образом. Можете сказать: как так? Я же знаю, как работает приложение, я же его написал.
Кроме вас в команде, скорее всего, есть такие люди как менеджеры и ручные тестировщики? Зачем они нам? Не хочу вас расстраивать, но зачастую менеджеры лучше знают, что же нужно пользователю от вашего приложения, чем вам самим. Поэтому они даже лучше представляют, как работает ваше приложение. Частая проблема в разработке ПО, не только на мобилах, в десктопе, вебе и так далее, в том, что программист думает, что я-то знаю, как пользоваться этим приложением. Он пользуется им определенным образом, проверяет, что оно вот так работает. Потом приходит глупый пользователь и неожиданно вместо того, чтобы нажать на логичную кнопку «войти» с использованием OAuth токена, нажимает на «вспомнить пароль», и приложение ломается. Ну какой вспомнить пароль? Вы еще не залогинились. Программисту такое не придет в голову. Менеджер это знает, он ставит задачи, он общается с пользователями, он имеет перед собой картину требований. Он поможет вам написать некий сценарий тестирования, который, возможно, стоит покрыть. Необязательно в первую очередь, в своем ритме, но стоит покрыть.
Ручной тестировщик, который, казалось бы, вообще должен быть заменен бездушным роботом, который прогоняет ваши автотесты, это человек зачастую со специальным образованием, особым складом ума. В то время как ваши автотесты проверяют, что уже найденные ошибки были исправлены и что ваш код работает определенным образом, ручные тестировщики очень хорошо умеют делать то, что вам зачастую недоступно. Они умеют ломать ваше приложение.
Я помню, у нас прекрасная тестировщица, когда я отправляю релиз на тестинг, ухожу за чаем, возвращаюсь — 14 открытых тикетов, потому что… Я тут просто зашла, нажала и все сломалось. Но как? Она могла это объяснить, но зачем? За это время она нашла еще 4 бага. Замечательная женщина, надеюсь, у нее все хорошо.
Именно такие люди находят за вас новые ошибки, чтобы вы могли покрыть их автотестами — и чтобы затем вы за счет этого облегчили им труд. Когда они не тратят силы на то, чтобы перепроверять старые ошибки, они еще активнее ищут способы сломать ваше приложение и найти побольше новых ошибок. Именно в этой связке, тандеме с такими людьми вы можете наиболее эффективно написать тесты, покрыть ваше приложение, сделать его красивым, хорошим, удобным и, самое главное, надежным. С помощью тестов.
Когда у вас есть тесты, вы можете быть уверены, что этот кусок кода за ночь не начал вести себя по-другому. Отлично, я дальше работаю с ним ожидаемым образом.
Чем больше времени мы можем потратить на добавление новых фич, которые приносят деньги, тем больше мы их получим. В теории. Экономика работает примерно так, я знаю.
Чем меньше времени мы тратим на поиск ошибок, тем больше времени остается на гораздо более ценные вещи, чем деньги, — на свободное время, хобби, увлечения, родных и близких, прогулки и т. д. Согласитесь, стоит потратить немного времени на тесты, чтобы получить столько добра в ответ.
Как только вы начнете писать тесты, вы неожиданно поймете, что по мере роста ваших навыков это становится делать все легче, тесты сами выходят у вас из-под руки. Вы написали код, выпили чаю, наделали тестов. Написали код, не успели оглянуться, а вы уже написали к нему тесты. Как только этот процесс запускается, его уже становится сложно остановить. Ведь вы попадаете в отрицательную обратную связь. У нас это работает. Будем надеяться, это поможет и вам.
— Прежде чем двинуться вперед, давайте устроим небольшой соцопрос. Кто из вас знает, что такое тесты? Кто пишет тесты? А кто знает, зачем он пишет тесты? Примерно одни и те же люди.
Тем, кто с тестами знаком понаслышке и не прикладывал к ним руки, хочу представить пример простейшего теста.
Как видите, ничего страшного. Это самый простой тест, который проверяет, что законы математики еще не изменились и 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? Здесь их нет.
Проблема достаточно актуальна. Связана с тем, что инструментируемые тесты хорошо, вы можете запускать это на вашем телефоне, на вашем эмуляторе, но они занимают гораздо больше времени, чем обычные локальные тесты. Это связано с тем, что в то время, как ваш тест запускается у вас на машине, эти тесты надо упаковать в отдельную APK, ее надо загрузить на ваше устройство, запустить там, потом собрать результат оттуда, перенести их на ваш компьютер снова, обработать и показать.
Если же таких тестов у вас будет много, то ваш test suite может прогоняться очень много времени. Это достаточно большая проблема, но она вносит дополнительные коррективы не только в это. Представьте, что у вас есть мифический Continuous Integration, что где-то у вас в компании есть сервер, который запускает эти тесты и проверяет их при каждом выпуске релиза, отправке кода в репозиторий и т. д.
Здесь появляется проблема. У меня где-то сервер, к примеру, на Windows или на Linux, как там будут запускать эти тесты? Хорошо, у разработчика есть телефон, подключили и запустили.
Надо что-то сделать с этой ситуацией. Есть различные подходы. Бывают очень экстремальные, к примеру, покупается 100 телефонов, подсоединяются к серверу и запускаются тесты на них. Я слышал, такое бывает, и возможно, вам когда-то придется с этим столкнуться. Но вполне может быть, что можно обойтись без этого.
Давайте попробуем посмотреть, как.
Одним из наиболее популярных решений являются моки, подражатели. Это чучела, имитация публичного интерфейса класса. Кто знаком с Python, у него есть мантра, что если оно ходит как утка, крякает как утка, то это, наверное, утка. Хотя это может быть селезень? Не знаю.
Если ваш класс и его публичный интерфейс соответствует каким-то ожиданиям функции, если готов принимать на вход класс определенного интерфейса, то ничто не мешает вам как бы сделать класс, который смотрит наружу теми же самыми функциями, но в то же время будет отдавать вместо реального кода и результатов вычисления реального кода заранее заданное значение. К примеру, вместо вычисления миллиона знаков после запятой в вашем тесте вы будете просто отдавать его, заранее вычисленное значение.
В нашем случае мы просто можем взять и подменить нужный нам класс из ОС Android, к примеру, PackageManager, один из самых частых примеров, на наше чучело, которое будет выдавать ожидаемый результат.
Они также обладают уникальным свойством — позволяют записывать вызовы методов класса. Иногда нам бывает интересно, а тот код, который просил на вход этот объект, он вообще им пользовался? Может, программист случайно забыл воспользоваться этим объектом, забыл убрать эту заглушку, возвращающую одно и то же значение и т. д. И случайно это значение совпало с тем, что мы ожидали. Честно, такое бывает, видел, страдал, исправлял.
В этом случае нам не помешало бы иногда проверять, что вызовы методов нашего класса вообще были сделаны.
В этом нам поможет популярный инструмент Mockito, он про моки. Он позволяет создавать «скелеты» классов вместо того, чтобы делать это вручную, он автоматизирует этот процесс. Просто даете ему некий класс, он возвращает некий другой класс. Усиленный, добавленный, возможно, более хитрый. Эта библиотека позволяет вам задавать ответы вызовов отдельных функций класса. К примеру, вы хотите сказать, что если у меня функция А, я ее вызываю — тогда отдавай 42. Если функция Б — отдавай null. и т. д.
Также она позволяет проверять факт вызова метода А и т. д.
Как обычно, все это имеет цену. У вас увеличивается сложность кода.
Посмотрим, как это выглядит на самом деле. Есть простейший пример простейшего теста. Есть некий абстрактный класс Restaurant, в котором есть два метода — getFreeSeats и getOccupiedSeats. Эти методы возвращают количество свободных мест и занятых мест.
По мере того, как код этого класса из внешних источников берет количество этих мест, к примеру, считывает их issued preferences, куда их записало какое-то другое приложение, к примеру, Airbnb и т. д. Если бы мы тестировали это обычным образом, нам потребовалось запускать это на телефоне, чтобы он мог получить в этих методах доступ к этим данным и т. д. Но нам неважно, откуда он получает эти данные. Нам важно, что тот код, который взаимодействует с классом Restaurant, это класс-информер, он просто получил ожидаемые нами данные, чтобы мы потом могли проверить, что результаты обработки этих данных соответствуют нашим ожиданиям.
Мы создаем некий мок, и говорим, что когда вызывается метод getFreeSeats — возвращай 42. Когда вызывается getOccupiedSeats — возвращай 56. И затем проверяем. Когда нашу классу-информеру скармливается это «чучело», когда мы вызываем определенный метод класса-информера, который и тестируем, мы смотрим, что результатом выполнения этой функции является строчка, где действительно содержатся наши числа — 42 и 56. На случай, если программист поленился, торопился или не подумал, мы проверяем, действительно ли вызывались эти методы.
С помощью функции verify мы проверяем, что они вызывались. Пример упрощенный, но можно также проверять, что эти методы были вызваны с ожидаемыми вами значениями, к примеру, если они принимают какие-то параметры.
Более приближенным к реальности случаем является такой простейший класс YandesPackageCounter, который считает, сколько приложений Яндекса на этом устройстве. Делает это очень изощренной эвристикой — он проверят, что в названии пакета есть слово “yandex”. Кстати, думаю, в обычной ситуации этого хватает. Этот класс подойдет для наших целей, он взят из моей маленькой программки. Этот класс обращается к методам класса PackageManager. Он ожидает его на входе, и в обычной ситуации мы должны были бы запустить его на устройстве, потому что PackageManager под виндой нет.
В нашем случае мы поступаем хитрее. Мы делаем мок этого класса PackageManager.
Мы для начала делаем результат выполнения этой функции. Если у нас вызывают получение списка пакетов, мы же должны что-то вернуть. Мы подготавливаем некоторое чучело класса ApplicationInfo, указываем там имена наших пакетов, которые хранятся где-то в массиве, и затем в тесте говорим, что делаешь мок из PackageManager, и просишь вернуть, когда его позовут, определенный массив.
Здесь же мы проверяем, что возвращаем это только тогда, когда запрашивают метод getInstalledApplications с тегом GET_META_DATA.
Тест дальше ничем особо не отличается от того, что мы видели ранее. Вызывается YandexPackageCounter, ему передается чучело, и он ничего не замечает, считает, что получил список пакетов, срабатывает сложнейшая эвристика, и мы получаем результат. Два пакета Яндекса в системе.
На всякий случай проверяем, что список пакетов вообще запрашивался. А то вдруг. Программисты.
ОС Android обширна, ее SDK обширна, существует множество интерфейсов, классов, которые можно оттуда позвать. Если будете руками готовить каждый раз чучело для каждого такого класса, будете готовить моки, рано или поздно у вас пропадет желание жить, писать тесты, и вообще уйдете в менеджеры или художники.
На этот случай собратья-программисты не бросили вас в беде и подготовили интересный инструмент с названием Robolectric. Он являет собой хорошую имитацию API Android. По сути, это уже целый лес уже готовых моков или не моков, теней существующих классов из SDK Android, которые пытаются делать вид минимально необходимым образом, что это у вас ОС Android, и она даже относительно работает.
Построено таким образом, что когда вы, к примеру, вызываете функции установки пакета в систему, Robolectric, конечно, не поставит его никуда, но добавит себе запись, что он тут пытался поставить пакет с таким именем.
Когда вы затем попросите у выданного Robolectric чучела PackageManager список пакетов, он посмотрит, что он ставил такой-то пакет, и отдаст его.
Он спасает от большого количества рутины и позволяет легко переводить инструментируемые тесты в категорию локальных тестов. Потому что сможете запускать их у себя на компьютере.
Но как обычно, ничего не бывает просто так. Обратная сторона, что инициализация всего этого великолепия теней с заготовленными SDK занимает достаточно много времени. На инициализацию одного такого теста, обернутого в Robolectric, может уходить до 100 мс и больше. Надо его применять с умом, это не серебряная пуля, а инструмент, который позволяет выпутаться из определенных ситуаций, сложных для решения.
Также он требует достаточно неочевидной настройки. Вы поймете это, если решите углубиться, это сложно передать в масштабах одной лекции.
Рассмотрим простейший случай.
Все тот же класс, но теперь он обернут в класс RobolectricTestRunner. Он инициализирует всё, подготавливает внутреннюю структуру, моки, подменяет эти вызовы. Когда вы в своем коде уже просто запускаете RuntimeEnvironment.application.getPackageManager, он возвращает вам объект ShadowPackageManager, ShadowPM. Под этим крутым названием скрывается все тот же класс мока PackageManager, но обернутый чуть более удобным набором функций, позволяющим вам, к примеру, вручную создавать пакет application info и т. д.
Мы также подготавливаем список пакетов, и наш тест ничем не отличается от того, как бы он выглядел, если бы был простым инструментируемым тестом. Мы получаем PackageManager от нашего Environment, отдаем его объекту counter, в список пакетов и т. д. Единственное, здесь не проверяется факт вызова функции получения списка пакетов, то можно сказать, это мы оставим как домашнее задание.
Следующей актуальной проблемой ОС Android является уже упоминавшееся тестирование UI приложений. Какие есть инструменты?
Во-первых, тесты, связанные с UI, очень хрупкие. Если раньше я знаю, что я вызываю функцию, есть лог, я на него повешусь, подожду, когда она завершится, и все будет хорошо. Здесь у вас этого нет, вы обычный пользователь, вы нажали на кнопку и у вас появился спиннер, к примеру, и ваш тест думает, что сейчас появится поле ввода, я введу туда данные. А поле ввода не появилось, потому что телефон ваш или эмулятор решил, что давайте я сейчас освобожу память или сделаю переиндексацию всех файлов. И поле ввода появляется буквально на секунду позже. А ваш тест не ожидал этого — ага, поле ввода не появилось, я падаю, хозяин, ошибка! В результате вроде бы код работает, и тест упал, и что делать?
Это связано с тем, что операции с UI не асинхронны. Вы не можете просто так положиться на то, что что-то появится с определенным интервалом или что-то работает за строго определенный промежуток времени. В не последнюю очередь надо понять, как я вообще буду имитировать действия пользователя, ведь я тыкаю руками, а код же не человек.
Попытку решить все это предпринимает инструмент Espresso. Знаком не понаслышке тем, кто решил вступить на эту скользкую дорожку. Он входит в состав Android Testing Support Library, представляет API для взаимодействия с интерфейсом. Он скрывает всю уродливую кухню ожидания, асинхронности и прочего под капотом достаточно простых функций. Главное, что он также еще очень хорошо расширяется. Поскольку наш UI не ограничивается простейшими кнопочками и полями ввода, к примеру, ваше приложение может посылать интенты или открывать WebView. К этому инструменту существует множество дополнений, расширяющих его функционал. Мы здесь рассмотрим только самые базовые применения. Но если вы решите воспользоваться им — знайте, впереди много приключений.
А мы займемся скукотой.
Есть простейшее приложение, старый друг по проверке силы пароля, все та же функция isStrongPassword, только теперь мы не можем вызвать ее напрямую и проверить true, false, значения и прочее. У нас есть интерфейс. Мы хотим сделать тест, когда мы вводим значение пароля, которое будет признано слабым, то отобразится это на UI. Нажимаем на кнопочку, и появляется слово «Слабый» в ужасном красном цвете. Цвет пока тестировать не будем, это вопрос вкуса.
Как выглядит тест подобного рода? Во-первых, есть множество дополнительных импортов. Мы статически импортируем из пакета Espresso дополнительные инструменты, которые нам пригодятся. Статические импорты здесь для того, чтобы не озабочиваться указанием полного пути каждый раз, и поскольку нам понадобится очень много всего из этого пакета.
Основная мякотка здесь. Это класс AndroidJUnit4, который обернул класс CheckerUITest. Он представляет из себя задание базовых констант, что мы ожидаем увидеть в нашем приложения. Приношу извинения, здесь они не вынесены в файл string.xml, как полагается по всем канонам, для нашей цели это подойдет.
Дальше одна из самых основных частей — аннотация Rule и некой переменной mActivityRule. Rule — это инструменты тестового фреймворка JUnit, которые предназначены для того, чтобы облегчить вашу жизнь. Как упоминалось ранее, задачи подготовки окружения, освобождения ресурсов могут быть достаточно тягомотные. ActivityRule в частности перед на себя всю нудную функцию по запуску вашей Activity, отображению ее на экране, потом удалению, освобождению ресурсов, связанных с ней. Зачем вам о таком думать? Вы просто хотите тестировать.
У нас есть достаточно простой тест — badPasswordDisplayed. Он гордо нам позволяет описать последовательность действий. Во-первых, говорим, что есть некая вьюшка, у которой мы знаем R.id.password. Пожалуйста, сделай с ней следующий набор действий. А дальше перечисление специальными операторами, что же вы хотите сотворить. В частности вы хотите ввести текст слабого пароля, и после этого закрыть вашу экранную клавиатуру, потому что она мешает, к примеру, нажать на кнопку. Не очень продуманный интерфейс, согласитесь.
После чего вы выполняете действие. Нажимаете на кнопочку «проверить пароль». У нее id.check_strength, и вы говорите — выполни метод click. Дальше идет основной блок проверки. Вы говорите, что есть такая вьюшка R.id.result, проверь, пожалуйста, что у нее задан текст, который соответствует нашим ожиданиям. И используем матчер из упоминавшегося Hamcrest. Здесь мы проверяем, что matches(withText(WEAK_STATUS)). Если там внезапно сильный, то этот тест провалится.
Он позволяет достаточно емко, кратко, а главное удобно описать базовое взаимодействие с интерфейсом. Никаких «найди мне в этом интерфейсе элемент с таким-то именем, а если не нашел или он равен null, то то-то…»
И главное, что Espresso при этом берет на себя все процесс ожидания, сам проверит, появился ли этот элемент или надо чуть подождать, и т. д.
Если же у вас нет времени прямо сейчас изучать Espresso, если надо прямо сейчас сделать UI тест, чтобы проверить самый базовый функционал, то для самых ленивых, ловких и умелых есть инструмент Espresso Test Recorder.
Относительный новичок в составе нашего инструментария, он входит в состав Android Studio и позволяет вам записать взаимодействие с вашим приложением. В то время, как раньше вы скрупулезно прописывали его сами, здесь вы просто запускаете эмулятор, протыкиваете мышкой, еще что-то, и оно тут пишет за вас. Он находится в меню Run рядом с любимыми пунктами Run и Debug, и по его запуску появится примерно такое окошко.
В нем вы можете видеть результат того, как я понатыкал в поле ввода, ввел в него определенные значения, нажал на кнопку, и снизу assert. Какой же код будет сгенерирован нашим помощником-роботом?
Этот тест мы не будем разбирать вот по какой причине:
Как вы можете понять, автоматизированная генерация тестов — это штука хорошая, но на выходе вы получаете нечто сложно поддерживаемое, просто потому что бездушная машина не обладает тем талантом писать емкий, выразительный код и старается перестраховаться по всем параметрам, написать кучу дополнительного кода проверок и т. д.
Такой код подойдет, если вам нужно что-то протестировать прямо здесь и сейчас, но использовать как стратегию генерации тестов, поддерживаемых в долгосрочной перспективе, не стоит. Вы сами выстрелите себе в ногу и посыплете ее солью. Не надо делать так.
В то же время он остается хорошим инструментом в вашей коробке. Если вам неожиданно что-то пригодится, скорее всего, это будет оно.
Как же глубока эта кроличья нора? Еще один вопрос всплывает, когда мы задаемся целью протестировать код на Android. Но у меня же есть сервис! С UI еще понятно, понятно с юнит-тестом. А как протестировать, что у меня сервис работает нормально? Ведь нам нужно позаботиться, чтобы запустить этот сервис, нужно оставить его, к нему присоединиться, а если к нему прибиндиться не получится и т. д. Все это требует инструментируемых тестов.
К сожалению, с инструментируемостью этого вам вряд ли смогут помочь обычные средства, возможно, сможет помочь Robolectric, но это требует определенного погружения в эту глубокую среду. А с первыми двумя пунктами поможет ServiceTestRule. Это также батарейка, входящая в состав стандартной Android Testing Support Library, он берет на себя всю черную кухню по запуску теста, остановке теста, получения соединения с тестом. Но к сожалению, он также ограничен в своих возможностях, и если ваш сервис — это не обычный, играющий музыку на фоне сервис, а какой-то интент-сервис, который реагирует на бродкасты и так далее, здесь он уже не поможет, придется выкручиваться самим.
Как этот более удобный инструмент поможет вам хоть немного скрасить вашу нелегкую жизнь?
Есть пример теста сервиса, взятый из странички, посвященной ему в Android Testing Support Library документации. Он тестирует сервис, который получает некое число и возвращает на выходе другое число. Это очень частый паттерн написания сервисов, когда вы получаете что-то и отдаете что-то. Здесь и будет протестирован.
Для начала наш старый друг Rule, в нашем случае ServiceTestRule, который инстанцируется и дальше говорит, что все, не волнуйся, я все беру на себя.
Уже в нашем тесте мы просто берем и как бы создаем Intent, который мы хотим использовать для запуска нашего сервиса. Затем помещаем туда какой-то определенный аргумент, и получаем ссылку на наш сервис.
Когда мы вызываем функцию getService, мы получаем экземпляр нашего сервиса, который уже подсоединился, и вызываем у него напрямую метод, проверяем с помощью упоминавшихся матчеров, что ответом стало любое случайное значение, но главное, что класса integer.
Пять строчек, и уже есть протестированный сервис. Другие случаи с тестированием сервиса будут более вовлеченными, оставим это для самостоятельного изучения.
Разобрались со спецификой тестов под Android. Узнали, как тестировать и что тестировать. Давайте посмотрим на более насущную важную вещь, как вообще жить с тестами? Как их писать, когда писать? Как их внедрить в ваш обычный процесс разработки.
Ответом на это, во-первых, становится обычный Continuous Integration. Это не просто вещь, программа или сервер, это подход к разработке вашего ПО. В нашем случае мы выделим один аспект.
Вы выделяете какой-то определенный сервер, на который ставите ПО, к примеру, Jenkins, TeamCity — неважно, и настраиваете таким образом, чтобы оно проверяло, что как только у вас приходит какой-то pull request в ваш github или еще куда-то, чтобы он прогонял тесты и проверял, что все проходит, тесты работают, можно мержить, разрешаю.
Это сделано для того, чтобы нерадивый программист, который торопится выпить пива с друзьями, не поддался искушению просто отправить код, запихнуть в ветку мастера, а дальше живи как живет. Наверное, оно заработает.
Continuous Integration — это страшно, что не позволяет испортить ваш код таким очевидным и нелогичным образом. Он просто не даст его замержить, если тесты не прошли. Конечно, мы все понимаем, что бывают ситуации, когда надо замержить, даже если тесты не проходят, просто мердж, все горит, нас взломали, все пропали. В этих случаях обычно существуют красные кнопки, которые позволяют вашему Continuous Integration серверу махнуть рукой и сказать, что ладно, так и быть, сегодня мержи, но дальше — ни-ни.
Все это необходимо интегрировать в ваши основные процессы разработки. Если этот Continuous Integration сервер будет просто стоять и ничего не делать, пусть и полностью настроенный, то пользы от этого никакой не будет. Ваши программисты, то есть вы сами в первую очередь, должны привыкнуть к мысли, что халява тут больше не пройдет, придется думать, делать и отвечать за свой код.
В этом вам также может помочь один из подходов разработки, притча во языцех всех, кто любит разрабатывать с тестами, — Test Driven Development. Это подход, который является одним из самых популярных, но в то же время не единственным доминирующим. Он также используется некоторыми командами в Яндексе с очень интересными результатами, я бы даже сказал, положительными результатами. Он заключается в очень интересной процедуре разработки. В то время, как раньше вы брали идею и начинали писать код, разовьем его, и потом напишем тесты. Честно, потом, обещаю. Здесь вы сначала пишите тесты. Это очень странно выглядит, ведь у нас нет кода, как мы тестировать будем? Что тестировать? В этом изюминка этого подхода. Сначала с помощью тестов вы описываете, что же вы ожидаете от вашего кода, как бы пишите, что есть функция упоминавшаяся isStrongPassword, она должна в такой-то ситуации с такой-то строчкой возвращать вот это, здесь возвращать вот это. Потом вы запускаете эти тесты, которые тут же проваливаются, потому что кода еще нет, чего их запускать? И понимаем, что кода нет, начинаем его писать. Написали достаточно кода, чтобы прошел один тест, потом написали, чтобы прошел второй тест. Неожиданно у вас начинают проходить все тесты. Отлично.
Вы совершаете круг и снова возвращаетесь к написанию тестов. Ведь у вас еще есть фичи в вашем приложении. Вы пишите тесты для них, пишете для них код, тесты проходят. Пишите еще тесты, еще код… Из этого цикла выхода особого и нет. Но на самом деле выход есть. В какой-то момент вы можете сказать, что всё. Здесь наш процесс разработки останавливается, баста, делаем релиз.
Это связано с тем, что очень легко можно увлечься и начать писать тесты ради того, чтобы написать под них код, потом опять тесты… Остановите меня когда-нибудь.
Главное выбрать для себя масштаб покрытия этим тестированием, определенный набор фич, которые будете покрывать тестами, и уже затем итеративно по этому циклу нарабатывать ровно столько, сколько вы себе отвели.
После чего вы занимаетесь другими вещами, делаете релиз, тестируете ручным образом и т. д. И уже некий следующий спринт, процесс разработки и прочее. Начинаете снова: написали тесты, тесты не проходят, код, тесты, код, тесты… И пока ваше приложение не станет максимально близким к абстрактному идеалу.
Хочу расстроить. В то время как вы, разработчики, настоящие герои своего труда, от которых зависит это приложение, без которых ничего этого бы не было, к сожалению, вы одни не справитесь с написанием тестов нормальным образом. Можете сказать: как так? Я же знаю, как работает приложение, я же его написал.
Кроме вас в команде, скорее всего, есть такие люди как менеджеры и ручные тестировщики? Зачем они нам? Не хочу вас расстраивать, но зачастую менеджеры лучше знают, что же нужно пользователю от вашего приложения, чем вам самим. Поэтому они даже лучше представляют, как работает ваше приложение. Частая проблема в разработке ПО, не только на мобилах, в десктопе, вебе и так далее, в том, что программист думает, что я-то знаю, как пользоваться этим приложением. Он пользуется им определенным образом, проверяет, что оно вот так работает. Потом приходит глупый пользователь и неожиданно вместо того, чтобы нажать на логичную кнопку «войти» с использованием OAuth токена, нажимает на «вспомнить пароль», и приложение ломается. Ну какой вспомнить пароль? Вы еще не залогинились. Программисту такое не придет в голову. Менеджер это знает, он ставит задачи, он общается с пользователями, он имеет перед собой картину требований. Он поможет вам написать некий сценарий тестирования, который, возможно, стоит покрыть. Необязательно в первую очередь, в своем ритме, но стоит покрыть.
Ручной тестировщик, который, казалось бы, вообще должен быть заменен бездушным роботом, который прогоняет ваши автотесты, это человек зачастую со специальным образованием, особым складом ума. В то время как ваши автотесты проверяют, что уже найденные ошибки были исправлены и что ваш код работает определенным образом, ручные тестировщики очень хорошо умеют делать то, что вам зачастую недоступно. Они умеют ломать ваше приложение.
Я помню, у нас прекрасная тестировщица, когда я отправляю релиз на тестинг, ухожу за чаем, возвращаюсь — 14 открытых тикетов, потому что… Я тут просто зашла, нажала и все сломалось. Но как? Она могла это объяснить, но зачем? За это время она нашла еще 4 бага. Замечательная женщина, надеюсь, у нее все хорошо.
Именно такие люди находят за вас новые ошибки, чтобы вы могли покрыть их автотестами — и чтобы затем вы за счет этого облегчили им труд. Когда они не тратят силы на то, чтобы перепроверять старые ошибки, они еще активнее ищут способы сломать ваше приложение и найти побольше новых ошибок. Именно в этой связке, тандеме с такими людьми вы можете наиболее эффективно написать тесты, покрыть ваше приложение, сделать его красивым, хорошим, удобным и, самое главное, надежным. С помощью тестов.
Когда у вас есть тесты, вы можете быть уверены, что этот кусок кода за ночь не начал вести себя по-другому. Отлично, я дальше работаю с ним ожидаемым образом.
Чем больше времени мы можем потратить на добавление новых фич, которые приносят деньги, тем больше мы их получим. В теории. Экономика работает примерно так, я знаю.
Чем меньше времени мы тратим на поиск ошибок, тем больше времени остается на гораздо более ценные вещи, чем деньги, — на свободное время, хобби, увлечения, родных и близких, прогулки и т. д. Согласитесь, стоит потратить немного времени на тесты, чтобы получить столько добра в ответ.
Как только вы начнете писать тесты, вы неожиданно поймете, что по мере роста ваших навыков это становится делать все легче, тесты сами выходят у вас из-под руки. Вы написали код, выпили чаю, наделали тестов. Написали код, не успели оглянуться, а вы уже написали к нему тесты. Как только этот процесс запускается, его уже становится сложно остановить. Ведь вы попадаете в отрицательную обратную связь. У нас это работает. Будем надеяться, это поможет и вам.