Предлагаем вам перевод поста «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<Location> GetLocationAsync(string locationQuery) { /* ... */ }

    // Gets current location by IP
    public async Task<Location> 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<SolarTimes> GetSolarTimesAsync(DateTimeOffset date) { /* ... */ }

    public void Dispose() => _locationProvider.Dispose();
}

Хотя представленная выше структура совершенно верна с точки зрения ООП, ни для одного из этих классов невозможно провести юнит-тестирование. Поскольку LocationProvider зависит от своего собственного экземпляра HttpClient, а SolarCalculator, в свою очередь, зависит от LocationProvider, невозможно изолировать бизнес-логику, которая может содержаться внутри методов этих классов.

Давайте выполним итерацию кода и заменим конкретные реализации абстракциями:

public interface ILocationProvider
{
    Task<Location> GetLocationAsync(string locationQuery);

    Task<Location> GetLocationAsync();
}

public class LocationProvider : ILocationProvider
{
    private readonly HttpClient _httpClient;

    public LocationProvider(HttpClient httpClient) =>
        _httpClient = httpClient;

    public async Task<Location> GetLocationAsync(string locationQuery) { /* ... */ }

    public async Task<Location> GetLocationAsync() { /* ... */ }
}

public interface ISolarCalculator
{
    Task<SolarTimes> GetSolarTimesAsync(DateTimeOffset date);
}

public class SolarCalculator : ISolarCalculator
{
    private readonly ILocationProvider _locationProvider;

    public SolarCalculator(ILocationProvider locationProvider) =>
        _locationProvider = locationProvider;

