Ура! Наконец-то вы написали столько строк кода, что можете позволить себе дом на берегу моря. Вы нанимаете Питера Китинга — архитектора, всемирно известного своими небоскребами. Он уверяет, что у него есть блестящие идеи по поводу вашего пляжного домика.

Спустя несколько месяцев вы приезжаете на торжественное открытие, и что вы видите? Ваш новый дом представляет собой внушительную пятиэтажную громадину из стали, бетона и стекла. Вы проходите через вращающиеся двери, и на роскошном мраморном полу остается песок. Внутри вы обнаружите стойку администратора, за которой находится лифт. Наверху — спальня и три гостевые комнаты в виде смежных офисных кабинок.

Питер Китинг, архитектор-эксперт, не может понять, почему вы разочарованы. «Я следовал всем лучшим практикам» — говорит он, защищаясь. Стены толщиной в девять метров, потому что структурная целостность здания жизненно важна. Поэтому ваш дом лучше, чем соседние, наполненные светом и пропускающие воздух. Возможно, у вас и нет больших окон с видом на океан, но Китинг говорит, что такие окна не являются лучшей практикой — они снижают энергоэффективность и отвлекают офисных работников.

Часто разработчики программного обеспечения подходят к юнит-тестированию с подобным ошибочным мышлением. Они автоматически применяют все «правила» и лучшие практики, которые они усвоили из опыта написания продакшен кода, не проверяя, подходят ли они для написания тестов. В результате строят небоскребы на пляже.

Код тестов не похож на другой код

Приложения обычно содержат от тысяч до миллионов строк кода. Это слишком много, чтобы человек мог осмыслить все сразу. Чтобы справиться со сложностью, создатели языка создали такие механизмы, как функции и иерархии классов, которые позволяют разработчикам мыслить абстракциями.

В хорошем продакшен-коде достигается инкапсуляция. Это помогает читателю легко ориентироваться в больших системах, погружаться в детали или подниматься на более высокий уровень абстракции в зависимости от необходимости.

Код тестов — это совсем другой зверь. Хороший юнит-тест зачастую достаточно мал, чтобы разработчик мог сразу представить себе всю логику. Добавление уровней абстракции в код теста усложняет его. Тесты — это диагностический инструмент, поэтому они должны быть максимально простыми и очевидными.

Хороший продакшен код хорошо скомпонован; хороший код теста очевиден.

Image of a ruler
Линейка обыкновенная

Представьте обычную линейку. Она существует в одинаковом виде сотни лет, потому что она проста для восприятия и использования. Предположим, что я изобрел новую линейку, которая измеряет в «абстрактных единицах линейки». Чтобы перевести «единицы линейки» в сантиметры и метры, нужно использовать отдельную таблицу пересчета.

Если бы я предложил такую линейку плотнику, он бы ударил меня ею по лицу. Было бы нелепо добавлять слой абстракции к инструменту, который дает четкую и однозначную информацию.

Хороший код тестов ничем от этого не отличается. Он должен давать четкие результаты, не заставляя читателя преодолевать многочисленные уровни непрямолинейности. Разработчики часто упускают это из виду, поскольку это отличается от того, как они учились писать продакшен код.

Плохой тест хорошего разработчика

Я часто вижу, как талантливые разработчики пишут подобные тесты:

def test_initial_score(self):
  initial_score = self.account_manager.get_score(username='joe123')
  self.assertEqual(150.0, initial_score)

Что делает этот тест? Он получает «результат» (score) для пользователя с именем joe123 и проверяет, что он равен 150. На этом этапе у вас могут возникнуть вопросы:

  1. Откуда взялась учетная запись joe123?

  2. Почему я ожидаю, что результат для joe123 будет равен 150?

Возможно, ответы находятся в методе setUp, который тестовый фреймворк вызывает перед выполнением каждой тестовой функции:

def setUp(self):
  database = MockDatabase()
  database.add_row({
      'username': 'joe123',
      'score': 150.0
    })
  self.account_manager = AccountManager(database)

Итак, метод setUp создал пользователя joe123 с результатом 150 баллов, что объясняет, почему test_initial_score ожидал таких значений. Теперь все в порядке, да?

Нет, это плохой тест.

Держите читателя в пределах своей тестовой функции

