Unit test что это

Анатомия юнит-теста

Эта статья является конспектом книги «Принципы юнит-тестирования». Материал статьи посвящен структуре юнит-теста.

В этой статье рассмотрим структуру типичного юнит-теста, которая обычно описывается паттерном AAA (arrange, act, assert — подготовка, действие и проверка). Затронем именование юнит-тестов. Автор книги описал распространенные советы по именованию и показал, почему он несогласен с ними и привел альтернативы.

Структура юнит-теста

Согласно паттерну AAA (или 3А) каждый тест разбивается на три части: arrange (подготовка), act (действие) и assert (проверка). Возьмем для примера класс Calculator с методом для вычисления суммы двух чисел:

Unit test что это. Смотреть фото Unit test что это. Смотреть картинку Unit test что это. Картинка про Unit test что это. Фото Unit test что этоРис. 1 – Листинг теста для проверки поведения класса по схеме ААА

Паттерн AAA предоставляет простую единообразную структуру для всех тестов в проекте. Это единообразие дает большое преимущество: привыкнув к нему, вы сможете легко прочитать и понять любой тест. Структура теста выглядит так:

в секции подготовки тестируемая система (system under test, SUT) и ее зависимости приводятся в нужное состояние;

в секции действия вызываются методы SUT, передаются подготовленные зависимости и сохраняется выходное значение (если оно есть);

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

Как правило, написание теста начинается с секции подготовки (arrange), так как она предшествует двум другим. Но начинать писать тесты можно также и с секции проверки (assert). Если практиковать разработку через тестирование (TDD), то вы еще не знаете всего о поведении этой функциональности. Возможно, будет полезно сначала описать, чего вы ожидаете от поведения, а уже затем разбираться, как создать систему для удовлетворения этих ожиданий. В этом случае сначала мы думаем о цели: что разрабатываемая функциональность должна делать для нас. А затем начинаем решать задачу. Но следует еще раз подчеркнуть, что эта рекомендация применима только в том случае, когда практикуется TDD. Если вы пишете основной код до кода тестов, то к тому моменту, когда вы доберетесь до теста, вы уже знаете, чего ожидать от поведения, и лучше будет начать с секции подготовки.

Есть еще паттерн «Given-When-Then», похожем на AAA. Этот паттерн также рекомендует разбить тест на три части:

Given — соответствует секции подготовки (arrange);

When — соответствует секции действия (act);

Then — соответствует секции проверки (assert)

В отношении построения теста эти два паттерна ничем не отличаются. Единственное отличие заключается в том, что структура «Given-When-Then» более понятна для непрограммиста. Таким образом, она лучше подойдет для тестов, которые вы собираетесь показывать людям, не имеющим технической подготовки.

Избегайте множественных секций arrange, act и assert

Иногда встречаются тесты с несколькими секциями arrange (подготовка), act (действие) или assert (проверка).

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

Интеграционным тестом называется тест, который не удовлетворяет хотя бы одному из следующих критериев: проверяет одну единицу поведения, делает это быстро и в изоляции от других тестов.

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

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

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

Избегайте команд if в тестах

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

Присутствие команды if означает, что тест проверяет слишком много всего. Следовательно, такой тест должен быть разбит на несколько тестов. Но в отличие от ситуации с множественными секциями AAA, здесь нет исключений для интеграционных тестов. Ветвление в тестах не дает ничего, кроме дополнительных затрат на сопровождение: команды if затрудняют чтение и понимание тестов.

Насколько большой должна быть каждая секция?

Насколько большой должна быть каждая секция? И как насчет завершающей (teardown) секции — той, что должна «прибирать» после каждого теста?

Секция подготовки обычно является самой большой из трех. Если она становится слишком большой, лучше выделить отдельные операции подготовки либо в приватные методы того же класса теста, либо в отдельный класс-фабрику. Есть несколько популярных паттернов, которые помогут организовать переиспользование кода в секциях подготовки: «Мать объектов» (Object Mother) и «Построитель тестовых данных» (Test Data Builder).

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

Обратите внимание: секция действия (act) в этом тесте состоит из вызова одного метода, что является признаком хорошо спроектированного API класса. Теперь сравним ее с версией, в которой секция действия состоит из двух строк. Это признак проблемы с API тестируемой системы: он требует, чтобы клиент помнил о необходимости второго вызова метода для завершения покупки, следовательно, тестируемая система недостаточно инкапсулирована.