    public async Task<SolarTimes> 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<ILocationProvider>(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.

Для начала нам нужно найти способ определения местоположения пользователя по IP, выполняемое при помощи класса LocationProvider, который мы видели в предыдущих примерах. Он является простой обёрткой вокруг внешнего сервиса GeoIP-поиска под названием IP-API:

public class LocationProvider
{
    private readonly HttpClient _httpClient;

    public LocationProvider(HttpClient httpClient) =>
        _httpClient = httpClient;

    public async Task<Location> GetLocationAsync(IPAddress ip)
    {
        // If IP is local, just don't pass anything (useful when running on localhost)
        var ipFormatted = !ip.IsLocal() ? ip.MapToIPv4().ToString() : "";

        var json = await _httpClient.GetJsonAsync($"http://ip-api.com/json/{ipFormatted}");

        var latitude = json.GetProperty("lat").GetDouble();
        var longitude = json.GetProperty("lon").GetDouble();

        return new Location
        {
            Latitude = latitude,
            Longitude = longitude
        };
    }
}

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

public class SolarCalculator
{
    private readonly LocationProvider _locationProvider;

    public SolarCalculator(LocationProvider locationProvider) =>
        _locationProvider = locationProvider;

    private static TimeSpan CalculateSolarTimeOffset(Location location, DateTimeOffset instant,
        double zenith, bool isSunrise)
    {
        /* ... */

        // Algorithm omitted for brevity

        /* ... */
    }

    public async Task<SolarTimes> GetSolarTimesAsync(Location location, DateTimeOffset date)
    {
        /* ... */
    }

    public async Task<SolarTimes> GetSolarTimesAsync(IPAddress ip, DateTimeOffset date)
    {
        var location = await _locationProvider.GetLocationAsync(ip);

        var sunriseOffset = CalculateSolarTimeOffset(location, date, 90.83, true);
        var sunsetOffset = CalculateSolarTimeOffset(location, date, 90.83, false);

        var sunrise = date.ResetTimeOfDay().Add(sunriseOffset);
        var sunset = date.ResetTimeOfDay().Add(sunsetOffset);

        return new SolarTimes
        {
            Sunrise = sunrise,
            Sunset = sunset
        };
    }
}

Так как это веб-приложение MVC, нам также потребуется контроллер, предоставляющий конечные точки для раскрытия функциональности приложения:

[ApiController]
[Route("solartimes")]
public class SolarTimeController : ControllerBase
{
    private readonly SolarCalculator _solarCalculator;
    private readonly CachingLayer _cachingLayer;

    public SolarTimeController(SolarCalculator solarCalculator, CachingLayer cachingLayer)
    {
        _solarCalculator = solarCalculator;
        _cachingLayer = cachingLayer;
    }

    [HttpGet("by_ip")]
    public async Task<IActionResult> GetByIp(DateTimeOffset? date)
    {
        var ip = HttpContext.Connection.RemoteIpAddress;
        var cacheKey = $"{ip},{date}";

        var cachedSolarTimes = await _cachingLayer.TryGetAsync<SolarTimes>(cacheKey);
        if (cachedSolarTimes != null)
            return Ok(cachedSolarTimes);

        var solarTimes = await _solarCalculator.GetSolarTimesAsync(ip, date ?? DateTimeOffset.Now);
        await _cachingLayer.SetAsync(cacheKey, solarTimes);

        return Ok(solarTimes);
    }

    [HttpGet("by_location")]
    public async Task<IActionResult> GetByLocation(double lat, double lon, DateTimeOffset? date)
    {
        /* ... */
    }
}

Как показано выше, конечная точка /solartimes/by_ip в основном просто делегирует исполнение SolarCalculator, а кроме того, имеет очень простую логику кэширования для избавления от избыточных запросов к сторонним сервисам. Кэширование выполняется классом CachingLayer, инкапсулирующим клиент Redis, используемый для хранения и получения JSON-контента:

public class CachingLayer
{
    private readonly IConnectionMultiplexer _redis;

    public CachingLayer(IConnectionMultiplexer connectionMultiplexer) =>
        _redis = connectionMultiplexer;

    public async Task<T> TryGetAsync<T>(string key) where T : class
    {
        var result = await _redis.GetDatabase().StringGetAsync(key);

        if (result.HasValue)
            return JsonSerializer.Deserialize<T>(result.ToString());

        return null;
    }

    public async Task SetAsync<T>(string key, T obj) where T : class =>
        await _redis.GetDatabase().StringSetAsync(key, JsonSerializer.Serialize(obj));
}

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

public class Startup
{
    private readonly IConfiguration _configuration;

    public Startup(IConfiguration configuration) =>
        _configuration = configuration;

    private string GetRedisConnectionString() =>
        _configuration.GetConnectionString("Redis");

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc(o => o.EnableEndpointRouting = false);

        services.AddSingleton<IConnectionMultiplexer>(
            ConnectionMultiplexer.Connect(GetRedisConnectionString()));

        services.AddSingleton<CachingLayer>();

        services.AddHttpClient<LocationProvider>();
        services.AddTransient<SolarCalculator>();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
            app.UseDeveloperExceptionPage();

        app.UseMvcWithDefaultRoute();
    }
}

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

Хоть проект и довольно прост, это приложение уже содержит в себе достаточное количество инфраструктурной сложности: оно полагается на сторонний веб-сервис (провайдера GeoIP), а также на слой хранения данных (Redis). Это вполне стандартная схема, используемая во многих реальных проектах.

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

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

public class FakeApp : IDisposable
{
    private readonly WebApplicationFactory<Startup> _appFactory;

    public HttpClient Client { get; }

    public FakeApp()
    {
        _appFactory = new WebApplicationFactory<Startup>();
        Client = _appFactory.CreateClient();
    }

    public void Dispose()
    {
        Client.Dispose();
        _appFactory.Dispose();
    }
}

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

Мы можем использовать экземпляр этого объекта в тестах для запуска приложения и отправки запросов с предоставленным HttpClient, а затем проверять соответствует ли ответ нашим ожиданиям. Этот экземпляр может быть или общим для нескольких тестов, или создаваться отдельно для каждого теста.

Поскольку мы также используем Redis, нам нужен способ запуска нового сервера, который будет использоваться приложением. Существует множество способов реализации этого, но для простого примера я решил использовать в этих целях API оборудования (fixture) фреймворка xUnit:

public class RedisFixture : IAsyncLifetime
{
    private string _containerId;

    public async Task InitializeAsync()
    {
        // Simplified, but ideally should bind to a random port
        var result = await Cli.Wrap("docker")
            .WithArguments("run -d -p 6379:6379 redis")
            .ExecuteBufferedAsync();

        _containerId = result.StandardOutput.Trim();
    }

