[Перевод] Юнит-тесты переоценены
Предлагаем вам перевод поста «Unit Testing is Overrated» от Alex Golub, чтобы подискутировать на тему юнит-тестов. Действительно ли они переоценены, как считает автор, или же являются отличным подспорьем в работе? Опрос — в конце поста
Результаты использования юнит-тестов: отчаяние, мучения, гнев
Важность тестирования в современной разработке ПО сложно переоценить. Для создания успешного продукта недостаточно выпустить его и сразу забыть, это долгий итеративный процесс. После изменения каждой строки кода программа должна сохранять свою функциональность, что подразумевает необходимость тщательного тестирования.
В процессе развития отрасли разработки ПО совершенствовались и методики тестирования. Они постепенно сдвигались в сторону автоматизации и повлияли на саму структуру ПО, порождая такие «мантры», как «разработка через тестирование» (test-driven development), делая упор на такие паттерны, как инверсия зависимостей (dependency inversion), и популяризируя построенные на их основе высокоуровневые архитектуры.
Сегодня автоматизированное тестирование настолько глубоко связано в нашем сознании с разработкой ПО, что одно сложно представить без другого. И поскольку оно, в конечном итоге, позволяет нам быстро создавать ПО, не жертвуя при этом его качеством, то трудно спорить о полезности тестирования.
Однако, несмотря на существование различных подходов, современные «best practices» в основном подталкивают разработчиков к использованию конкретно юнит-тестирования. Тесты, область контроля которых находится в пирамиде Майка Кона выше, или пишутся как часть более масштабного проекта (часто совершенно другими людьми), или полностью игнорируются.
Преимущество такого подхода часть поддерживается следующим аргументом: юнит-тесты обеспечивают в процессе разработки наибольшую полезность, потому что способны быстро отслеживать ошибки и помогают применять упрощающие модульность паттерны разработки. Эта мысль стала настолько общепринятой, что сегодня термин «юнит-тестирование» в какой-то мере сливается с автоматизированным тестированием в целом, из-за оно чего теряет часть своего значения и вводит в замешательство.
Когда я был менее опытным разработчиком, я неукоснительно следовал этим «best practices», полагая, что они могут сделать мой код лучше. Мне не особо нравилось писать юнит-тесты из-за всех связанных с этим церемоний с абстракциями и созданием заглушек, но таким был рекомендованным подход, а кто я такой, чтобы с ним спорить?
И только позже, поэкспериментировав и создав новые проекты, я начал осознавать, что существуют гораздо более хорошие подходы к тестированию, и что в большинстве случаев упор на юнит-тесты является пустой тратой времени.
Агрессивно продвигаемые «best practices» часто имеют тенденцию к созданию вокруг себя карго-культов, соблазняющих разработчиков применять паттерны разработки или использовать определённые подходы, не позволяя им задуматься. В контексте автоматизированного тестирования такая ситуация возникла с нездоровой одержимостью отрасли юнит-тестированием.
В этой статье я поделюсь своими наблюдениями о данном способе тестирования и расскажу о том, почему считаю его неэффективным. Также я скажу о том, какие подходы использую для тестирования своего кода, как в open-source-проектах, так и в повседневной работе.
Примечание: код примеров этой статьи написан на C#, но при объяснении моей позиции сам язык не (особо) важен.
Примечание 2: я пришёл к выводу, что терминология программирования совершенно не передаёт свой смысл, потому что каждый, похоже, понимает её по-своему. В этой статье я буду использовать «стандартные» определения: юнит-тестирование направлено на проверку наименьших отдельных частей кода, сквозное тестирование (end-to-end testing) проверяет самые отдалённые друг от друга входные точки ПО, а интеграционное тестирование (integration testing) используется для всего промежуточного между ними.
Примечание 3: если вам не хочется читать статью целиком, то можете сразу перейти к выводам в конце.
Заблуждения о юнит-тестировании
Юнит-тесты, как понятно из их названия, связаны с понятием «юнита», обозначающим очень маленькую изолированную часть системы. Не существует формального определения того, что такое юнит, и насколько он должен быть мал, но чаще всего принимается, что он соответствует отдельной функции модуля (или методу объекта).
Обычно, если код пишется без учёта юнит-тестирования, тестирование некоторых функций в полной изоляции может оказаться невозможным, потому что они могут иметь внешние зависимости. Чтобы обойти эту проблему, мы можем применить принцип инверсии зависимостей и заменить конкретные зависимости абстракциями. Затем эти абстракции можно заменить на реальные или фальшивые реализации; это зависит от того, выполняется ли код обычным образом, или как часть теста.
Кроме того, ожидается, что юнит-тесты должны быть чистыми. Например, если функция содержит код, записывающий данные в файловую систему, то эту часть тоже нужно абстрагировать — в противном случае, тест, проверяющий такое поведение, будет считаться интеграционным тестом, потому что он покрывает ещё и интеграцию юнита с файловой системой.
Учитывая вышеупомянутые факторы, мы можем прийти к выводу, что юнит-тесты полезны только для проверки чистой бизнес-логики внутри конкретной функции. Их область применения не охватывает тестирование побочных эффектов или других интеграций, потому что это сфера уже интегрального тестирования.
Чтобы продемонстрировать, как эти нюансы влияют на проектирование, давайте возьмём пример простой системы, которую мы хотим протестировать. Представьте, что мы работаем над приложением, вычисляющим время восхода и заката; свою задачу оно выполняет при помощи следующих двух классов:
public class LocationProvider : IDisposable
{
private readonly HttpClient _httpClient = new HttpClient();
// Gets location by query
public async Task GetLocationAsync(string locationQuery) { /* ... */ }
// Gets current location by IP
public async Task GetLocationAsync() { /* ... */ }
public void Dispose() => _httpClient.Dispose();
}
public class SolarCalculator : IDiposable
{
private readonly LocationProvider _locationProvider = new LocationProvider();
// Gets solar times for current location and specified date
public async Task GetSolarTimesAsync(DateTimeOffset date) { /* ... */ }
public void Dispose() => _locationProvider.Dispose();
}
Хотя представленная выше структура совершенно верна с точки зрения ООП, ни для одного из этих классов невозможно провести юнит-тестирование. Поскольку LocationProvider
зависит от своего собственного экземпляра HttpClient
, а SolarCalculator
, в свою очередь, зависит от LocationProvider
, невозможно изолировать бизнес-логику, которая может содержаться внутри методов этих классов.
Давайте выполним итерацию кода и заменим конкретные реализации абстракциями:
public interface ILocationProvider
{
Task GetLocationAsync(string locationQuery);
Task GetLocationAsync();
}
public class LocationProvider : ILocationProvider
{
private readonly HttpClient _httpClient;
public LocationProvider(HttpClient httpClient) =>
_httpClient = httpClient;
public async Task GetLocationAsync(string locationQuery) { /* ... */ }
public async Task GetLocationAsync() { /* ... */ }
}
public interface ISolarCalculator
{
Task GetSolarTimesAsync(DateTimeOffset date);
}
public class SolarCalculator : ISolarCalculator
{
private readonly ILocationProvider _locationProvider;
public SolarCalculator(ILocationProvider locationProvider) =>
_locationProvider = locationProvider;
public async Task GetSolarTimesAsync(DateTimeOffset date) { /* ... */ }
}
Благодаря этому мы сможем отделить LocationProvider
от SolarCalculator
, но взамен размер кода увеличился почти в два раза. Обратите также внимание на то, что нам пришлось исключить из обоих классов IDisposable
, потому что они больше не владеют своими зависимостями, а следовательно, не отвечают за их жизненный цикл.
Хотя некоторым подобные изменения могут показаться усовершенствованиями, важно указать на то, что определённые нами интерфейсы не имеют практической пользы, за исключением возможности проведения юнит-тестирования. В нашей структуре нет необходимости в полиморфизме, то есть в нашем конкретном случае такие абстракции являются самоцельными (т.е. абстракциями ради абстракций).
Давайте попробуем воспользоваться преимуществами проделанной работы и написать юнит-тест для SolarCalculator.GetSolarTimesAsync
:
public class SolarCalculatorTests
{
[Fact]
public async Task GetSolarTimesAsync_ForKyiv_ReturnsCorrectSolarTimes()
{
// Arrange
var location = new Location(50.45, 30.52);
var date = new DateTimeOffset(2019, 11, 04, 00, 00, 00, TimeSpan.FromHours(+2));
var expectedSolarTimes = new SolarTimes(
new TimeSpan(06, 55, 00),
new TimeSpan(16, 29, 00)
);
var locationProvider = Mock.Of(lp =>
lp.GetLocationAsync() == Task.FromResult(location)
);
var solarCalculator = new SolarCalculator(locationProvider);
// Act
var solarTimes = await solarCalculator.GetSolarTimesAsync(date);
// Assert
solarTimes.Should().BeEquivalentTo(expectedSolarTimes);
}
}
Мы получили простой тест, проверяющий, что SolarCalculator
правильно работает для известного нам местоположения. Так как юнит-тесты и их юниты тесно связаны, мы используем рекомендуемую систему наименований, а название метода теста соответствует паттерну Method_Precondition_Result
(«Метод_Предусловие_Результат»).
Чтобы симулировать нужное предусловие на этапе Arrange, нам нужно внедрить в зависимость юнита ILocationProvider
соответствующее поведение. В данном случае мы реализуем это заменой возвращаемого значения GetLocationAsync()
на местоположение, для которого заранее известно правильное время восхода и заката.
Обратите внимание, что хотя ILocationProvider
раскрывает два разных метода, с точки зрения контракта мы не имеем возможности узнать, какой из них вызывается. Это означает, что при выборе имитации одного из этих методов мы делаем предположение о внутренней реализации тестируемого метода (которую в предыдущих фрагментах кода мы намеренно скрыли).
В конечном итоге, тест правильно проверяет, что бизнес-логика внутри GetSolarTimesAsync
работает ожидаемым образом. Однако давайте перечислим наблюдения, сделанные нами в процессе работы.
1. Юнит-тесты имеют ограниченную применимость
Важно понимать, что задача любого юнит-теста очень проста: проверять бизнес-логику в изолированной области действия. Применимость юнит-тестирования зависит от взаимодействий, которые нам нужно протестировать.
Например, логично ли подвергать юнит-тесту метод, вычисляющий время восхода и заката при помощи долгого и сложного математического алгоритма? Скорее всего да.
Имеет ли смысл выполнять юнит-тест метода, отправляющего запрос к REST API для получения географических координат? Скорее всего нет.
Если рассматривать юнит-тестирование как самоцель, то вы вскоре обнаружите, что несмотря на множество усилий, большинство тестов неспособно обеспечить нужный вам уровень уверенности просто потому, что они тестируют не то, что необходимо. В большинстве случаев гораздо выгоднее тестировать более обширные взаимодействия при помощи интегрального тестирования, чем фокусироваться конкретно на юнит-тестах.
Любопытно, что некоторые разработчики в подобных ситуациях в конечном итоге всё-таки пишут интегральные тесты, но по-прежнему называют их юнит-тестами. В основном это вызвано путаницей, которая окружает это понятие. Конечно, можно заявить, что размер юнита можно выбирать произвольно и что он может охватывать несколько компонентов, но из-за этого определение становится очень нечётким, а потому использование термина оказывается совершенно бесполезным.
2. Юнит-тесты приводят к усложнению структуры
Один из наиболее популярных аргументов в пользу юнит-тестирования заключается в том, что оно стимулирует вас проектировать ПО очень модульным образом. Аргумент основан на предположении, что проще воспринимать код, когда он разбит на множество мелких компонентов, а не на малое количество крупных.
Однако часто это приводит к противоположной проблеме — функциональность может оказаться чрезмерно фрагментированной. Из-за этого оценивать код становится намного сложнее, потому что разработчику приходится просматривать несколько компонентов того, что должно быть единым связанным элементом.
Кроме того, избыточное использование абстракций, необходимое для обеспечения изоляции компонентов, создаёт множество необязательных косвенных взаимодействий. Хоть сама по себе эта техника является невероятно мощной и полезной, абстракции неизбежно повышают когнитивную сложность, ещё сильнее затрудняя восприятие кода.
Из-за таких косвенных взаимодействий мы в конечном итоге теряем определённую степень инкапсуляции, которую могли бы сохранить. Например, ответственность за управление сроком жизни отдельных зависимостей переходит от содержащих их компонентов к какому-то другому не связанному с ними сервису (обычно к контейнеру зависимостей).
Часть инфраструктурной сложности тоже можно делегировать фреймворку внедрения зависимостей, что упрощает конфигурирование зависимостей, управление ими и активацию. Однако это снижает компактность, что в некоторых случаях, например, при написании библиотеки, нежелательно.
В конечном итоге, хоть и очевидно, что юнит-тестирование влияет на проектирование ПО, его полезность весьма спорна.
3. Юнит-тесты затратны
Можно логично предположить, что из-за своего малого размера и изолированности юнит-тесты очень легко и быстро писать. К сожалению, это ещё одно заблуждение; похоже, оно довольно популярно, особенно среди руководства.
Хоть упомянутая выше модульная архитектура заставляет нас думать, что индивидуальные компоненты можно рассматривать отдельно друг от друга, на самом деле юнит-тесты от этого не выигрывают. В действительности, сложность юнит-теста только растёт пропорционально количеству его внешних взаимодействий; это вызвано всей той работой, которую необходимо проделать для достижения изолированности при сохранении требуемого поведения.
Показанный выше пример довольно прост, однако в реальном проекте этап Arrange довольно часто может растягиваться на множество длинных строк, в которых просто задаются предусловия одного теста. В некоторых случаях имитируемое поведение может быть настолько сложным, что почти невозможно распутать его, чтобы разобраться, что оно должно было делать.
Кроме того, юнит-тесты по самой своей природе очень тесно связаны с тестируемым кодом, то есть все трудозатраты для внесения изменений по сути удваиваются, чтобы тест соответствовал обновлённому коду. Ухудшает ситуацию и то, что очень немногим разработчикам эта задача кажется увлекательной, поэтому они просто сбрасывают её на менее опытных членов команды.
4. Юнит-тесты зависят от подробностей реализации
Печальным следствием юнит-тестирования на основе заглушек (mocks) заключается в том, что любой тест, написанный по этой технике, обязательно учитывает реализацию. Имитируя конкретную зависимость, тест начинает полагаться на то, как тестируемый код потребляет эту зависимость, что не регулируется публичным интерфейсом.
Эта дополнительная связь часто приводит к неожиданным проблемам, при которых изменения, которые, казалось бы, ничего не могут сломать, начинают давать сбой при устаревании заглушек. Это может очень напрягать и в конечном итоге отталкивает разработчиков от рефакторинга кода, потому что никогда непонятно, возникла ли ошибка в тесте из-за действительной регрессии или из-за того, что он зависит от подробностей реализации.
Ещё более сложным может быть юнит-тестирование кода с хранением состояния, потому что наблюдение за мутациями через публичный интерфейс может оказаться невозможным. Чтобы обойти эту проблему, обычно можно внедрять шпионов, то есть своего рода имитируемое поведение, регистрирующее вызов функций и помогающее убедиться, что юнит использует свои зависимости правильно.
Разумеется, когда мы зависим не только от вызова конкретной функции, но и от количества вызовов и переданных аргументов, то тест становится ещё более тесно связанным с реализацией. Написанные таким образом тесты полезны только для внутренней специфики и обычно даже ожидается, что они не будут изменяться (крайне неразумное ожидание).
Слишком сильная зависимость от подробностей реализации также очень усложняет сами тесты, учитывая объём подготовки, необходимый для имитации определённого поведения; особенно справедливо это, когда взаимодействия нетривиальны или присутствует множество зависимостей. Когда тесты становятся настолько сложными, что трудно понимать само их поведение, то кто будет писать тесты для тестирования тестов?
5. Юнит-тесты не используют действия пользователей
Какое бы ПО вы не разрабатывали, его задача — обеспечение ценности для конечного пользователя. На самом деле, основная причина написания автоматизированных тестов — обеспечение гарантии отсутствия непреднамеренных дефектов, способных снизить эту ценность.
В большинстве случаев пользователь работает с ПО через какой-нибудь высокоуровневый интерфейс типа UI, CLI или API. Хотя в самом коде могут применяться множественные слои абстракции, для пользователя важен только тот уровень, который он видит и с которым взаимодействует.
Ему даже не важно, если ли в какой-то части системы баг несколькими слоями ниже, если пользователь с ним не сталкивается и он не вредит функциональности. И наоборот: пусть даже у нас есть полное покрытие всех низкоуровневых частей, но если есть изъян в интерфейсе пользователя, то это делает систему по сути бесполезной.
Разумеется, если вы хотите гарантировать правильность работы какого-то элемента, то нужно проверить именно его и посмотреть, действительно ли он работает правильно. В нашем случае, лучшим способом обеспечения уверенности в системе является симуляция взаимодействия реального пользователя с высокоуровневым интерфейсом и проверка того, что он работает в соответствии с ожиданиями.
Проблема юнит-тестов заключается в том, что они являются полной противоположностью такого подхода. Так как мы всегда имеем дело с небольшими изолированными частями кода, с которыми пользователь никогда напрямую не взаимодействует, мы никогда не тестируем истинное поведение пользователя.
Тестирование на основе заглушек ставит ценность таких тестов под ещё большее сомнение, потому что части системы, которые бы использовались, заменяются на имитации, ещё сильнее отдаляя симулируемое окружение от реальности. Невозможно обеспечить уверенность удобства работы пользователя, тестируя нечто непохожее на эту работу.
Юнит-тестирование — отличный способ проверки работы заглушек
Тестирование на основе пирамиды
Так почему же мы как отрасль решили, что юнит-тестирование должно быть основным способом тестирования ПО, несмотря на все его изъяны? В основном это вызвано тем, что тестирование на высоких уровнях всегда считалось слишком трудным, медленным и ненадёжным.
Если обратиться к традиционной пирамиде тестирования, то она предполагает, что наиболее значимая часть тестирования должна выполняться на уровне юнитов. Смысл в том, что поскольку крупные тесты считаются более медленными и сложными, для получения эффективного и поддерживаемого набора тестов нужно сосредоточить усилия на нижней части спектра интеграции:
Сверху — сквозное тестирование, в центре — интегральное тестирование, внизу — юнит-тестирование
Метафорическая модель, предлагаемая пирамидой, должна передать нам мысль о том, что для качественного тестирования должны использоваться множество различных слоёв, ведь если сконцентрироваться на крайностях, то это может привести к проблемам: тесты будут или слишком медленными и неповоротливыми, или бесполезными и не обеспечивающими никакой уверенности. Тем не менее, упор делается на нижние уровни, потому что считается, что там возврат инвестиций в разработку тестов наиболее высок.
Высокоуровневые тесты, хоть и обеспечивают наибольшую уверенность, часто оказываются медленными, сложными в поддержке или слишком широкими для включения в обычно быстрый процесс разработки. Именно поэтому в большинстве случаев такие тесты поддерживаются специалистами QA, ведь обычно считается, что писать их должны не разработчики.
Интегральное тестирование, которое на абстрактной части спектра лежит где-то между юнит-тестированием и полным сквозным тестированием, часто совершенно игнорируется. Непонятно, какой конкретно уровень интеграции предпочтим, как структурировать и организовывать такие тесты. Кроме того, существуют опасения, что они выйдут из под контроля. Поэтому многие разработчики отказываются от них в пользу более чётко очерченной крайности, которой и является юнит-тестирование.
Из-за этих причин всё тестирование в процессе разработки обычно остаётся на самом дне пирамиды. На самом деле, это стало настолько стандартным, что тестирование разработки и юнит-тестирование сегодня стали практически синонимами, что приводит к путанице, усиливаемой докладами на конференциях, постами в блогах, книгами и даже некоторыми IDE (по мнению JetBrains Rider, все тесты являются юнит-тестами).
По мнению большинства разработчиков, пирамида тестирования выглядит примерно так:
Сверху — не моя проблема, внизу — юнит-тестирование
Хотя эта пирамида стала достойной уважения попыткой превратить тестирование ПО в решённую задачу, в этой модели очевидно есть множество проблем. В частности, используемые в ней допущения справедливы не во всех контекстах, особенно допущение о том, что высокоинтегральные наборы тестов являются медленными или трудными.
Мы как люди естественно склонны полагаться на информацию, переданную нам более опытными людьми, благодаря чему можем использовать знания предыдущих поколений и применять вторую систему мышления к чему-то более полезному. Это важная эволюционная черта, чрезвычайно повысившая нашу выживаемость как вида.
Однако когда мы экстраполируем свой опыт в инструкции, то обычно воспринимаем их как хорошие сами по себе, забывая об условиях, неотъемлемо связанных с их актуальностью. На самом деле эти условия меняются, и когда-то совершенно логичные выводы (или best practices) могут оказаться не столь хорошо применимыми.
Если взглянуть на прошлое, то очевидно, что в 2000-х высокоуровневое тестирование было сложным, вероятно, оно оставалось таким даже в 2009 году, но на дворе 2020 год и мы уже живём в будущем. Благодаря прогрессу технологий и проектирования ПО эти проблемы стали гораздо менее важными, чем ранее.
Сегодня большинство современных фреймворков предоставляет какой-нибудь отдельный слой API, используемый для тестирования: в нём можно запускать приложение в симулируемой среде внутри памяти, которое очень близко к реальной. Такие инструменты виртуализации, как Docker, также позволили нам проводить тесты, полагающиеся на действительные инфраструктурные зависимости, сохраняя при этом свою детерминированность и скорость.
У нас есть такие решения, как Mountebank, WireMock, GreenMail, Appium, Selenium, Cypress и бесконечное множество других, они упрощают различные аспекты высокоуровневого тестирования, которые когда-то считались недостижимыми. Если вы не разрабатываете десктопные приложения для Windows и не вынуждены использовать фреймворк UIAutomation, то у вас, скорее всего, есть множество возможных вариантов выбора.
В одном из моих предыдущих проектов у нас был веб-сервис, тестировавшийся на границе системы при помощи почти сотни поведенческих тестов, на параллельное выполнение которых тратилось менее 10 секунд. Разумеется, при использовании юнит-тестов можно добиться гораздо более быстрого выполнения, но учитывая обеспечиваемую уверенность, мы даже не рассматривали такую возможность.
Однако заблуждение о медленности тестов — не единственное ошибочное допущение, на котором основана пирамида. Принцип применения большинства тестов на уровне юнитов работает только тогда, когда эти тесты действительно обеспечивают ценность, что, разумеется, зависит от того, какой объём бизнес-логики находится в тестируемом коде.
В некоторых приложениях бизнес-логики может быть много (например, в системах подсчёта зарплаты), в некоторых она почти отсутствует (например, в CRUD-приложениях), а большинство ПО находится где-то посередине. Большинство проектов, над которыми работал лично я, не содержали такого объёма, чтобы была необходимость в обширном покрытии юнит-тестами; с другой стороны, в них было много инфраструктурной сложности, для которой было бы полезно интегральное тестирование.
Разумеется, в идеальном мире разработчик смог бы оценить контекст проекта и создать способ тестирования, наиболее подходящий для решения насущных задач. Однако в реальности большинство разработчиков даже не задумывается об этом, слепо наворачивая горы юнит-тестов в соответствии с рекомендациями best practices.
Наконец, по-моему, было бы справедливо сказать, что создаваемая пирамидой тестирования модель слишком проста в целом. Вертикальная ось представляет спектр тестирования как линейную шкалу, при которой любое повышение уверенности компенсируется эквивалентной величиной потери поддерживаемости и скорости. Это может быть истинным, если вы сравниваете крайние случаи, но не всегда правда для точек в промежутке между ними.
Пирамида также не учитывает того, что изоляция сама по себе имеет цену, она не возникает бесплатно просто благодаря «избеганию» внешних взаимодействий. Учитывая то, сколько труда требуется для написания и поддержки заглушек, вполне возможно, что менее изолированный тест может быть дешевле и в конечном итоге обеспечит бОльшую уверенность, хоть и при чуть более низкой скорости выполнения.
Если принять во внимание эти аспекты, то кажется вероятным, что шкала окажется нелинейной, и что точка максимального возврата инвестиций находится где-то ближе к середине, а не к уровню юнитов:
В конечном итоге, если вы пытаетесь определить эффективный набор тестов для своего проекта, пирамида тестирования — не лучший образец, которому можно следовать. Гораздо логичнее сосредоточиться на том, что относится конкретно к вашему контексту, а не полагаться на «best practices».
Тестирование на основе реальности
На самом базовом уровне тест обеспечивает ценность, если он гарантирует нам, что ПО работает правильно. Чем больше мы уверены, тем меньше нам нужно полагаться на себя в поиске потенциальных багов и регрессий при внесении изменений в код, потому что мы поручаем заниматься этим тестам.
Это доверие, в свою очередь, зависит от точности воспроизведения тестом настоящего поведения пользователя. Тестовый сценарий, работающий на границе системы без знаний её внутренней специфики должен обеспечивать нам бОльшую уверенность (а значит, и ценность), чем тест, работающий на нижнем уровне.
По сути, степень получаемой от тестов уверенности — это основная метрика, которой должна измеряться их ценность. А основная цель — это её максимальное увеличение.
Разумеется, как мы знаем, в деле задействованы и другие факторы: цена, скорость, возможность параллелизации и прочие, и все они тоже важны. Пирамида тестирования создаёт сильные допущения о том, как взаимосвязаны масштабирование этих элементов, но эти допущения не универсальны.
Более того, эти факторы являются вторичными относительно основной цели — достижения уверенности. Дорогой и долго выполняемый тест, обеспечивающий большую уверенность, бесконечно более полезен, чем чрезвычайно быстрый и простой тест, который не делает ничего.
Поэтому я считаю, что лучше писать тесты с максимально возможной степенью интеграции, поддерживая при этом их разумную скорость и сложность.
Значит ли это, что каждый создаваемый нами тест должен быть сквозным? Нет, но мы должны стремиться как можно дальше продвинуться в этом направлении, обеспечивая при этом допустимый уровень недостатков.
Приемлемость субъективна и зависит от контекста. В конечном итоге, важно то, что эти тесты пишутся разработчиками и используются в процессе разработки, то есть они не должны создавать обузу при поддержке и обеспечивать возможность запуска в локальных сборках и на конфигурационной единице.
В таком случае у нас скорее всего получатся тесты, разбросанные по нескольким уровням шкалы интеграции при кажущемся отсутствии ощущения структуры. Такой проблемы не возникает при юнит-тестировании, потому что в нём каждый тест связан с конкретным методом или функцией, поэтому структура обычно отзеркаливает сам код.
К счастью, это не важно, потому что упорядочивание тестов по отдельным классам или модулям не имеет значения само по себе; скорее, это побочный эффект юнит-тестирования. Вместо этого тесты должны разделятся по истинной пользовательской функциональности, которую они проверяют.
Такие тесты часто называют функциональными (functional), потому что они основаны на требованиях к функциональности ПО, описывающих его возможности и способ их работы. Функциональное тестирование — это не ещё один слой пирамиды, а совершенно перпендикулярная ей концепция.
Вопреки распространённому мнению, для написания функциональных тестов не требуется использовать Gherkin или фреймворк BDD, их можно реализовать при помощи тех же инструментов, которые применяются для юнит-тестирования. Например, давайте подумаем, как мы можем переписать пример из начала статьи так, чтобы тесты были структурированы на основе поддерживаемого поведения пользователей, а не юнитов кода:
public class SolarTimesSpecs
{
[Fact]
public async Task User_can_get_solar_times_automatically_for_their_location() { /* ... */ }
[Fact]
public async Task User_can_get_solar_times_during_periods_of_midnight_sun() { /* ... */ }
[Fact]
public async Task User_can_get_solar_times_if_their_location_cannot_be_resolved() { /* ... */ }
}
Обратите внимание, что сама реализация тестов скрыта, потому что она не связана с тем, что они функциональны. Важно здесь то, что тесты и их структура задаются требованиями к ПО, а их масштаб теоретически может изменяться от сквозного тестирования и даже до уровня юнитов.
Называя тесты в соответствии со спецификациями, а не с классами, мы получаем дополнительное преимущество — устраняем эту необязательную связь. Теперь, если мы решим переименовать SolarCalculator
во что-то ещё или переместим его в другой каталог, то названия тестов не нужно будет изменять.
Если придерживаться такой структуры, то наш набор тестов по сути принимает вид живой документации. Вот, например, как организован набор тестов в CliWrap (xUnit заменил нижние подчёркивания на пробелы):
Пока элемент ПО выполняет нечто хотя бы отдалённо полезное, то он всегда имеет функциональные требования. Они могут быть или формальными (документы спецификации, пользовательские истории, и т.д.) или неформальными (в устной форме, допускаемые, тикеты JIRA, записанные на туалетной бумаге, и т.д.)
Преобразование неформальных спецификаций в функциональные тесты часто может быть сложным процессом, потому что для этого требуется отступить от кода и заставить себя взглянуть на ПО с точки зрения пользователя. В моих open-source-проектах мне помогает составление файла readme, в котором я перечисляю список примеров использования, а затем кодирую их в тесты.
Подведём итог: можно заключить, что лучше разделять тесты по цепочкам поведений, а не по внутренней структуре кода.
Если объединить оба вышеупомянутых подхода, то образуется структура мышления, дающая нам чёткую цель написания тестов, а также понимание организации; при этом нам не нужно полагаться ни на какие допущения. Мы можем использовать эту структуру для создания набора тестов для проекта, сосредоточенный на ценности, а затем масштабировать его в соответствии с важными в текущем контексте приоритетами и ограничениями.
Принцип заключается в том, чтобы вместо упора на определённую область или набор областей, мы создаём набор тестов на основании пользовательской функциональности, стремясь как можно точнее покрыть эту функциональность.
Функциональное тестирование для веб-сервисов (с помощью ASP.NET Core)
Вероятно, вы не понимаете, из чего же состоит функциональное тестирование и как конкретно оно должно выглядеть, особенно если не занимались им раньше. Поэтому разумно будет привести простой, но законченный пример. Для этого мы превратим наш калькулятор восходов и закатов в веб-сервис и покроем его тестам в соответствии с правилами, изложенными в предыдущей части статьи. Это приложение будет основано на ASP.NET Core — веб-фреймворке, с которым я знаком больше всего, но такой же принцип должен быть применим к любой другой платформе.
Наш веб-сервис раскрывает свои конечные точки для вычисления времени восхода и заката на основании IP пользователя или указанного местоположения. Чтобы всё было чуть интереснее, для ускорения ответов мы добавим слой кэширования Redis, хранящий предыдущие вычисления.
Тесты будут выполняться запуском приложения в симулируемой среде, в которой оно может получать HTTP-запросы, обрабатывать маршрутизацию, выполнять валидацию и демонстрировать поведение. практически идентичное приложению, запущенному в продакшене. Также мы используем Docker, чтобы наши тесты использовали те же инфраструктурные зависимости, что и реальное приложение.
Чтобы разобраться, с чем мы имеем дело, давайте сначала рассмотрим реализацию веб-приложения. Обратите внимание, что некоторые части фрагментов кода ради краткости пропущены, а полный проект можно посмотреть на GitHub.
Для начала нам нужно найти с