В новой версии клиент сначала пытается приобрести пять единиц товара в магазине. Затем товар удаляется со склада. Удаление происходит только в том случае, если предшествующий вызов Purchase() завершился успехом.

Недостаток новой версии заключается в том, что она требует двух вызовов для выполнения одной операции. Следует заметить, что это не является проблемой самого теста. Тест проверяет ту же единицу поведения: процесс покупки. Проблема кроется в API класса Customer. Он не должен требовать от клиента дополнительного вызова. Если клиентский код вызывает первый метод, но не вызывает второй; в этом случае клиент получит товар, но количество товара на складе при этом не уменьшится.

Такое нарушение логической целостности называется нарушением инварианта (invariant violation). Защита кода от потенциальных нарушений инвариантов называется инкапсуляцией (encapsulation). Когда нарушение логической целостности проникает в базу данных, оно становится серьезной проблемой; теперь не удастся сбросить состояние приложения простым перезапуском. Придется разбираться с поврежденными данными в базе и, возможно, связываться с клиентами и решать проблему с каждым из них по отдельности.

Проблема решается поддержанием инкапсуляции кода. В предыдущих примерах удаление запрашиваемого товара со склада должно быть частью метода Purchase. Сustomer не должен полагаться на то, что клиентский код сделает это сам, вызвав метод store.RemoveInventory. Когда речь заходит о поддержании инвариантов в системе, вы должны устранить любую потенциальную возможность нарушить эти инварианты.

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

Сколько проверок должна содержать секция проверки?

Так как под «юнитом» в юнит-тестировании понимается единица поведения, а не единица кода, то одна единица поведения может приводить к нескольким результатам. Проверять все эти результаты в одном тесте вполне нормально.

Тем не менее, если секция проверки получается слишком большой: это может быть признаком того, что в коде недостает какой-то абстракции. Например, вместо того чтобы по отдельности проверять все свойства объекта, возвращенного тестируемой системой, возможно, будет лучше добавить методы проверки равенства (equality members) в класс такого объекта. После этого объект можно будет сравнивать с ожидаемым значением всего одной командой.

Нужна ли завершающая (teardown) фаза?

Иногда еще выделяют четвертую — завершающую (teardown) — секцию, которая следует после остальных. Например, в завершающей секции можно удалить любые файлы, созданные в ходе теста, закрыть подключение к базе данных и т. д. Завершение обычно представляется отдельным методом, который переиспользуется всеми тестами в классе. По этой причине автор книги не включил эту фазу в паттерн AAA.

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

Переиспользование тестовых данных между тестами

Важно понимать, как и когда переиспользовать код между тестами. Переиспользование кода между секциями подготовки — хороший способ сокращения и упрощения ваших тестов.

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

Первый (неправильный) способ переиспользования тестовых данных — инициализация их в конструкторе теста. Такой подход позволяет значительно сократить объем кода в тестах — вы можете избавиться от большинства (или даже от всех) конфигураций в тестах. Однако у этого подхода есть два серьезных недостатка.

Он создает сильную связность (high coupling) между тестами. Изменение логики подготовки одного теста повлияет на все тесты в классе. Тем самым нарушается важное правило: изменение одного теста не должно влиять на другие тесты. Чтобы следовать этому правилу, необходимо избегать совместного состояния (shared state) в классах тестов.

Другой недостаток выделения кода подготовки в конструктор — ухудшение читаемости теста. С таким конструктором просмотр самого теста больше не дает полной картины. Чтобы понять, что делает тест, приходится смотреть в два места: сам тест и конструктор тест-класса.

Второй (правильный) способ — написать фабричные методы, как показано ниже.

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

Обратите внимание: в этом конкретном примере писать фабричные методы не обязательно, так как логика подготовки весьма проста. Этот код приводится исключительно в демонстрационных целях.

Именование юнит-тестов

Правильное именование помогает понять, что проверяет тест и как работает система. Как же выбрать имя для юнит-теста? Есть много рекомендаций на эту тему. Одна из самых распространенных (и, пожалуй, одна из наименее полезных по мнению автора книги) рекомендаций выглядит так:

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

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

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

Рекомендации по именованию юнит-тестов

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

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

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