    public async Task ResetAsync() =>
        await Cli.Wrap("docker")
            .WithArguments($"exec {_containerId} redis-cli FLUSHALL")
            .ExecuteAsync();

    public async Task DisposeAsync() =>
        await Cli.Wrap("docker")
            .WithArguments($"container kill {_containerId}")
            .ExecuteAsync();
}

Показанный выше код реализует интерфейс IAsyncLifetime, позволяющий нам определять методы, которые будут выполняться до и после запусков тестов. Мы используем эти методы для запуска контейнера Redis в Docker с последующим его уничтожением после завершением тестирования.

Кроме того, класс RedisFixture также раскрывает метод ResetAsync, который можно использовать для выполнения команды FLUSHALL, удаляющей все ключи из базы данных. Мы будем вызывать этот метод перед каждым тестом для сброса Redis к чистому состоянию. В качестве альтернативы мы могли бы просто перезапускать контейнер, что занимает больше времени, но более надёжно.

Настроив инфраструктуру, можно переходить к написанию первого теста:

public class SolarTimeSpecs : IClassFixture<RedisFixture>, IAsyncLifetime
{
    private readonly RedisFixture _redisFixture;

    public SolarTimeSpecs(RedisFixture redisFixture)
    {
        _redisFixture = redisFixture;
    }

    // Reset Redis before each test
    public async Task InitializeAsync() => await _redisFixture.ResetAsync();

    [Fact]
    public async Task User_can_get_solar_times_for_their_location_by_ip()
    {
        // Arrange
        using var app = new FakeApp();

        // Act
        var response = await app.Client.GetStringAsync("/solartimes/by_ip");
        var solarTimes = JsonSerializer.Deserialize<SolarTimes>(response);

        // Assert
        solarTimes.Sunset.Should().BeWithin(TimeSpan.FromDays(1)).After(solarTimes.Sunrise);
        solarTimes.Sunrise.Should().BeCloseTo(DateTimeOffset.Now, TimeSpan.FromDays(1));
        solarTimes.Sunset.Should().BeCloseTo(DateTimeOffset.Now, TimeSpan.FromDays(1));
    }
}

Как видите, схема очень проста. Нам достаточно лишь создать экземпляр FakeApp и использовать предоставленный HttpClient для отправки запросов к одной из конечных точек, как бы это происходило в реальном веб-приложении.

Конкретно этот тест запрашивает маршрут /solartimes/by_ip, определяющий время восхода и заката для текущей даты на основании IP пользователя. Так как мы полагаемся на настоящего провайдера GeoIP и не знаем, каким будет результат, то выполняем утверждения на основе свойств, чтобы гарантировать валидность времени восхода и заката.

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

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

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

Для этого нам понадобится создать фильтр запуска, позволяющий нам при помощи middleware инъектировать выбранный IP-адрес в контекст запроса:

public class FakeIpStartupFilter : IStartupFilter
{
    public IPAddress Ip { get; set; } = IPAddress.Parse("::1");

    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> nextFilter)
    {
        return app =>
        {
            app.Use(async (ctx, next) =>
            {
                ctx.Connection.RemoteIpAddress = Ip;
                await next();
            });

            nextFilter(app);
        };
    }
}

Затем мы можем соединить его с FakeApp, зарегистрировав его в качестве сервиса:

public class FakeApp : IDisposable
{
    private readonly WebApplicationFactory<Startup> _appFactory;
    private readonly FakeIpStartupFilter _fakeIpStartupFilter = new FakeIpStartupFilter();

    public HttpClient Client { get; }

    public IPAddress ClientIp
    {
        get => _fakeIpStartupFilter.Ip;
        set => _fakeIpStartupFilter.Ip = value;
    }

    public FakeApp()
    {
        _appFactory = new WebApplicationFactory<Startup>().WithWebHostBuilder(o =>
        {
            o.ConfigureServices(s =>
            {
                s.AddSingleton<IStartupFilter>(_fakeIpStartupFilter);
            });
        });

        Client = _appFactory.CreateClient();
    }

    /* ... */
}

Теперь мы можем дополнить тест, чтобы он использовал конкретные данные:

[Fact]
public async Task User_can_get_solar_times_for_their_location_by_ip()
{
    // Arrange
    using var app = new FakeApp
    {
        ClientIp = IPAddress.Parse("20.112.101.1")
    };

    var date = new DateTimeOffset(2020, 07, 03, 0, 0, 0, TimeSpan.FromHours(-5));
    var expectedSunrise = new DateTimeOffset(2020, 07, 03, 05, 20, 37, TimeSpan.FromHours(-5));
    var expectedSunset = new DateTimeOffset(2020, 07, 03, 20, 28, 54, TimeSpan.FromHours(-5));

    // Act
    var query = new QueryBuilder
    {
        {"date", date.ToString("O", CultureInfo.InvariantCulture)}
    };

    var response = await app.Client.GetStringAsync($"/solartimes/by_ip{query}");
    var solarTimes = JsonSerializer.Deserialize<SolarTimes>(response);

    // Assert
    solarTimes.Sunrise.Should().BeCloseTo(expectedSunrise, TimeSpan.FromSeconds(1));
    solarTimes.Sunset.Should().BeCloseTo(expectedSunset, TimeSpan.FromSeconds(1));
}

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

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

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

[Fact]
public async Task User_can_get_solar_times_for_a_specific_location_and_date()
{
    // Arrange
    using var app = new FakeApp();

    var date = new DateTimeOffset(2020, 07, 03, 0, 0, 0, TimeSpan.FromHours(+3));
    var expectedSunrise = new DateTimeOffset(2020, 07, 03, 04, 52, 23, TimeSpan.FromHours(+3));
    var expectedSunset = new DateTimeOffset(2020, 07, 03, 21, 11, 45, TimeSpan.FromHours(+3));

    // Act
    var query = new QueryBuilder
    {
        {"lat", "50.45"},
        {"lon", "30.52"},
        {"date", date.ToString("O", CultureInfo.InvariantCulture)}
    };

    var response = await app.Client.GetStringAsync($"/solartimes/by_location{query}");
    var solarTimes = JsonSerializer.Deserialize<SolarTimes>(response);

    // Assert
    solarTimes.Sunrise.Should().BeCloseTo(expectedSunrise, TimeSpan.FromSeconds(1));
    solarTimes.Sunset.Should().BeCloseTo(expectedSunset, TimeSpan.FromSeconds(1));
}

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

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

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

Мы можем изменить реализацию SolarCalculator разделив чистые и загрязнённые части кода:

public class SolarCalculator
{
    private static TimeSpan CalculateSolarTimeOffset(Location location, DateTimeOffset instant,
        double zenith, bool isSunrise)
    {
        /* ... */
    }

    public SolarTimes GetSolarTimes(Location location, DateTimeOffset date)
    {
        var sunriseOffset = CalculateSolarTimeOffset(location, date, 90.83, true);
        var sunsetOffset = CalculateSolarTimeOffset(location, date, 90.83, false);

        var sunrise = date.ResetTimeOfDay().Add(sunriseOffset);
        var sunset = date.ResetTimeOfDay().Add(sunsetOffset);

        return new SolarTimes
        {
            Sunrise = sunrise,
            Sunset = sunset
        };
    }
}

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

Чтобы снова соединить всё вместе, нам достаточно изменить контроллер:

[ApiController]
[Route("solartimes")]
public class SolarTimeController : ControllerBase
{
    private readonly SolarCalculator _solarCalculator;
    private readonly LocationProvider _locationProvider;
    private readonly CachingLayer _cachingLayer;

    public SolarTimeController(
        SolarCalculator solarCalculator,
        LocationProvider locationProvider,
        CachingLayer cachingLayer)
    {
        _solarCalculator = solarCalculator;
        _locationProvider = locationProvider;
        _cachingLayer = cachingLayer;
    }

    [HttpGet("by_ip")]
    public async Task<IActionResult> GetByIp(DateTimeOffset? date)
    {
        var ip = HttpContext.Connection.RemoteIpAddress;
        var cacheKey = ip.ToString();

        var cachedSolarTimes = await _cachingLayer.TryGetAsync<SolarTimes>(cacheKey);
        if (cachedSolarTimes != null)
            return Ok(cachedSolarTimes);

        // Composition instead of dependency injection
        var location = await _locationProvider.GetLocationAsync(ip);
        var solarTimes = _solarCalculator.GetSolarTimes(location, date ?? DateTimeOffset.Now);

        await _cachingLayer.SetAsync(cacheKey, solarTimes);

        return Ok(solarTimes);
    }

