Делай как вGoogle
![]()
|
Как предотвратить хрупкие тесты Как только что было определено, хрупкий тест — это тест, который может провалить изменение в коде, не содержащее фактических ошибок 1 . Такие тесты должны вы- являться и исправляться инженерами в рамках повседневной работы. В небольших 1 Обратите внимание, что хрупкие тесты немного отличают от нестабильных тестов, которые терпят неудачу недетерминированно, без всяких изменений в тестируемом коде. Как предотвратить хрупкие тесты 233 базах кода, разрабатываемых небольшой командой инженеров, корректировка не- скольких тестов не будет большой проблемой. Но если команда регулярно пишет хрупкие тесты, ей придется тратить все больше времени на обслуживание тестов, потому что число сбоев будет расти вместе с числом тестов. Если для каждого из- менения инженерам придется вручную настраивать набор тестов, трудно назвать такой набор «автоматизированным»! Хрупкие тесты вызывают головную боль при работе с базой кода любого размера, но эта боль становится особенно острой в масштабах Google. Любой инженер Google может запускать тысячи тестов в течение дня, а одно крупномасштабное измене- ние (главу 22) может повлечь запуск сотен тысяч тестов. При таких масштабах ложные отказы даже в небольшой доле тестов могут привести к тратам огромного количества инженерного времени. В разных командах Google разные показатели хрупкости тестов, но мы определили несколько общих практик и шаблонов, помо- гающих сделать тесты более устойчивыми к изменениям. Добивайтесь неизменности тестов Прежде чем перейти к шаблонам, помогающим предотвратить появление хрупких тестов, мы должны ответить на вопрос: насколько часто нужно изменять тест после его написания? Ведь можно заняться более интересной работой, чем изменение те- стов. Таким образом, идеальный тест — это неизменяемый тест: после написания его не нужно изменять, если не изменяются требования SUT. Как это выглядит на практике? Подумайте о том, какие изменения вносят инже- неры в код и как тесты реагируют на эти изменения. По сути, есть четыре вида изменений: Чистый рефакторинг Это рефакторинг внутренних компонентов системы без изменения ее интерфейса, например для увеличения производительности, ясности или по любой другой причине, который не требует изменения тестов. Задача тестов в этом случае — убедиться, что в процессе рефакторинга не было изменено поведение системы. Если во время рефакторинга потребовалось изменить тесты, значит, внесенные изменения повлияли на поведение системы (рефакторинг не чистый) или уровень абстракции тестов не соответствует желаемому. Подробнее о чистом рефакторинге и поддержке масштабных изменений в Google читайте в главе 22. Новые особенности При добавлении новых особенностей существующее поведение системы должно оставаться неизменным. Инженер должен написать новые тесты для проверки новых особенностей, не меняя уже имеющиеся тесты. Как и в случае с рефакто- рингом, изменение существующих тестов при добавлении новой особенности свидетельствует о непреднамеренных последствиях включения этой особенности или о некорректности тестов. 234 Глава 12. Юнит-тестирование Исправление ошибок Наличие ошибки предполагает, что она не обнаруживалась первоначальным на- бором тестов, и процесс исправления ошибки должен включать создание отсут- ствующего теста. Опять же, исправления ошибок обычно не требуют изменения существующих тестов. Изменение поведения Может потребовать изменить существующие тесты. Обратите внимание, что та- кие изменения, как правило, обходятся значительно дороже, чем изменения трех других типов. Пользователи системы, вероятно, будут полагаться на ее текущее поведение, и изменения в поведении потребуют координации с этими пользова- телями, чтобы избежать путаницы или недопонимания. Изменение теста в этом случае указывает на то, что мы намеренно нарушаем контракт системы, тогда как изменения в предыдущих случаях свидетельствуют о непреднамеренном наруше- нии контракта. Разработчики низкоуровневых библиотек часто прикладывают значительные усилия, чтобы избежать изменений в поведении и не нарушить работу кода, который использует их библиотеки. Таким образом, после того как тест написан, мы не должны снова прикасаться к нему при рефакторинге системы, исправлении ошибок или добавлении новых особенно- стей. Это понимание позволяет нам работать с крупной системой: расширение си- стемы потребует написания лишь небольшого числа новых тестов, прямо связанных с вносимыми изменениями, но не пересмотра тестов, написанных раньше. Только критические изменения в поведении системы могут повлечь изменения тестов, но помните, что обновление тестов системы обходится дешевле обновления всего кода, пользующегося системой. Тестирование через общедоступные API Теперь, определив свою цель, давайте взглянем на некоторые практики, помога- ющие избежать необходимости изменять тесты, если не изменяются требования SUT. Для начала пишите тесты, которые обращаются к SUT точно так же, как ее пользователи, — то есть вызывают ее общедоступный API и не учитывают детали ее реализации ( https://oreil.ly/ijat0 ). Если тесты работают так же, как пользователи системы, то изменение, вызывающее нарушение в работе теста, также может приве- сти к нарушению в работе кода пользователя. При этом такие тесты могут служить полезными примерами и документацией для пользователей. Рассмотрим листинг 12.1, который проверяет транзакцию и сохраняет ее в базе данных. Заманчиво протестировать этот код, удалив модификаторы private и проверив логику реализации напрямую, как показано в листинге 12.2. Этот тест взаимодействует с процессором транзакций совсем не так, как реальные пользователи: он проверяет внутреннее состояние системы и вызывает методы, Как предотвратить хрупкие тесты 235 которые не являются частью общедоступного системного API. В результате тест оказывается хрупким и почти любой рефакторинг SUT (например, переименование методов, выделение их во вспомогательный класс или изменение формата сериали- зации) может привести к поломке теста, даже если эти изменения будут не видны для реальных пользователей класса. Листинг 12.1. API транзакции public void processTransaction(Transaction transaction) { if (isValid(transaction)) { saveToDatabase(transaction); } } private boolean isValid(Transaction t) { return t.getAmount() < t.getSender().getBalance(); } private void saveToDatabase(Transaction t) { String s = t.getSender() + "," + t.getRecipient() + "," + t.getAmount(); database.put(t.getId(), s); } public void setAccountBalance(String accountName, int balance) { // Запись баланса непосредственно в базу данных } public void getAccountBalance(String accountName) { // Чтение транзакций из базы данных для определения баланса } Листинг 12.2. Наивное тестирование реализации API транзакции @Test public void emptyAccountShouldNotBeValid() { assertThat(processor.isValid(newTransaction().setSender(EMPTY_ACCOUNT))) .isFalse(); } @Test public void shouldSaveSerializedData() { processor.saveToDatabase(newTransaction() .setId(123) .setSender("me") .setRecipient("you") .setAmount(100)); assertThat(database.get(123)).isEqualTo("me,you,100"); } 236 Глава 12. Юнит-тестирование Однако того же охвата тестирования можно достичь путем тестирования только основного общедоступного API класса, как показано в листинге 12.3 1 Листинг 12.3. Тестирование общедоступного API @Test public void shouldTransferFunds() { processor.setAccountBalance("me", 150); processor.setAccountBalance("you", 20); processor.processTransaction(newTransaction() .setSender("me") .setRecipient("you") .setAmount(100)); assertThat(processor.getAccountBalance("me")).isEqualTo(50); assertThat(processor.getAccountBalance("you")).isEqualTo(120); } @Test public void shouldNotPerformInvalidTransactions() { processor.setAccountBalance("me", 50); processor.setAccountBalance("you", 20); processor.processTransaction(newTransaction() .setSender("me") .setRecipient("you") .setAmount(100)); assertThat(processor.getAccountBalance("me")).isEqualTo(50); assertThat(processor.getAccountBalance("you")).isEqualTo(20); } Тесты, использующие только общедоступные API, по определению обращаются к SUT так же, как ее пользователи. Такие тесты более реалистичные и менее хрупкие, потому что используют явные контракты: если такой тест ломается, это означает, что нормальная работа существующего кода, использующего систему, тоже будет нарушена. Тестирование только с использованием явных контрактов позволяет свободно выполнять рефакторинг любых внутренних компонентов системы, не за- ботясь о необходимости вносить изменения в тесты. В юнит-тестировании не всегда очевидно, что относится к «общедоступному API», и эта проблема сводится к определению понятия «юнит». Юниты могут быть такими же маленькими, как отдельные функции, или большими, как набор из нескольких взаимосвязанных пакетов. Когда в этом контексте мы говорим «общедоступный API», то подразумеваем API, предоставляемый одним юнитом третьим сторонам. Это не 1 Иногда этот подход называют «Принципом использования парадной двери» ( https://oreil. ly/8zSZg ). Как предотвратить хрупкие тесты 237 всегда согласуется с понятием видимости в некоторых языках программирования. Например, классы в Java могут объявляться как «общедоступные», чтобы их можно было использовать в других пакетах в том же юните, но они не предназначены для использования кодом за пределами юнита. Некоторые языки, такие как Python, во- обще не имеют механизмов ограничения видимости (и часто полагаются на такие соглашения, как добавление символов подчеркивания в начало имен закрытых ме- тодов), а системы сборки, такие как Bazel ( https://bazel.build ), могут дополнительно ограничивать тех, кому позволено использовать API, объявленный общедоступным в исходном коде. Определение границ юнита и соответственно общедоступного API — это больше искусство, чем наука. Тем не менее есть несколько универсальных правил: y Если метод или класс предназначен только для поддержки одного или двух других классов (то есть это «вспомогательный класс»), его, вероятно, не следует рассма- тривать как юнит и нужно тестировать не напрямую, а через эти другие классы. y Если пакет или класс спроектирован для использования извне без разрешения его владельца, он почти наверняка является юнитом, который следует тестировать напрямую, причем тесты должны обращаться к юниту так же, как сторонний код. y Если пакет или класс доступен только его владельцу, но предлагает возможно- сти, полезные в различных контекстах (то есть это «библиотека поддержки»), его следует рассматривать как юнит и тестировать непосредственно. Обычно в этом случае возникает некоторая избыточность в тестировании, потому что код библиотеки поддержки будет охватываться не только собственными тестами, но и тестами кода, который ее использует. Но такая избыточность полезна: без нее может появиться пробел в охвате тестирования, если какой-то код, использующий библиотеку (и его тесты), будет удален. Мы долго убеждали инженеров, что тестирование через общедоступные API лучше тестирования деталей реализации. Конечно, им было проще написать тест, прове- ряющий только что написанный фрагмент кода, не выясняя, как этот код влияет на систему в целом. Тем не менее мы считаем, что дополнительные предварительные усилия окупаются многократно за счет снижения затрат на поддержку. Вход через общедоступные API не устраняет хрупкость теста полностью, но позволяет гаранти- ровать, что тесты будут ломаться только в случае значительных изменений в системе. Тестируйте состояние, а не взаимодействия Другая причина появления зависимости тестов от деталей реализации заключается не в том, какие методы вызывает тест, а в том, как проверяются результаты этих вызовов. Есть два основных способа убедиться, что поведение SUT соответствует ожиданиям. Тестируя состояние, вы наблюдаете за поведением самой системы по- сле обращения к ней. Тестируя взаимодействия, вы хотите убедиться, что в ответ на вызов система предприняла ожидаемую последовательность действий ( https://oreil. ly/3S8AL ). Многие тесты объединяют проверки состояния и взаимодействий. 238 Глава 12. Юнит-тестирование Тестирование взаимодействий, как правило, более хрупкое, чем тестирование со- стояния, по той же причине, почему тестирование закрытого метода более хрупкое, чем тестирование общедоступного метода: при тестировании взаимодействия про- веряется, как система достигла своего результата, а не сам результат. В листинге 12.4 показан тест, использующий тестового дублера (глава 13), чтобы проверить, как система взаимодействует с базой данных. Листинг 12.4. Хрупкий тест взаимодействия @Test public void shouldWriteToDatabase() { accounts.createUser("foobar"); verify(database).put("foobar"); } Тест проверяет, был ли сделан вызов определенного API базы данных. Есть несколько ситуаций, когда события могут начать развиваться по неправильному пути: y Если ошибка в SUT приводит к удалению записи из базы данных вскоре после ее добавления, тест выполнится успешно, даже если мы хотим, чтобы он не был пройден. y Если в ходе рефакторинга SUT будет вызывать другой API для создания записи, тест завершится ошибкой, даже если мы хотим, чтобы он выполнился успешно. Гораздо меньше проблем возникает, если тестировать состояние системы, как по- казано в листинге 12.5. Листинг 12.5. Тестирование состояния @Test public void shouldCreateUsers() { accounts.createUser("foobar"); assertThat(accounts.getUser("foobar")).isNotNull(); } Этот тест точнее выражает наш интерес: состояние SUT после взаимодействия с ней. Распространенная причина проблем при тестировании взаимодействий — чрезмерная зависимость системы от фреймворков с фиктивными объектами. Эти фреймворки позволяют легко создавать и использовать тестовые дублеры, которые регистрируют и проверяют каждый вызов, направленный им. Эта стратегия прямо ведет к появ- лению хрупких тестов взаимодействий, и поэтому мы предпочитаем использовать реальные объекты вместо их имитаций, если эти реальные объекты действуют быстро и детерминированно. Подробнее о тестовых дублерах, фреймворках с фиктивными объектами и их альтернативах в главе 13. Создание ясных тестов 239 Создание ясных тестов Рано или поздно, даже предприняв все возможное, чтобы избежать хрупкости, мы столкнемся со сбоями в тестировании. Неудача — это хорошо: неудачи при тести- ровании дают полезные сигналы инженерам. Сбой в тесте может произойти по одной из двух причин 1 : y SUT содержит ошибку или недочет. Это именно то, для чего предназначены тесты: сообщать об ошибках, чтобы вы могли их исправить. y Сам тест содержит ошибку. В этом случае с SUT все в порядке, но тест был по- строен неверно. Если это уже существующий тест — не тот, который вы только что написали, — это означает, что данный тест хрупкий. В предыдущем разделе обсуждалось, как предотвратить появление хрупких тестов, но их редко полу- чается полностью устранить. Когда тест провален, инженер в первую очередь должен определить, по какой из этих причин произошел сбой, а затем диагностировать истинную проблему. Скорость, с какой инженер может сделать это, зависит от ясности теста. Ясный тест — это тест, диагностика сбоя которого позволяет инженеру сразу определить цель теста и причину сбоя. Также ясные тесты документируют SUT и могут использоваться в качестве основы для новых тестов. Ценность ясности теста растет с течением времени. Тесты живут долго и должны отвечать изменениям в системе по мере ее развития. Может так получиться, что тест, потерпевший неудачу, был написан несколько лет назад инженером, который больше не работает в команде и не оставил ничего, что помогло бы выяснить назначение теста или способы его исправления. Если назначение неясного кода можно определить по коду, который его вызывает, и нарушениям при его удалении, то цель неясного теста понять невозможно, так как удаление теста вызовет только (потенциально) пробел в охвате тестирования. В худшем случае такие неясные тесты просто удаляются, если инженерам не уда- ется понять, как их исправить. Удаление тестов не только создает пробел в охвате тестирования, но также указывает на то, что тест имел нулевую ценность, возможно, в течение всего периода своего существования. Чтобы набор тестов масштабировался и оставался полезным с течением времени, важно, чтобы каждый тест в наборе был максимально ясным. В этом разделе мы обсудим методы и способы увеличения ясности тестов. 1 Это те же две причины «ненадежности» тестов. Либо SUT имеет недетерминированную ошибку, проявляющуюся от случая к случаю, либо тест имеет недостатки, вынуждающие его терпеть неудачу в случаях, когда он должен выполняться безошибочно. 240 Глава 12. Юнит-тестирование Тесты должны быть краткими и полными Достичь ясности тестов помогают два их основных качества: полнота и краткость ( https://oreil.ly/lqwyG ). Тест считается полным, если его тело содержит всю информацию, которая может понадобиться читателю, чтобы понять, как этот тест получает свой результат. Тест считается кратким, если он не содержит никакой другой отвлекающей или не относящейся к тесту информации. В листинге 12.6 показан тест, который не является ни полным, ни кратким: Листинг 12.6. Неполный и громоздкий тест @Test public void shouldPerformAddition() { Calculator calculator = new Calculator(new RoundingStrategy(), "unused", ENABLE_COSINE_FEATURE, 0.01, calculusEngine, false); int result = calculator.calculate(newTestCalculation()); assertThat(result).isEqualTo(5); // Окуда взялось это число? } Тест содержит в вызове конструктора много информации, не относящейся к делу, а некоторые важные части теста скрыты внутри вспомогательного метода. Вы мо- жете сделать тест более полным, пояснив назначение аргументов вспомогательного метода, и более кратким, использовав другой вспомогательный метод, чтобы скрыть ненужные детали создания калькулятора. |