Также обратите внимание на то, что, хотя автор использует паттерн [ИмяКласса]Tests при выборе имен классов тестов, это не означает, что тесты ограничиваются проверкой только этого класса. Юнитом в юнит-тестировании является единица поведения, а не класс. Единица поведения может охватывать один или несколько классов. Рассматривайте класс в [ИмяКласса]Tests как точку входа — API, при помощи которого можно проверить единицу поведения.

Вывод

Все юнит-тесты должны строиться по схеме AAA: подготовка (Arrange), действие (Act), проверка (Assert). Если тест состоит из нескольких секций подготовки, действий или проверки, это указывает на то, что тест проверяет сразу несколько единиц поведения. Если этот тест — юнит-тест, разбейте его на несколько тестов: по одному для каждого действия.

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

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

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

Источник

Другая сторона медали или про недостатки юнит-тестирования

Введение

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

На самом деле «серебряные пули» есть, но их изобрели ещё первые инженеры, и они воспринимаются нами как скучные банальности: «мойте руки перед едой», «вытирайте ноги», «структурируйте код», «не пишите без отступов», «локализируйте состояние» и т.д. Тем не менее, тесты — это не «серебряная пуля», а один из эффективных и широко используемых инструментов, а значит, у него есть недостатки.

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

Почему функциональное программирование? Так тестируем же мы почти исключительно функции.

Прощание с иллюзиями или 33 банальности

Это, в общем, не секрет, что даже 100% покрытие тестами не гарантирует правильного поведения программы. Для примера глянем на код:

Мы прошли по всем веткам, всё замечательно, но вот доказали ли мы, что функция f всегда возвращает двойку?

Да, можно сказать, что это не 100% покрытие всех путей выполнения, но, боюсь, это автоматически определить, действительно ли мы прошли по всем возможным путям, невозможно. Обычные юнит-тесты неизбежно проверяют лишь небольшое кол-во точек в пространстве параметров тестируемой функции. Их можно использовать для проверки каких-то очень важных особых случаев, но считать это покрытие «полным» несколько наивно.

Поэтому перейдём к property-base testing: это знаменитый QuickCheck из Haskell, GAST из Clean, Kotlintest, QCheck из Ocaml, Hypothesis для Python’а и другие. Они покрывают сразу большое и случайное количество параметров тестируемой функции. Увы и ах, но это тоже не серебряная пуля: у них есть свои особенности, свои проблемы и области применимости.

На языке физики, в первом приближении эти библиотеки гоняют Монте-Карло, перебирая разные пути выполнения или разные варианты редукции графа, как траектории пролёта элементарной частицы. Прямо как в Geant4 мы задаём «источники» (генераторы), «рисуем геометрию» (записываем свойства) и запускаем расчёт (когда на 5 секунд, а когда и на сутки).

И ровно также, как в физике высоких энергий, из-за относительно малого количества запусков мы можем пропустить что-то очень интересное. Мне доводилось видеть, как даже 20 000 прогонов не хватало, чтобы найти ошибку в функции, перемножающей полиномы в символьном виде — требовалось 50 000 запусков. И даже миллион траекторий не может гарантировать того, что не вылезет ошибка на втором миллионе.

В общем, property-based testing — это эксперимент, который надо уметь ставить, и его результаты тоже надо уметь обрабатывать. Об этом прекрасно рассказал автор QuickCheck в видео John Hughes — Building on developers’ intuitions (. ) | Lambda Days 19. У программистов же далеко не всегда есть возможность и умение вникнуть во все эти дисперсии, распределения.

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

И, наконец, контрольный вопрос: что покажет тестирование свойства ниже?

2 + 2 = 5? А если протестирую?

Эту нехитрую, но глубокую мысль я увидел здесь, на Хабре, в одном из комментариев ув. Jef239.

Заметьте, что если юнит-тест мы используем для проверки нового кода, то желательно, чтобы код и проверяющий его тест писали разные люди. Ведь если программист по какой-то причине решит, что месяц Январь следует за Февралём, то он так и напишет в двух местах, причём ещё и методом copy-paste:

То есть, говоря языком статистики, происходит «систематическая» ошибка. Суть проблемы заключается в том, что программист неправильно понимает контекст, в рамках которого работает его программа: либо предметную область, либо постановку задачи.

Представьте, к примеру, что тест написан другим программистом, у которого год начинается с марта. Они поспорят, может быть даже подерутся, но в конце-концов непременно отыщут истину!

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

