Если освоить небольшой список типичных ошибок, возникающих при написании юнит-тестов, то можно даже полюбить писать их. Сегодня руководитель группы разработки Яндекс.Браузера для Android Константин kzaikin Заикин поделится с читателями Хабра своим опытом.
— У меня доклад практический. Надеюсь, он вам всем принесет пользу — и тем, кто юнит-тесты уже пишет, и тем, кто только думает писать, и тем, кто пробует, и у кого не получилось.
У нас довольно большой проект. Один из самых больших мобильных проектов в России. У нас много кода, много тестов. Тесты гоняются на каждом пул-реквесте, они не падают при этом.
Кто знает, какое у него в проекте тестовое покрытие? Ноль, окей. У кого в проекте есть юнит-тесты? А кто считает, что юнит-тесты не нужны? Не вижу в этом ничего плохого, есть люди, которые в этом искренне убеждены, и мой рассказ должен помочь им в этом разубедиться.
К этому счастью, зеленым тестам — тысячи их, — мы пришли не сразу. Нет серебряной пули, и главная идея моего доклада на экране:
Иероглифами написано китайское изречение, что путь в тысячу ли начинается с одного шага. Кажется, что есть такой аналог этой поговорки.
Мы достаточно давно приняли решение, что надо улучшать наш продукт, наш код, и целенаправленно к этому двигаемся. На этом пути мы встретили много шишек, подводных граблей, и собрали вместе с этим некоторые убеждения.
Зачем нужны тесты?
Чтобы не падали старые фичи, когда внедряем новые. Чтобы был бейджик на GitHub. Чтобы рефакторить существующие фичи — глубокая мысль, ее нужно раскрыть для тех, кто не пишет тесты. Чтобы существующие фичи не падали при рефакторинге, тестами мы себя обезопасим. Чтобы начальник заапрувил пул-реквест, это да.
Мое мнение — прошу не ассоциировать его со мнением моей команды, — что тесты нам помогают. Они позволяют ваш код прогнать без выкладывания в продакшен, без установки на девайсы, вы его очень быстро запускаете и прогоняете. Вы можете прогнать все corner cases, которые на девайсе и в продакшене вы в жизни не получите, и ваш тестировщик их не придумает. Но вы их как разработчик пытливым умом придумаете, проверите и баги на самой ранней стадии устраните.
Очень важно: тесты рассказывают, как, по мнению разработчика, должен работать код и что, по мнению разработчика, ваши методы должны делать. Это не комментарии, которые отгнивают и через некоторое время из полезных становятся вредными. Бывает, что в комментариях написано одно, а в коде совсем другое. Юнит-тесты в этом смысле не могут врать. Если тест зеленый — он документирует то, что там происходит. Тест поломался — первичный замысел разработчика вы нарушили.
Зафиксировать контракты. Это не договоры с подписью и печатью, а программные контракты поведения классов. Если вы будете производить рефакторинг, в этом случае контракты будут нарушены и тесты упадут, если вы их нарушите. Если контракты сохранятся, тесты останутся зелеными, у вас будет больше уверенности, что ваш рефакторинг правильный.
Это общая идея всего моего доклада. Можно показать первую строчку и уходить.
Многие считают, что тестовый код — это так себе код, он не для продакшена, поэтому можно его писать так себе. Я с этим категорически не согласен и считаю, что к тестам нужно подходить в первую очередь ответственно, так же как к продакшен коду. Если вы будете подходить к ним так же, то тесты вам будут приносить пользу. В противном случае это будет один головняк.
Если конкретно, две строчки ниже относятся, кажется, к любому коду.
KISS — keep it simple, stupid. Не надо усложнять. Тесты должны быть простые. И продакшен код должен быть простой, но тесты особенно. Если у вас будут тесты, которые просто читать, то это будут тесты, которые, скорее всего, написаны хорошо, они хорошо выразили мысль, их будет легко проверить. Даже во время пул-реквеста человек, который смотрит на ваши новенькие тесты, поймет, что вы хотели сказать. И если что-то сломается, вы легко сможете понять, что случилось.
DRY — don’t repeat yourself. В тестах разработчик часто склонны к тому, чтобы использовать тот запрещенный прием, который в продакшене, кажется, никто не использует — copy paste. В продакшене разработчика, который будет активно копипастить, просто не поймут. В тестах это нормальная практика, к сожалению. Не нужно так делать, потому что — первая строчка. Если вы будете писать тесты по-честному, как настоящий хороший код, тесты будут вам полезны.
Пока мы разрабатывали наши сотни тысяч строк кода, писали тысячи тестов, собирали грабли, у меня накопились типичные замечания к тестам. Я достаточно ленив, и когда я приходил в пул-реквесты и наблюдал одни и те же ошибки, исходя из принципа DRY решил записать эти типичные проблемы, и сделал сначала на внутренней Вики, а потом выложил на GitHub практические test smells, которыми вы можете руководствоваться, когда пишете тесты.
Буду перечислять по пунктам. Инкрементируете у себя в уме счетчик, если вспомните такой test smell. Если вы досчитаете до пяти, то можете поднять руку и закричать «Бинго!» А в конце интересно, кто до скольки досчитал. У меня счетчик будет равен количеству пунктов, я их все сам собирал.
Ссылка на GitHub
Самую сложную вещь в программировании вы знаете. И в тестах это действительно важно. Если вы плохо назовете тест, то скорее всего, не сможете формулировать, что тест проверяет.
Люди — довольно простые существа, они легко ловятся в ловушку имен. Поэтому прошу вас хорошо называть тесты. Формулируйте, что тест проверяет, и следуйте простым правилам.
no_action_or_assertion
Если в названии теста нет описания того, что тест проверяет, например, у вас есть класс Controller, и вы пишите тест testController, что вы проверяете? Что этот тест должен сделать? Скорее всего, либо ничего, либо слишком много вещей проверять. Ни то, ни другое нас не устраивает. Поэтому в имени теста надо написать, что мы проверяем.
long_name
Нельзя вдаваться и в другую крайность. Имя теста должно быть достаточно коротким, чтобы человек легко мог его распарсить. В этом смысле Kotlin прекрасен, потому что он позволяет писать имена тестов в кавычках с пробелами нормальным английским языком. Их читать проще. Но все равно длинные имена — это smell.
Если у вас имя теста слишком длинное, скорее всего, вы в один тестовый класс засунули слишком много тестовых методов, и вам нужно уточнять, что же вы проверяете. В этом случае вам нужно разнести ваш тестовый класс на несколько. Не нужно этого бояться. У вас будет имя тестового класса, которое проверяет имя вашего продакшен кода, и будут короткие имена тестов.
older_prefix
Это атавизм. Раньше в Java все тестировали при помощи JUnit, где до четвертой версии было соглашение, что тестовые методы должны начинаться со слова test. Так сложилось, до сих пор все так называют. Но тут есть проблема, в английском языке слово test — это глагол «проверить». Люди легко ловятся на эту ловушку, и больше не пишут никаких других глаголов. Пишут testController. Себя легко проверить: если вы не написал глагол, что должен делать ваш тестовый класс, скорее всего, что-то вы не проверили, не написали в названии достаточно хорошо. Поэтому я всегда прошу из названий тестовых методов убирать слово test.
Я рассказываю очень простые вещи, но как ни странно, они помогают. Если тесты называются хорошо, скорее всего под капотом они будут неплохо выглядеть. Это очень просто.
Я фактически зачитываю идентификаторы test smells как на GitHub. Ссылка внизу, можете ходить и пользоваться.
multiple_asserts
В тестовом методе встречается много ассертов. Так может быть или нет? Может быть. А хорошо это или плохо? Я считаю, что это очень плохо. Если вы написали в тестовом методе несколько ассертов, то вы проверяете несколько утверждений. Если вы проверяете ваш тест, и упал первый ассерт, дойдет ли тест до второго ассерта? Не дойдет. Вы уже после падения вашей сборки где-то на CI получите, что тест упал, пойдете что-то исправить, зальете заново, он упадет на следующем ассерте. Это вполне может быть.
При этом гораздо круче было бы, если бы вы распилили этот тестовый метод на несколько, и упали бы все методы с несколькими ассертами одновременно, потому что запускались бы они независимо друг от друга.
Еще несколько ассертов могут маскировать за собой разные действия, которые производятся с тестовым классом. Я рекомендую писать один тест — один ассерт. При этом ассерты могут быть довольно сложные. Мой коллега в самом первом докладе демонстрировал кусочек кода, где использовал великолепную конструкцию assertThat и матчер. Я очень люблю матчеры в JUnit, так тоже можно использовать. Для читателя тестов он получается просто одним коротким оператором. На GitHub есть примеры всех этих smells и как их исправить. Там есть пример плохого кода и рядом хорошего. Это все сделано в виде проекта, который вы можете загрузить, открыть, скомпилировать и прогнать все тесты.
many_tests_in_one
Следующий smell тесно связан с предыдущим. Вы делаете что-то с системой — делаете ассерт. Делаете еще что-то с системой, длинные какие-то операции — делаете ассерт — делаете еще что-то. Фактически вы просто распиливаете на несколько методов, и у вас получаются цельные хорошие тестовые методы.
repeating_setup
Это относится к многословию. Если у вас есть тестовый класс, и в каждом тестовом методе в начале выполняются одни и те же методы.
Тестовый класс, в котором у вас в начале выполняются одни и те же методы. Кажется, это немного, но в каждом тестовом методе этот мусор присутствует. А если он общий для всех тестовых методов, то почему бы не унести в конструктор или в блок Before или блок BeforeEach в JUnit 5. Если вы это сделаете, то читаемость каждого метода улучшится, плюс вы избавитесь от греха DRY. Такие тесты легче поддерживать и легче читать.
Надежность тестов — это очень важно. Есть признаки, по которым можно определить, что тест будет флакать, быть то зеленым, то красным. Когда разработчик его пишет, он точно уверен, что он зеленый, а потом почему-то тесты становятся то зелеными, то красными, доставляют нам боль и неуверенность в целом, что тесты полезны. Мы не уверены в тестах, значит, не уверены, что они приносят пользу.
random
Я сам когда-то писал тесты, у которых внутри был Math.random(), делал случайные числа, что-то с ними делал. Не надо так делать. Мы ожидаем, что в тестовую систему тест входит в одной и той же конфигурации, и выход у него тоже обязан быть один и тот же. Поэтому в юнит-тестах, например, никогда не нужно делать какие-то операции с сетью. Потому что сервер может не ответить, могут быть разные тайминги, еще что-то.
Если вам нужен тест, который работает с сетью, делайте прокси, локальный, что угодно, но ни в коем случае не ходите в настоящую сеть. Это тот же самый random. И конечно, нельзя использовать рандомные данные. Если вы должны что-то сделать, сделайте несколько примеров с краевыми условиями, с плохими условиями, но они должны быть захардкодены.
tread_sleep
Классическая проблема, с которой сталкиваются разработчики, когда пытаются протестировать какой-то асинхронный код. Это то, что я в тесте что-то сделал, а потом нужно дождаться, когда оно выполнится. Как сделать? Thread.sleep(), конечно.
Есть проблема. Когда вы разрабатывали свой тест, например, вы это делали на какой-то своей машинке, она работает с какой-то скоростью. Тесты вы запустите на другой машинке. И что будет, если ваша система за время Thread.sleep() не успеет отработать? Тест покраснеет. Это неожиданно. Поэтому здесь рекомендация, если вы выполняете асинхронные операции, не тестировать их вовсе. Почти любую асинхронную операцию можно развернуть так, что у вас будет какой-то условный механизм, обеспечивающий асинхронщину, и синхронно выполняющийся блок кода. Например, AsyncTask внутри имеет синхронно выполняющийся блок кода. Вы легко можете его протестировать синхронно, без всякой асинхронщины. Тестировать сам AsyncTask нет необходимости, это фреймворк класс, зачем его тестировать? Вынесите его за скобки, и ваша жизнь станет проще.
Thread.sleep() доставляет много боли. Кроме того, что он ухудшает надежность тестов, поскольку позволяет им флакать из-за разных таймингов на девайсах, еще и замедляет выполнение ваших тестов. Кому понравится, что его юнит-тесты, которые должны выполняться миллисекунды, будут выполняться пять секунд, потому что я поставил tread sleep?
modify_global
Типичный smell, что мы поменяли какую-то глобальную статическую переменную в начале теста, чтобы проверить, что наша система корректно отрабатывает, а в конце не вернули. Тогда получаем классную ситуацию: на машине разработчик выполнял тесты в одной последовательности, сначала проверял с дефолтным значением глобальную переменную, потом в тесте другом ее менял, потом еще что-то делал. Оба теста зеленые. А на CI, так получилось, тесты запустились в обратной последовательности. И либо один, либо оба теста будут красные, хотя были все зеленые.
Нужно прибирать за собой. Правила бойскаутов в этом смысле: поменял глобальную переменную — верни к исходному состоянию. А еще лучше сделать так, чтобы не использовались глобальные состояния. Но это уже более глубокая мысль. Она про то, что тесты иногда подсвечивают дефекты в архитектуре. Если нам приходится менять глобальные состояния и возвращать их в исходные, чтобы писать тесты, точно ли мы все хорошо делаем в нашей архитектуре? Действительно ли нам нужны глобальные переменные, например? Без них, как правило, можно обойтись, инжектировав какие-то классы контекстов или что-то, чтобы вы каждый раз могли в тесте их заново проинициализировать, инжектировать и чистенько выполнять.
@VisibleForTesting
Test smell для продвинутых. Необходимость такую штуку использовать возникает не в первый день, как правило. Вы уже что-то тестировали, и тут вам потребовалось класс перевести в какое-то специфическое состояние. И вы себе делаете бэкдор. У вас есть продакшен-класс, и вы делаете специфический метод, который никогда в продакшен не будет вызван, и через него что-то инжектируете в класс или меняете его состояние. Тем самым злостно нарушая инкапсуляцию. В продакшене у вас класс работает как-то, а в тестах, по сути, это другой класс, вы через другие входы-выходы с ним общаетесь. И тут вы можете получить ситуацию, когда продакшен вы поменяете, а тесты этого не заметят. Тесты продолжают ходить через бэкдор и не заметили, что, например, в конструкторе начали стрелять исключения, поскольку они ходят через другой конструктор.
В целом вы должны тестировать ваши классы через те же входы и выходы, что и в продакшене. Не должно быть доступа к каким угодно методам только для тестов.
Сколько выполняются наши 15 тысяч тестов? Около 20 минут, на каждом пул-реквесте, на Team City разработчики вынуждены ждать. Просто потому что 15 тысяч — это много тестов. И в этом разделе я собрал smells, которые замедляют тесты. Хотя thread_sleep уже был.
unnecessary_android_test
В Android есть instrumentation tests, они прекрасны, они запускаются на девайсе или эмуляторе. Это поднимет ваш проект полностью, по-настоящему, но они очень медленные. И для них нужно даже поднять целый эмулятор. Даже если представить, что у вас есть поднятый эмулятор на CI — так совпало, что он у вас есть, — то выполнение теста на эмуляторе займет гораздо больше времени, чем на хост-машине, например, при помощи Robolectric. Хотя есть и другие методы. Это такой фреймворк, который позволяет вам на хост-машине, на чистой Java работать с классами из Android-фреймворка. Мы используем его достаточно активно. Раньше к нему Google относился несколько с прохладцей, но сейчас про него рассказывают и сами гуглеры на разных докладах, он рекомендуется к использованию.
unnecessary_robolectric
Android-фреймворк в Robolectric эмулируется. Он там не полный, хотя реализация чем дальше, тем полнее. Это почти настоящий Android, только выполняется на вашем декстопе, ноутбуке или CI. Но его тоже не везде нужно использовать. Robolectric не бесплатный. Если у вас есть тест, который вы героически перенесли с Android instrumentation на Robolectric, надо подумать — может, пойти еще дальше, избавиться от Robolectric, превратить его в самый простой JUnit-тест? Robolectric-тесты требуют времени на инициализацию, пытаются загружать ресурсы, инициализируют вашу activity, application и все остальное. Это занимает определенное время. Это уже не секунды, это миллисекунды, иногда десятки и сотни. Но когда тестов много, даже это имеет значение.
Существуют техники, которые позволяют избавиться от Robolectric. Вы можете изолировать свой код через интерфейсы, обернув всю платформенную часть интерфейсами. Тогда будет просто JUnit-хост-тест. JUnit на хост-машине работает очень быстро, там минимальное количество overhead, такие тесты можно запускать тысячами и десятками тысяч, они будут выполняться минуту, единицы минут. Наши тесты, к сожалению, выполняются долго, потому что у нас много Android instrumentation-тестов, потому что у нас есть нативная часть в браузере и мы вынуждены выполнять их на настоящем эмуляторе или девайсе. Поэтому так долго.
Не буду больше вас утомлять. Сколько у вас smells? Пока семь максимум. Подписывайтесь на канал, ставьте звезды.
Автор: Леонид Клюев