Pull to refresh

Читабельный тест

Reading time8 min
Views13K

Вступление

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

Юнит. Что это такое?

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

Юнит – это фрагмент кода, дающий в данном окружении при определенных входных данных определенные выходные данные.

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


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

Пустое окружение
Это наиболее распространенная конфигурация для юнитов, написанных в стиле структурной декомпозиции. Функция, получающая аргументы и возвращающая результат – это пример юнита, имеющего в качестве входных данных множество аргументов и в качестве выходных данных – результат. Связь результата со входными параметрами и есть суть функции. Например, функция y = sin(x) является отображением множества всех вещественных чисел на отрезок [-1,1]. Для каждого входного x однозначно определен y, и ответственность функции sin(x) заключается в обеспечении этой однозначности. sin(x) будет отображать эти x в эти y в любом окружении для любого пользователя, в любое время года. Ему не интересно окружение.

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

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

void f()
Функция, ничего не принимающая, ничего не возвращающая и никак не взаимодействующая с окружением имеет мало смысла. Эта некая вещь в себе, которую лучше не беспокоить – не использовать и не создавать вообще.

Состояние и юниты
В свете вышеприведенных примеров и определения посмотрим на функцию void std::list::remove(const T& elem). Допустим, elem – это входные данные. А как же быть с окружением и выходными данными? Выходных данных нет – функция void. А окружение тогда что? Формально – память. Но память – это сущность более низкого уровня чем список. Ведь никто же не будет утверждать, что «удаление из списка – это освобождение памяти по какой-то хитрой схеме». Если попытаться определить, что значит (какая у него семантика) «удаление из списка», то звучать это будет примерно так: «удаление из списка некоего элемента – это изменение состояния списка так, что получить этот элемент из списка больше не представляется возможным». Итак, определение семантики удаления из списка делается на том же самом уровне абстракции, что и само удаление, задействовав другую операцию из интерфейса списка. Тем самым, сама функция list::remove юнитом не является. Саму функцию remove в отрыве от всего определить (и проверить) невозможно. Список имеет смысл только в совокупности операций над ним. Именно список – это юнит. Входными данными в него является множество пар (command, command_arguments), а выходными данными пары (query, query_results). Кстати, такой дизайн STL соответствует принципу CQS (command query separation), в котором методы, меняющие состояние и возвращающие его отделены друг от друга (сделано это в том числе ради безопасности к исключениям). В общем случае, однако, у класса множество выходных данных может также задействовать результаты некоторых его операций, имеющих входные данные. Плохо, но возможно. Кстати, вопрос, а как насчет окружения? Неужели у std::list тоже environment agnostic? Нет! В него можно подать allocator! Работа с памятью ведется списком через алокатор. Список явно декларировал свою зависимость от окружения. Молодец!

Читабельные тесты

Казалось бы, при чем здесь теория юнит тестов, когда обещали поговорить о читабельности тестов? Ответ прост:

Читабельным является тот юнит тест, в котором все четыре компонента, участвующие в определении юнита: юнит, входные и выходные данные и окружение, очевидны.

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

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

Прежде чем давать конкретные рекомендации, сделаем некоторые определения, чтобы не было двусмысленности. Итак, типовой тест, написанный с использованией фреймворка для юнит тестирования gtest, выглядит так:
TEST(Subject, Assertion)
{
  // Body
}

или так:
TEST_F(Subject, Assertion)
{
  // Body
}

Правила


Явность юнита
Правило: Subject должен однозначно указывать на юнит или system under test.
Пояснение: Subject должен быть существительным английского языка (предпочтительно несоставным и коротким), обозначающим юнит. Все тесты в данном файле должны иметь этот юнит своим Subject-ом, причем микс макросов TEST и TEST_F с одним и тем же Subject-ом запрещен.
Примеры:

Плохо:
TEST(GetDiskSignature, ReadsFirstSector)
TEST(SetDiskSignature, WritesToFirstSector)

Хорошо:
TEST(DiskSignature, ResidesOnFirstSector)

Предикативность теста
Правило: Assertion должен содержать полноценное утверждение (повествовательное предложение в настоящем времени) на английском языке, подлежащее смысловой проверке.
Пояснение: От прочтения утверждения у читателя должно возникнуть некоторое ожидание от содержимого теста. То есть, прочтя этот Assertion про заданный Subject, у читателя в голове должен сложиться план, как бы данное утверждение проверил он сам. Именно поэтому в качестве сказуемого предиката строго настрого запрещены такие паразитные слова как «correctly», «good», «fine», «well» и тому подобные этически-моралистические эпитеты. Суть теста как раз в том, чтобы раскрыть что значит «correct», «good» итд. Такие слова в предикате позволительны исключено в качестве определяемых сущностей, а не как самих определений.
Примеры:

Плохо:
TEST(FileCache, IsInitializedCorrectly)

Хорошо:
TEST(FileCache, IsInitializedAsEmpty)


Использование сущностей из Assertion в Body
Правило: Идентификаторы, используемые в теле теста, должны повторно использовать термины, употребленные в утверждении.
Пояснение: Что может быть для читателя очевиднее при чтении теста, чем дословное повторение терминов из утверждения? Если читателю сделали некоторое качественное обещание в Assertion и у него сложились некоторые ожидания, что будет в тесте, ничто так не поможет ему цепляться за употребляемые сущности как повторение терминов из утверждения. Допускаются мелкие модификации, такие как изменение падежа, числа и даже употребления всяких коротких суффиксов и префиксов – человеческий мозг (тем более русский) очень эффективно борется с такими трансформациями символов, считая их эквивалентными оригинальному символу. Чем больше мутаций в термине, тем сложнее его узнавать читателю. Поэтому даже синонимы не приветствуются.
Примеры:

Плохо:
TEST_F(MRUCache, MovesLastAccessedItemToFront)
{
  Items.Touch("http://facebook.com/");
  Items.Touch("http://habrahabr.ru/");
  EXPECT_EQ(0, Items.GetIndex("http://habrahabr.ru/"));
}

Хорошо:
TEST_F(MRUCache, SetsIndexOfLastTouchedItemToZero)
{
  MRUCache.Touch(Item("http://facebook.com/"));
  MRUCache.Touch(Item("http://habrahabr.ru/"));
  EXPECT_EQ(0, MRUCache.GetIndex(Item("http://habrahabr.ru/")));
}

Хорошо:
TEST_F(MRUCache, MovesLastAccessedItemToFront)
{
  MRUCache.Access(Item("http://facebook.com/"));
  MRUCache.Access(Item("http://habrahabr.ru/"));
  EXPECT_EQ(Item("http://habrahabr.ru/"), MRUCache.Front());
}


Явность тестовых данных
Правило: Все тестовые данные (как входные, так и выходные), ровно как и данные читаемые из окружения или пишущиеся в окружение, должны присутствовать в теле теста.
Пояснение: При чтении первое, за что цепляется сторонний читатель – эксперт предметной области – это знакомые данные. Человек с колыбели строит свое познание от частного к общему, поэтому частную конкретику человек воспринимает лучше всего остального. Именно удачно подобранными данными, собранными в одном контексте достигается наибольшая выразительность теста. Это настолько важный аспект читабельного теста, что антипаттерны нарушения этого правила будут вынесены в отдельную статью и показаны альтернативы борьбы с ними.
Примеры:

Плохо:
const Common::String SomeUnixPath = GET_WCHAR("/var/log");
const int SomeUnixPathComponents = 2;
 
...
 
TEST(UnixPath, ContainsSlashes)
{
  EXPECT_EQ(SomeUnixPathComponents, Paths::Unix(SomeUnixPath).Components());
}

Хорошо:
Common::String Path(const char* value)
{
  return GET_WCHAR(value);
}
 
...
 
TEST(UnixPath, ContainsSlashes)
{
  EXPECT_EQ(2, Paths::Unix(Path("/var/log")).Components());
}


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

Плохо:
MockFileSystem Files;
 
void AddFile(std::string path, int size)
{
  ON_CALL(Files, Get(path.c_str()).WillByDefault(Return(File(size)));
}
 
...
 
TEST(FileStatistics, SumsFileSizes)
{
  AddFile("/bin/ls", 10);
  AddFile("/bin/bash", 20);
  EXPECT_EQ(30, GetStats(Files, "/bin").Size);
}

Хорошо:
MockFileSystem Files;
 
void AddFile(MockFileSystem& fs, std::string path, int size)
{
  ON_CALL(fs, Get(path.c_str()).WillByDefault(Return(File(size)));
}
 
...
 
TEST(FileStatistics, SumsFileSizes)
{
  AddFile(Files, "/bin/ls", 10);
  AddFile(Files, "/bin/bash", 20);
  EXPECT_EQ(10 + 20, GetStats(Files, "/bin").Size);
}


Заключение

В данной статье рассмотрена теория и практика написания читабельных тестов. Приведенные примеры не исчерпывают весь спектр трюков, которые делают тест лучше – это всего лишь выжимки, которые я заметил в своих тестах, ровно как и в чужих просматриваемых тестах. Основное правило – пишите тест самодостаточным. Если что и выносится во вспомогательную функцию, то само имя функции, ровно как и ее сигнатуры должны быть настолько очевидными, чтобы не возникало необходимости и желания смотреть ее реализацию. Выносите, чтобы скрыть несущественное и назвать существенное!
Tags:
Hubs:
+9
Comments10

Articles