Юнит-тесты — это код

Собственно, заголовка может быть и достаточно — большая часть программистов может выписать все минусы кода сразу, не приходя в сознание после сна. То, что тесты — это код, текст на каком-то языке программирования, означает:

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

Кто-то его должен отладить. Как это ни смешно, в тестах тоже бывают ошибки. Их, конечно, отладить проще, тем не менее, это таки надо сделать.

Кто-то должен отрецензировать эти тесты, потратить уйму времени, ведь тесты длинные (см. пункт 1). Ужасный расход средств, дорогого времени старших программистов, тимлидов. Так и в трубу вылететь недолго, если это стартап!

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

Кто-то должен поддерживать тесты. Если мы говорим не об одной из Стандартных Библиотек, тестируемый код и требования к нему будут меняться. Непонятно столько раз за время жизни кода и программиста, но обязательно. Значит придётся подправлять и юнит-тесты, разбираться в них, а ведь кода там много, значит опять потери.

Все пункты выше — это расходы времени программистов разного уровня, то есть деньги.

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

Юнит-тесты — это запускающийся код

Опять, пессимисту достаточно заголовка, а дальше он сам придумает. Но, всё же, раскроем тему.

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

Прогон тестов занимает физическое время. То есть, программист вынужден оторваться от задачи в лучшем случае на несколько секунд, а в худшем — пойти пить чай, т.к. до вечера тесты всё равно не пройдут. А ведь время итерации write-check-correct loop — это важнейшая характеристика, напрямую влияющая на производительность программиста.

Кстати, полный набор тестов компилятора Ocaml прогоняется примерно за час — не то, чтобы критично, но при работе с какой-то частью компилятора приходится делать «стенд» — выделять отдельный тест(ы) и прогонять его за секунды. Хотя все тесты совершенно по-делу, отмечу для протокола, что весь компилятор с нуля собирается минуты за две.

Прогон тестов занимает машинное время с соответствующим расходом машин и электроэнергии. Где-то это не критично, а где-то таки да.

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

Юнит-тесты — это работающий код без пользователей

Очень важный момент заключается в том, что по-сути, у юнит-тестов нет пользователя, кроме их автора. То есть, нет человека, который в них заинтересован, готов их подправлять, поддерживать. У обычной программы, как правило, есть пользователи, которые либо сами правят, либо находят ошибки и пишут автору. За счёт этого даже какие-то древние вещи вроде WindowMaker, Quake I, Heroes 2 до сих пор живут и здравствуют, портируются на другие системы, развиваются. Даже если программа заморожена, как TeX, с пользователями она живёт, а без пользователей умирает.

Это же относится и к таким программам, как юнит-тесты. Это ведь программы, а не просто письмена на жёстком диске. А значит, они тоже могут деградировать и портиться — в компьютерном мире как нигде «всё течёт, всё меняется».

Как сказано выше, неизбежно качество и документация юнит-тестов не может, да и не должна быть выше, чем качество основного кода. Значит, их труднее поддерживать, а какого-то положительного эффекта для работника от их обновления почти нет. This does not add business value, right?

Смотрите, если вы — разработчик, и у вас какой-то тест протух по каким-то внешним причинам, и не пускает ваши изменения в общий репозиторий, то у вас есть два выхода: исправить тест или обойти его. Причём соблазн обойти иногда крайне велик — при переходе, скажем, с Python 2 на Python 3 или серьёзном обновлении библиотек Boost, портирование даже короткой программы может занять дни. И всё это время вам будут капать на мозги, что же это ваша работа не сделана!

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

То есть, тесты гарантированно «защищают» основной код программы лишь ограниченное время — год, два, три.

Заключение

Вроде бы я перечислил всё, что накопилось на душе. Наверняка это не всё, и наверняка часть из претензий надумана. Например, я совершенно не рассмотрел интеграционные тесты, не затронул mutation testing, без сомнения упустил ещё что-то важное.

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

Разумеется, юнит-тесты — это мощнейший инструмент, но как у любого инструмента, их применение имеет свою цену. Лишь «идеальная система – это система, которой нет, а её функции выполняются».

То есть, они должны находиться в конвейере после системы типов, а не вместо неё. Как, впрочем, всё и устроено в типичном функциональном программировании. Так что у нас всё хорошо!

Источник

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *