Существуют две основные школы юнит-тестирования: классическая (ее также называют школой Детройта, или Чикаго) и лондонская (ее также называют мокистской школой, от слова mock).
Эти школы кардинально отличаются друг от друга в подходе к юнит-тестированию, но все эти отличия можно свести к расхождению во мнениях о том, что является юнит-тестом. В этой статье обсудим, как именно школы интерпретируют это понятие и к каким отличиям это приводит.
Определение юнит-теста
Что же такое «юнит-тест»? Так называется автоматизированный тест, который:
проверяет правильность работы небольшого фрагмента кода (также называемого юнитом)
делает это быстро
и поддерживая изоляцию от другого кода.
Суть различий между классической и лондонской школой юнит-тестирования сводится к третьему атрибуту. Все остальные отличия двух школ проистекают из несогласия относительно того, что же именно означает «изоляция».
Лондонская школа описывает это как изоляцию тестируемого кода от его зависимостей. Это означает, что если класс имеет зависимость от другого класса или нескольких классов, все такие зависимости должны быть заменены на тестовые заглушки (test doubles). Такой подход позволяет сосредоточиться исключительно на тестируемом классе, изолировав его поведение от внешнего влияния.
В классическом подходе изолируются друг от друга не фрагменты рабочего кода, а сами тесты. Такая изоляция позволяет запускать тесты параллельно, последовательно и в любом порядке, не влияя на результат работы этих тестов.
Классический подход к изоляции не запрещает тестировать несколько классов одновременно, при условии что все они находятся в памяти и не обращаются к совместному состоянию (shared state), через которое тесты могут влиять на результат выполнения друг друга. Типичными примерами такого совместного состояния служат внепроцессные (out-of-process) зависимости — база данных, файловая система и т. д.
Такой подход к вопросу изоляции приводит к намного меньшему использованию моков и других тестовых заглушек по сравнению с лондонским подходом: как правило, только для внепроцессных зависимостей, чтобы избежать влияния тестов друг на друга.
Кроме третьего атрибута, оставляющего место для разных интерпретаций, первый атрибут также интерпретируется неоднозначно. Насколько небольшим должен быть небольшой фрагмент кода (юнит)?
Если придерживаться лондонского подхода и изолировать каждый класс, то естественным следствием этого подхода будет то, что юнит должен быть одним классом или методом внутри класса. Время от времени вы можете тестировать пару классов одновременно, но в большинстве случаев тестироваться будет только один класс.
В классическом подходе юнит не обязан ограничиваться классом. Вы можете юнит-тестировать как один класс, так и несколько классов, при условии что ни один из них не является внепроцессной зависимостью.
Ниже приведен тест, написанный в классическом и лондонском стиле.
Классический стиль:
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
// Arrange
var store = new Store();
store.AddInventory(Product.Shampoo, 10);
var customer = new Customer();
// Act
bool success = customer.Purchase(
store, Product.Shampoo, 5);
// Assert
Assert.True(success);
Assert.Equal(5, store.GetInventory(Product.Shampoo));
// Количество товара на складе уменьшилось на 5
}
public enum Product
{
Shampoo,
Book
}
А вот тот же тест, переписанный в лондонском стиле:
[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
// Arrange
var storeMock = new Mock<IStore>();
storeMock
.Setup(x => x.HasEnoughInventory(Product.Shampoo, 5))
.Returns(true);
var customer = new Customer();
// Act
bool success = customer.Purchase(
storeMock.Object, Product.Shampoo, 5);
// Assert
Assert.True(success);
storeMock.Verify(
x => x.RemoveInventory(Product.Shampoo, 5),
Times.Once);
}
Обратите внимание, насколько эти тесты отличаются друг от друга. В фазе подготовки (arrange) лондонский тест уже не создает полнофункциональный экземпляр Store
. Вместо этого он заменяется на заглушку (мок) при помощи класса Mock<T>
из библиотеки Moq.
Также вместо того, чтобы изменять состояние Store добавлением в него товара для дальнейшей покупки, мы напрямую сообщаем моку, как следует реагировать на вызовы HasEnoughInventory()
. Мок реагирует на этот запрос так, как требуется тесту, независимо от фактического состояния Store
. Более того, тесты вообще не используют Store
— мы добавили интерфейс IStore
и используем этот интерфейс вместо класса Store.
Фаза проверки тоже изменилась, и именно здесь кроется ключевое различие. Лондонский тест проверяет результат работы метода customer.Purchase
так же, как и классический тест, но взаимодействие между Customer и Store теперь проверяется по-другому. В классическом тесте для этого используется состояние магазина. Лондонский же анализирует взаимодействия между Customer и Store: тест проверяет, какой метод и с какими параметрами Customer вызвал у Store. Для этого в мок передается вызываемый метод (x.RemoveInventory), а также сколько раз этот метод должен был вызываться в течение работы теста (Times.Once).
Отличия школ юнит-тестирования
К каким же именно отличиям приводит такое расхождение во мнениях о том, что является юнит-тестом?
Мы уже обсудили первое (и, пожалуй, самое главное) отличие — частоту использования моков. Следует отметить, что несмотря на повсеместное использование моков, лондонская школа все же позволяет использовать в тестах некоторые зависимости без их замены на заглушки. Основное различие здесь в том, является ли зависимость изменяемой: лондонская школа допускает не использовать моки для неизменяемых объектов.
В тесте, написанном в лондонском стиле, видно, что на мок был заменен только Store:
// Act
bool success = customer.Purchase(
storeMock.Object, Product.Shampoo, 5);
Причина как раз в том, что из трех аргументов метода Purchase только Store содержит внутреннее состояние, которое может изменяться со временем. Экземпляры Product (тип Product — перечисление (enum) C#) и число 5 неизменяемы.
В таблице ниже показаны детали различия между лондонской и классической школами, разбитые по трем темам: подход к изоляции, размер юнита и использование тестовых заглушек (моков).
Что изолируется |
Размер юнита |
Для чего используются моки |
|
Лондонская школа |
Юниты |
Класс |
Любые изменяемые зависимости |
Классическая школа |
Юнит-тесты |
Класс или набор классов |
Внепроцессные (out-of-process) зависимости |
Я предпочитаю классическую школу юнит-тестирования. На мой взгляд, она обычно приводит к тестам более высокого качества, а следовательно, лучше подходит для достижения цели юнит-тестирования — стабильного роста вашего проекта. Причина кроется в хрупкости: тесты, использующие моки, обычно бывают более хрупкими, чем классические тесты.
Рассмотрим основные привлекательные стороны лондонской школы и оценим их. Как я уже упоминал, все эти различия между лондонским и классическим подходами — следствие того, как школы интерпретируют аспект изоляции в определении юнит-теста.
Юнит-тестирование одного класса за раз
Лондонский подход, в отличие от классического, приводит к лучшей детализации тестов. Это связано с обсуждением того, что представляет собой юнит в юнит-тестировании.
Лондонская школа считает, что юнитом должен быть класс. Разработчики с опытом объектно-ориентированного программирования обычно рассматривают классы как атомарные элементы, из которых складывается фундамент любой кодовой базы. Это естественным образом приводит к тому, что классы также начинают рассматриваться как атомарные единицы для проверки в тестах.
Такая тенденция понятна, но ошибочна.
Тесты не должны проверять *единицы кода* (units of code). Вместо этого они должны проверять *единицы поведения* (units of behavior) — нечто имеющее смысл для предметной области, а в идеале — нечто такое, полезность чего будет понятна бизнесу. Количество классов, необходимых для реализации такой единицы поведения, не имеет значения. Тест может охватывать как несколько классов, так и только один класс или даже всего один метод.
Таким образом, повышение детализации тестируемого кода само по себе не является чем-то полезным. Если тест проверяет одну единицу поведения, это хороший тест. Стремление к тому, чтобы охватить что-то меньшее, может повредить вашим юнит-тестам, так как становится сложнее понять, что же именно эти тесты проверяют.
В идеале тест должен рассказывать о проблеме, решаемой кодом проекта, и этот рассказ должен быть связным и понятным даже для непрограммиста.
Пример связного рассказа:
«Когда я зову свою собаку, она идет ко мне».
Теперь сравните это со следующим рассказом:
«Когда я зову свою собаку, она сначала выставляет вперед левую переднюю лапу, потом правую переднюю лапу, поворачивает голову, начинает вилять хвостом...»
Второй рассказ не кажется особо вразумительным. Для чего нужны все эти движения? Собака идет ко мне? Или убегает? Сходу не скажешь. Именно так начинают выглядеть ваши тесты, когда вы ориентируетесь на отдельные классы (лапы, голова, хвост) вместо фактического поведения (собака идет к своему хозяину).
Юнит-тестирование большого графа взаимосвязанных классов
Использование моков вместо реальных зависимостей может упростить тестирование класса — особенно при наличии сложного графа объектов, в котором тестируемый класс имеет зависимости, каждая из которых имеет свои зависимости, и т. д. на несколько уровней в глубину.
С тестовыми заглушками вы можете устранить непосредственные зависимости тестируемого класса и таким образом разделить граф объектов, что может значительно сократить объем подготовки, необходимой для юнит-тестирования. Если же следовать канонам классической школы, то необходимо будет воссоздать полный граф объектов (кроме внепроцессных зависимостей) просто ради того, чтобы подготовить тестируемую систему, что может потребовать значительной работы.
И хотя всё это правда, такие рассуждения фокусируются не на той проблеме. Вместо того, чтобы искать способы тестирования большого сложного графа взаимосвязанных классов, следует сконцентрироваться на том, чтобы у вас изначально не было такого графа классов. Как правило, большой граф классов — результат плохого проектирования кода.
Тот факт, что тесты подчеркивают эту проблему, становится преимуществом. Сама возможность юнит-тестирования кода служит хорошим негативным признаком — она позволяет определить плохое качество кода с относительно высокой точностью. Если вы видите, что для юнит-тестирования класса необходимо увеличить фазу подготовки теста сверх любых разумных пределов, это указывает на определенные проблемы с нижележащим кодом. Использование моков только скрывает эту проблему, не пытаясь справиться с ее корневой причиной.
Выявление точного местонахождения ошибки
Если в систему с тестами в лондонском стиле будет внесена ошибка, то, как правило, упадут только те тесты, у которых тестируемая система содержит ошибку. С другой стороны, при классическом подходе также могут падать и тесты, проверяющие клиентов неправильно функционирующего класса. Это приводит к каскадному эффекту: одна ошибка может вызвать тестовые сбои во всей системе. В результате усложняется поиск корневой проблемы и требуется дополнительное время для отладки.
Это хороший довод в пользу лондонской школы, но, на мой взгляд, не является большой проблемой для классической школы. Если вы регулярно запускаете тесты (в идеале после каждого изменения в коде приложения), то знаете, что стало причиной ошибки — это тот код, который вы редактировали в последний раз, и поэтому найти ошибку будет не так трудно. Также вам не нужно просматривать все упавшие тесты. Исправление одного автоматически исправляет все остальные.
Более того, в каскадном распространении сбоев по всем тестам есть некоторые плюсы. Если ошибка ведет к сбою не только одного теста, но сразу многих, это показывает, что только что сломанный код чрезвычайно ценен — от него зависит вся система. Это полезная информация, которую следует учитывать при работе с этим кодом.
Различия в подходе к разработке через тестирование (TDD)
Лондонский стиль юнит-тестирования ведет к методологии TDD (Test-Driven Development) по схеме «снаружи внутрь» (outside-in): вы начинаете с тестов более высокого уровня, которые задают ожидания для всей системы. Используя моки, вы указываете, с какими зависимостями система должна взаимодействовать для достижения ожидаемого результата. Затем вы проходите по графу классов, пока не реализуете их все.
Моки делают такой процесс разработки возможным, потому что вы можете сосредоточиться на одном классе за раз. Вы можете отсечь все зависимости тестируемой системы и таким образом отложить реализацию этих зависимостей.
Классическая школа такой возможности не дает, потому что вам приходится иметь дело с реальными объектами в тестах. Вместо этого обычно используется подход по схеме «изнутри наружу» (inside-out или middle-out). В этом стиле вы начинаете с модели предметной области, а затем накладываете на нее дополнительные слои, пока программный код не станет пригодным для конечного пользователя.
Интеграционные тесты в двух школах
Лондонская и классическая школы также расходятся в определении интеграционного теста. Такое расхождение естественным образом вытекает из различий в их взглядах на вопрос изоляции.
В лондонской школе любой тест, в котором используется реальный объект-зависимость (за исключением неизменяемых объектов), рассматривается как интеграционный. Большинство тестов, написанных в классическом стиле, будут считаться интеграционными тестами сторонниками лондонской школы.
К примеру, тест Purchase_succeeds_when_enough_inventory, написанный в классическом стиле, покрывает функциональность покупки товара клиентом. Этот код является типичным юнит-тестом с классической точки зрения, но для последователя лондонской школы он будет интеграционным тестом.
Итоги
Итак, подведем итоги. Юнит-тестом называется автоматизированный тест, который:
проверяет правильность работы небольшого фрагмента кода (также называемого юнитом),
делает это быстро
и поддерживая изоляцию от другого кода.
Различия между лондонской и классической школами юнит-тестирования проистекают из несогласия относительно того, что именно означает «изоляция».
Остальные отличия между школами:
Детализированность тестов — лондонская школа почти всегда тестирует только один класс за раз. Классическая школа может охватывать несколько тестов.
Юнит-тестирование большого графа взаимосвязанных классов -- лондонский подход позволяет проще тестировать такие графы, благодаря замене непосредственных зависимостей тестируемого класса на моки.
Выявление точного местонахождения ошибки — лондонский подход позволяет выявить точное местонахождение ошибки, благодаря замене всех зависимостей на моки.
Test-Driven Development (TDD) — лондонская школа предпочитает подход outside-in, в то время как классическая — inside-out (или middle-out).
Интеграционные тесты — лондонская школа считает интеграционным любой тест, который тестирует больше одного изменяемого класса за раз. Классическая школа считает интеграционными тесты, которые напрямую работают с внепроцессными зависимостями.
Минутка рекламы. Если вам интересны подходы к тестированию, вам наверняка будет интересно и на конференции Heisenbug (онлайн, 5-7 октября). Там будет множество докладов о тестировании в самых разных его проявлениях, описания части этих докладов уже есть на сайте конференции, билеты — там же.
Комментарии (16)
NightShad0w
25.08.2021 12:19+2А откуда появились названия школ?
phillennium
25.08.2021 12:30+1Мне тоже стало любопытно. По беглому гуглению тут вроде бы обычный подход «называем по месту, где зародилось» (как «детройтское техно» в музыке). Но город Детройт обычно не ассоциируется с IT-движухой, это неожиданный поворот :)
Tellamonid
25.08.2021 13:38+1Насчет Детройта/Чикаго не уверен, а Лондонская почти наверняка из-за книжки «Growing Object Oriented Software, Guided by Tests”. Авторы книги – британцы, авторы библиотеки JMock, и они продвигали подход с моками.
Что интересно, конкретно про тесты и моки мне не очень понравилось, что они пишут, мне понравилось другое. А именно что они начинают с ужасного кода, плохо разбитого, и последовательно применяя SRP (single responsibility principle), они приходят к аккуратному коду. Для меня эта книжка была скорее про SRP, чем про тесты.
Politura
26.08.2021 02:30А теперь прикиньте, было-бы удобно этот аккуратный и хорошо разбитый код тестировать без моков? Скорее всего, для каждого простого теста пришлось-бы создавать полотенце связанных классов. Поэтому нравятся моки, или нет, но с моками подобный код тестировать проще.
nin-jin
26.08.2021 08:27+1Нет, достаточно один раз создать изолированный контекст и использовать его для всех тестов. Очень удобно.
Politura
26.08.2021 22:18Вынести создание контекста в отдельный метод и использовать его в каждом тесте это да, а вот шарить контекст между тестами может быть опасно - тесты могут влиять друг на друга меняя внутренее состояние какого-нибудь обьекта.
Кстати, почитал вашу статью по ссылке ниже, глянул ссылки в той статье, заметил такую особенность: там, где пропагандируется компонентное/интеграционное тестирование используются языки с динамической типизацией. И, например, если у языка есть четкий контракт ввиде типа параметра функции, то пример из статьи "тавтологическое тестирование" уже не будет работать - методу не подсунешь строку тогда, когда он ожидает бинарные данные. Может в этом все дело и тестируя классы по-отдельности в языках с динамической типизацией люди чаще обжигаются на ошибках при "зеленых" тестах, вынужденны больше писать интеграционных тестов и в итоге переходить на них?
Хотя, текущая статья не укладывается в такую теорию, здесь C#, судя по коду. Но кто знает, может ее автор пришел в C# из динамических языков. :)
nin-jin
26.08.2021 22:48Конечно для каждого теста свой экземпляр контекста.
В моём коде так-то статически типизированный тайпскрипт используется. Но подсунуть чего-то не того легко и со статической типизацией. Например, число Пи там, где ожидается значение от 0 до 1.
ApeCoder
26.08.2021 09:03Вопрос, что понимать под моками - все test douibles или как у Фаулера:
Mocks are what we are talking about here: objects pre-programmed with expectations which form a specification of the calls they are expected to receive.
Я против вот таких моков, потому, что они не полностью реализуют контракт зависимости. Соответствено, даже при безошибочных изменениях в SUT тесты будут падать.
nin-jin
25.08.2021 13:10Ну и ссылки по теме:
Концепции автоматического тестирования - это про непротиворечивую классификацию.
Фрактальное тестирование - это про упорядоченные компонентные тесты.
ivansyabro
25.08.2021 16:52+3Знакомый материал, только вчера дочитал книгу автора "unit testing principles practices and patterns".
Спасибо за статью на русском, ещё бы книгу на русском выпустили бы.
vkhorikov Автор
25.08.2021 16:53+2На русском книга тоже есть :) https://www.ozon.ru/product/printsipy-yunit-testirovaniya-horikov-vladimir-211424826
Переводил сам.
dedmagic
26.08.2021 17:48+3Книга шикарна. Крайне всем рекомендую, особенно мне зашла концепция широкого и глубокого кода.
Владимир, спасибо!
P.S. Но всё равно я адепт лондонской школы =D.
mvv-rus
25.08.2021 23:25Небольшое уточнение (в плане не возражения, а прояснения мысли из статьи).
Лондонский стиль юнит-тестирования ведет к методологии TDD (Test-Driven Development) по схеме «снаружи внутрь» (outside-in): вы начинаете с тестов более высокого уровня, которые задают ожидания для всей системы.
Не совсем так. Этот стиль вообще не навязывает направление разработки — можно разрабатывать как сверху вниз (outside-in), так и снизу вверх: ведь ничто не мешает использовать для тестирования классов более высокого уровня не разработанные ранее классы нижнего уровня, а их имитаторы(mock).
Собственно, далее по тексту самой статьи сказано именно это — но это сказано позже.
А вот «классический» стиль, если оставаться в рамках TDD, т.е. писать тесты до кода, как правильно сказано в статье, фактически навязывает разработку «снизу вверх». А это не всегда приемлемо. Если логика работы на верхнем уровне неочевидна, то при разработке «снизу вверх» нередко возникает необходимость менять спецификации интерфейсов к классам нижнего уровня (а то и выбрасыать эти классы целиком). То есть, часть труда пойдет на выброс.
Politura
26.08.2021 01:43И хотя всё это правда, такие рассуждения фокусируются не на той проблеме. Вместо того чтобы искать способы тестирования большого сложного графа взаимосвязанных классов, следует сконцентрироваться на том, чтобы у вас изначально не было такого графа классов. Как правило, большой граф классов — результат плохого проектирования кода.
А по-моему как-раз наоборот, если не будет большого графа классов, то либо проект уж очень маленький, либо просто он написан так, что не соответствует принципам SOLID, особенно первой букве - single responsibility.
anonymous
burzooom
ну вот, началось