Когда вы пишете тест, подумайте о следующем разработчике, который увидит, что тест сломался. Он не захочет читать весь ваш тест-сьют, и уж точно не захочет читать целое дерево наследования тестовых утилит.

Если тест ломается, читатель должен быть в состоянии диагностировать проблему, прочитав тестовую функцию по прямой сверху вниз. Если для чтения вспомогательного кода приходится выходить из теста, значит, тест не справился со своей задачей.

Исходя из этого, перепишем тест из предыдущего раздела:

def test_initial_score(self):
  database = MockDatabase()
  database.add_row({
      'username': 'joe123',
      'score': 150.0
    })
  account_manager = AccountManager(database)

  initial_score = account_manager.get_score(username='joe123')

  self.assertEqual(150.0, initial_score)

Я просто вставил код из метода setUp, но это многое изменило. Теперь все, что нужно читателю, находится прямо в тесте. Он также следует структуре arrange, act, assert, что делает каждую фазу теста отчетливой и очевидной.

Читатель должен понять ваш тест, не читая никакого другого кода.

Смело нарушайте DRY

Встраивание кода инициализации — это хорошо для одного теста, но что будет, если у меня много тестов? Не придется ли мне каждый раз дублировать этот код? Держитесь, потому что сейчас я буду пропагандировать программирование методом copy/paste.

Вот еще один тест того же класса:

def test_increase_score(self):
  database = MockDatabase()                  # <
  database.add_row({                         # <
      'username': 'joe123',                  # <--- Copy/pasted from
      'score': 150.0                         # <--- previous test
    })                                       # <
  account_manager = AccountManager(database) # <

  account_manager.adjust_score(username='joe123',
                         adjustment=25.0)

  self.assertEqual(175.0,
             account_manager.get_score(username='joe123'))

В строгих приверженцах принципа DRY (англ. “don’t repeat yourself” — «не повторяйся») приведенный выше код вызовет ужас. Я откровенно повторяюсь, я скопировал шесть строк из предыдущего теста. Хуже того, я утверждаю, что мои DRY-тесты лучше, чем тесты, в которых нет повторяющегося кода. Как такое может быть?

Если вы можете написать понятные тесты без дублирования кода, то это идеальный вариант, но помните, что отсутствие дублирования кода — это средство, а не цель. Конечная цель — понятные и простые тесты.

Прежде чем слепо применять метод DRY к тестам, подумайте о том, что сделает проблему очевидной, если тест не сработает. Рефакторинг может снизить количество дублирования, но он также увеличивает сложность и потенциально скрывает информацию при сбоях.

Смиритесь с избыточностью, если она поддерживает простоту.

Дважды подумайте, прежде чем добавлять хелперы

Может быть, вы можете смириться с копипастой шести строк в каждый тест, но что, если AccountManager потребует больше кода инициализации?

def test_increase_score(self):
  # vvvvvvvvvvvvvvvvvvvvv Beginning of boilerplate code vvvvvvvvvvvvvvvvvvvvv
  user_database = MockDatabase()
  user_database.add_row({
      'username': 'joe123',
      'score': 150.0
    })
  privilege_database = MockDatabase()
  privilege_database.add_row({
      'privilege': 'upvote',
      'minimum_score': 200.0
    })
  privilege_manager = PrivilegeManager(privilege_database)
  url_downloader = UrlDownloader()
  account_manager = AccountManager(user_database,
                                   privilege_manager,
                                   url_downloader)
  # ^^^^^^^^^^^^^^^^^^^^^ End of boilerplate code ^^^^^^^^^^^^^^^^^^^^^^^^^^^

  account_manager.adjust_score(username='joe123',
                         adjustment=25.0)

  self.assertEqual(175.0,
             account_manager.get_score(username='joe123'))

Эти 15 строк только для того, чтобы получить экземпляр AccountManager и начать его тестировать. На этом уровне так много шаблонов, что они отвлекают от тестируемого поведения.

Может возникнуть желание делегировать весь неинтересный код тестовым хелперам, но сначала следует задать более важный вопрос: почему систему так сложно тестировать?

Избыток шаблонного кода часто является симптомом слабой архитектуры. Например, в приведенном выше тесте можно обнаружить признаки кода с запашком:

account_manager = AccountManager(user_database,
                                 privilege_manager,
                                 url_downloader)