    /* ... */
}

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

[Fact]
public void User_can_get_solar_times_for_New_York_in_November()
{
    // Arrange
    var location = new Location
    {
        Latitude = 40.71,
        Longitude = -74.00
    };

    var date = new DateTimeOffset(2019, 11, 04, 00, 00, 00, TimeSpan.FromHours(-5));
    var expectedSunrise = new DateTimeOffset(2019, 11, 04, 06, 29, 34, TimeSpan.FromHours(-5));
    var expectedSunset = new DateTimeOffset(2019, 11, 04, 16, 49, 04, TimeSpan.FromHours(-5));

    // Act
    var solarTimes = new SolarCalculator().GetSolarTimes(location, date);

    // Assert
    solarTimes.Sunrise.Should().BeCloseTo(expectedSunrise, TimeSpan.FromSeconds(1));
    solarTimes.Sunset.Should().BeCloseTo(expectedSunset, TimeSpan.FromSeconds(1));
}

[Fact]
public void User_can_get_solar_times_for_Tromso_in_January()
{
    // Arrange
    var location = new Location
    {
        Latitude = 69.65,
        Longitude = 18.96
    };

    var date = new DateTimeOffset(2020, 01, 03, 00, 00, 00, TimeSpan.FromHours(+1));
    var expectedSunrise = new DateTimeOffset(2020, 01, 03, 11, 48, 31, TimeSpan.FromHours(+1));
    var expectedSunset = new DateTimeOffset(2020, 01, 03, 11, 48, 45, TimeSpan.FromHours(+1));

    // Act
    var solarTimes = new SolarCalculator().GetSolarTimes(location, date);

    // Assert
    solarTimes.Sunrise.Should().BeCloseTo(expectedSunrise, TimeSpan.FromSeconds(1));
    solarTimes.Sunset.Should().BeCloseTo(expectedSunset, TimeSpan.FromSeconds(1));
}

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

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

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

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

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

[Fact]
public async Task User_can_get_solar_times_for_their_location_by_ip_multiple_times()
{
    // Arrange
    using var app = new FakeApp();

    // Act
    var collectedSolarTimes = new List<SolarTimes>();

    for (var i = 0; i < 3; i++)
    {
        var response = await app.Client.GetStringAsync("/solartimes/by_ip");
        var solarTimes = JsonSerializer.Deserialize<SolarTimes>(response);

        collectedSolarTimes.Add(solarTimes);
    }

    // Assert
    collectedSolarTimes.Select(t => t.Sunrise).Distinct().Should().ContainSingle();
    collectedSolarTimes.Select(t => t.Sunset).Distinct().Should().ContainSingle();
}

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

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


Обратите внимание, что скорость выполнения тестов довольно хороша — самый быстрый интегральный тест завершается за 55 мс, а самый медленный — меньше чем за секунду (из-за холодного запуска). Учитывая то, что эти тесты задействуют весь рабочий цикл, в том числе все зависимости и инфраструктуру, при этом не используя никаких заглушек, я могу сказать, что это более чем приемлемо.

Если вы хотите самостоятельно поэкспериментировать с проектом, то его можно найти на GitHub.

Недостатки и ограничения


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

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

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

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

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

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

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

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

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

Выводы


Юнит-тестирование — популярный подход к тестированию ПО, но в основном по ошибочным причинам. Часто его навязывают как эффективный способ для тестирования разработчиками своего кода, стимулирующий к использованию best practices проектирования, однако многие считают его затруднительным и поверхностным.

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

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

Вот основные уроки:

  1. Рассуждайте критически и подвергайте сомнению best practices
  2. Не полагайтесь на пирамиду тестирования
  3. Разделяйте тесты по функциональности, а не по классам, модулям или области действия
  4. Стремитесь к максимально высокому уровню интеграции, сохраняя при этом разумные скорость и затраты
  5. Избегайте жертвования структурой ПО в пользу тестируемости
  6. Используйте заглушки только в крайних случаях

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