AccountManager обращается непосредственно к базе данных user_database, но следующим его параметром является privilege_manager, обертка для privilege_database. Почему он работает на двух разных уровнях абстракции? И что он делает с «загрузчиком URL»? Это, конечно, выглядит концептуально далеким от двух других параметров.

В данном случае рефакторинг AccountManager решает корневую проблему, в то время как добавление хелперов лишь прячет симптомы.

Когда возникает соблазн написать хелперы, попробуйте вместо этого отрефакторить продакшен код.

Если вам нужны хелперы, пишите их ответственно

Не всегда есть свобода действий, чтобы разделить класс из продакшен кода на части, чтобы обеспечить  его тестируемость. Иногда хелперы — это единственный выход, поэтому, если они нужны, пишите их хорошо.

Эффективный хелпер поддерживает принцип «держать читателя в рамках тестовой функции». Вполне допустимо извлекать из хелпера шаблонный код, если это не ухудшает понимания теста читателем.

В частности, хелперы не должны:

  • скрывать критические значения

  • взаимодействовать с тестируемым объектом.

Приведем пример хелпера, нарушающего эти рекомендации:

def add_dummy_account(self): # <- Helper method
  dummy_account = Account(username='joe123',
                          name='Joe Bloggs',
                          email='joe123@example.com',
                          score=150.0)
  # BAD: Helper method hides a call to the object under test
  self.account_manager.add_account(dummy_account)

def test_increase_score(self):
  self.account_manager = AccountManager()
  self.add_dummy_account()

  account_manager.adjust_score(username='joe123',
                               adjustment=25.0)

  self.assertEqual(175.0, # BAD: Relies on value set in helper method
                   account_manager.get_score(username='joe123'))

Читатель не поймет, почему итоговая оценка должна быть 175, если не найдет 150, скрытых в хелпере. Хелпер также скрывает поведение account_manager, пряча вызов add_account вместо того, чтобы сохранить все взаимодействия в самой тестовой функции.

Ниже приводится переписанный код, которая решает эти проблемы:

def make_dummy_account(self, username, score):
  return Account(username=username,
                 name='Dummy User',         # <- OK: Buries values but they're
                 email='dummy@example.com', # <-     irrelevant to the test
                 score=score)

def test_increase_score(self):
  account_manager = AccountManager()
  account_manager.add_account(
    make_dummy_account(
      username='joe123',  # <- GOOD: Relevant values stay
      score=150.0))       # <-       in the test

  account_manager.adjust_score(username='joe123',
                               adjustment=25.0)

  self.assertEqual(175.0,
                   account_manager.get_score(username='joe123'))

При этом значения в хелпере все равно остаются, но они не имеют отношения к тесту. Он также возвращает вызов add_account обратно в тест, чтобы читатель мог легко отследить все, что происходит с account_manager.

Не переусердствуйте с длиной имен тестов

Какие из этих имен функций вы предпочли бы видеть в продакшен коде?

  • userExistsAndTheirAccountIsInGoodStandingWithAllBillsPaid

  • isAccountActive

Первый вариант передает больше информации, но содержит целых 57 символов. Большинство разработчиков готовы пожертвовать некоторой точностью в пользу краткого, почти такого же хорошего имени, как isAccountActive (за исключением Java-разработчиков, для которых оба имени лаконичны).

Для тестовых функций есть важный фактор, который меняет уравнение: вы никогда не пишете вызовов тестовых функций. Разработчик набирает имя теста ровно один раз — в сигнатуре функции. При этом краткость все равно имеет значение, но меньшее, чем в продакшен коде.

Когда тест ломается, первым делом вы видите его имя, поэтому оно должно сообщать как можно больше информации. Например, рассмотрим такой класс в продакшен коде:

class Tokenizer {
 public:
  Tokenizer(std::unique_ptr<TextStream> stream);
  std::unique_ptr<Token> NextToken();
 private:
  std::unique_ptr<TextStream> stream_;
};

Предположим, что вы запустили свой тест-сьют, и в выводе появилась такая строка:

[  FAILED  ] TokenizerTests.TestNextToken (6 ms)

Можете ли вы узнать, что стало причиной неудачи теста? Скорее всего, нет.

Сбой в TestNextToken говорит о том, что вы испортили метод NextToken(), но это бессмысленно в классе с единственным публичным методом. Чтобы диагностировать сбой, необходимо прочитать реализацию теста.

А что, если бы вы увидели следующее:

[  FAILED  ] TokenizerTests.ReturnsNullptrWhenStreamIsEmpty (6 ms)

Название функции ReturnsNullptrWhenStreamIsEmpty в других контекстах показалось бы излишне многословным, но это хорошее тестовое имя. Если он сломается, вы сразу поймете, что класс неправильно работает с пустыми потоками данных. Скорее всего, вы сможете исправить багу, даже не читая реализацию теста. Это и есть признак хорошего имени теста.

Давайте тестам такие имена, чтобы другие могли диагностировать сбой по одному только названию.

Откажитесь от магических чисел

«Не используйте магические числа».

Это как правило «не разговаривать с незнакомцами» в мире программирования. Многие опытные разработчики настолько глубоко усваивают этот урок, что никогда не задумываются о том, когда магическое число может улучшить их код.

Для справки: магическим числом называют числовое значение или строку, которая появляется в коде без информации о том, что она представляет собой. Вот пример:

calculate_pay(80) # <-- Magic number

Программисты согласны с тем, что магические числа в продакшен коде — это очень плохо, поэтому они заменяют их именованными константами, такими как эта:

HOURS_PER_WEEK = 40
WEEKS_PER_PAY_PERIOD = 2
calculate_pay(hours=HOURS_PER_WEEK * WEEKS_PER_PAY_PERIOD)

К сожалению, существует заблуждение, что магические числа также ослабляют код теста, однако верно и обратное.

Рассмотрим следующий тест:

def test_add_hours(self):
  TEST_STARTING_HOURS = 72.0
  TEST_HOURS_INCREASE = 8.0
  hours_tracker = BillableHoursTracker(initial_hours=TEST_STARTING_HOURS)
  hours_tracker.add_hours(TEST_HOURS_INCREASE)
  expected_billable_hours = TEST_STARTING_HOURS + TEST_HOURS_INCREASE
  self.assertEqual(expected_billable_hours, hours_tracker.billable_hours())

Если вы считаете магические числа всеобщим злом, то приведенный выше тест выглядит корректным. 72.0 и 8.0 имеют именованные константы, поэтому никто не может обвинить тест в магии чисел.

Но на мгновение отбросьте свои религиозные убеждения и прикоснитесь к запретному плоду магических чисел:

def test_add_hours(self):
  hours_tracker = BillableHoursTracker(initial_hours=72.0)
  hours_tracker.add_hours(8.0)
  self.assertEqual(80.0, hours_tracker.billable_hours())

Он проще, содержит в два раза меньше строк кода. И он более наглядный — читателю не нужно бегать по функциям, отслеживая имена констант.

Когда я вижу, что разработчики определяют константы в коде тестов, это обычно связано с неправильным следованием DRY или с боязнью использовать магические числа. Однако в тестах редко возникает необходимость в объявлении констант, и это усложняет их понимание.

Предпочитайте магические числа именованным константам в коде тестов.

Примечание: Вполне допустимо, чтобы юнит-тесты ссылались на константы из продакшен кода. Они просто не должны определять свои собственные.

Заключение

Чтобы написать хорошие тесты, разработчик должен согласовать свои инженерные решения с целями кода тестов. Главное, тесты должны быть максимально простыми и при этом минимально абстрагированными. Хороший тест позволяет читателю понять предполагаемое поведение и диагностировать проблемы, не покидая тестовой функции.

Освоить больше лучших практик и актуальных инструментов по написанию тестов можно на онлайн-курсах в Отус под руководством экспертов области. А начинающих QA-инженеров приглашаем на бесплатный открытый урок, посвященный инструментам ручного тестирования. Записаться можно по ссылке.

Комментарии (4)


  1. Deirel
    21.10.2023 00:23

    Спасибо! Мне, как человеку, которому предстоит начать писать тексты впервые в жизни, было полезно.)


  1. slonopotamus
    21.10.2023 00:23

    Почему хорошие разработчики пишут плохие юнит-тесты

    Потому автор использует такое выдуманное им определение "хорошести", в которое не входят юнит-тесты.


  1. aavezel
    21.10.2023 00:23
    +1

    Смиритесь с избыточностью, если она поддерживает простоту.

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


    1. ggo
      21.10.2023 00:23

      на любую здравую идею можно найти исключение.

      это не делает идею менее здравой.