Я часто собеседую кандидатов на позиции .Net разработчиков в Retail Rocket. В прошлом работал в компаниях с различными командами. И далеко не один раз встречал и продолжаю встречать мнение, что “автотесты хорошо, но на них нет времени, писать их дорого, тестировать должны тестировщики”. Такое мнение не у всех, но встречается нередко (не исключаю, что мне так «везет»). В связи с этим хочу поделиться нашим подходом к автоматическому тестированию и обеспечению качества. Расскажу путь, который мы в Retail Rocket прошли за последние 3-4 года, к чему пришли сейчас, и —  главное — что дают нам автотесты и для чего мы их пишем. Надеюсь, статья кого-нибудь сподвигнет писать автотесты, кого-то — писать больше автотестов, а кому-то, возможно, поможет избежать ошибок, с которыми мы сталкивались.

Введение

Сервисами Retail Rocket пользуются тысячи клиентов во всем мире. Мы обрабатываем огромные объемы данных с высокой нагрузкой и достаточно жесткими требованиями к производительности и надежности. Каждую секунду тысячи пользователей просматривают товары в интернет-магазинах, совершают покупки, и для каждого из них в режиме реального времени мы должны сформировать рекомендации. Несколько сервисов, с которыми наша команда имеет дело:

  • Web-приложение (api) — принимает, обрабатывает и/или публикует сообщение в очередь. Например, события просмотра, добавления в корзину, заказ товара пользователем, а также выдача различных рекомендаций для него.

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

  • Сервис отправки пользователю email-сообщений, push-уведомлений и sms с учетом персонализации и интересов (мы отправляем миллионы сообщений в час).

  • И т.п.

Наш типичный сервис взаимодействует с очередью Kafka, базой данных (иногда с несколькими), и/или принимает запросы и отвечает по HTTP.Фактически подавляющая часть нашей бизнес-логики заключается в получении запроса/сообщения/события, чтения каких-то данных из БД, сохранении данных в БД/очередь и/или генерации ответа. Поскольку такой логики очень много, а требования к надежности и производительности очень жесткие, мы должны тщательно тестировать все, что разрабатываем. Вот несколько типовых сценариев тестирования, которые мы пишем:

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

  • Интеграционные тесты:

    • Тесты на взаимодействие компонентов сервиса в целом. Например, в результате обработки сообщения E сервисом S с учетом состояния C мы ожидаем, что в базе D появятся данные X.

  • Приемочные тесты:

    • Тесты на функционал в личном кабинете (вход, выполнение каких-то действий, проверка результата).

  • Тесты на инфраструктуру:

    • Тесты на валидацию входных данных. В основном применимо к WebAPI- приложениям. Например, тестирование валидации Request.

    • Тесты на корректную сериализацию/десериализацию данных в БД. Поскольку мы много где используем БД без схемы (MongoDb, Redis), то должны быть уверены, что данные сохранятся в том формате, в котором ожидаем.

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

Цели автоматического тестирования

Попробую чуть подробнее раскрыть, какие цели мы перед собой ставим и какие задачи решаем, используя практику написания автотестов:

  • Качество выпускаемого продуктаМы хотим быть уверены, что выпускаемый нами функционал не содержит ошибок, пользователь получит только ожидаемое поведение, и его опыт не пострадает.

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

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

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

Терминология

Типы тестов

Как правило, выделяют следующие типы тестов:

Unit-тесты. Проверяют наименьший элемент системы, метод/функцию или класс. Цель unit-теста — проверка модуля в изоляции. Таким образом, unit-тест не включает тестирование других модулей. Т.к. практически каждый модуль имеет зависимости, используются моки (mocks), позволяющие протестировать модуль в изоляции.

Модульные (module) тесты. Границы модульного и unit-теста очень размыты. Существует некоторая попытка разделения, которая говорит, что unit-тест пишется одним программистом, который и написал код, в то время как модульные — несколькими программистами в команде. Здесь и далее мы не будем проводить этой границы, оба типа тестов будем называть unit-тесты.

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

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

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

Концепции тестирования

В приемочных тестах, как я уже упомянул, выделяют 2 концепции тестирования:

  • Тестирование черного ящика (black-box testing). Концепция предполагает, что мы не знаем внутреннего устройства и алгоритмов системы, которую тестируем. Обычно выполняется командой тестирования.

  • Тестирование белого ящика (white-box testing). В противоположность концепции черного ящика предполагает, что мы знаем устройство тестируемой системы. Обычно тоже выполняется разработчиками системы.

Подходы к тестированию

Существуют 2 подхода в тестировании:

  • Деструктивное (destructive) тестирование. Цель — найти ошибку в программе.

  • Конструктивное (constructive) тестирование. Больше согласуется со стремлением разработчиков иметь работающий код и доказательство этого.

Мы в своей практике пишем только конструктивные тесты. Деструктивные тесты все-таки должны писать специализированные команды. Нам же важно иметь доказательство, что написанный код работает и реализует бизнес-требования, а после изменения структуры / рефакторинга система не сломалась.

Эволюция подхода

Отсутствие практик и дисциплины

В первой версии статьи эта глава называлась “Unit- и интеграционные тесты”, но, как верно заметил один из моих коллег, название очень слабо связано с содержанием. Изначально у нас действительно были слабо развиты практики и дисциплина в плане тестирования. Мы писали unit-тесты, в некоторых случаях — интеграционные, иногда не писали совсем (когда считали, что “код слишком простой”, чтобы его тестировать). Несмотря на это, у нас уже было порядка 1500 или более тестов, не считая приемочных. Тут мы начали сталкиваться со следующими проблемами:

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

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

  • Стоимость поддержки. Тестов было уже очень много. Зачастую каждый из разработчиков писал тесты в своем стиле, не было общей концепции и структуры, могли использоваться различные подходы и паттерны. Повсюду использовались сторонние генераторы случайных наборов данных, что делало инициализацию тестов и объекта тестирования очень неявной.

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

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

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

TDD и хрупкие тесты

Следующий этап эволюции подхода — TDD. Несмотря на то, что мы сами тестируем свой код, бывало так, что какая-то часть не была покрыта тестами. Мы начали осваивать TDD, Red-Green-Refactor. Red-Green-Refactor подразумевает под собой небольшие циклы написания кода, чтобы тест работал, а также наведение порядка в коде и тесте. О TDD написано бесчисленное количество статей. Подробно о циклах в TDD писал Robert C. Martin в The Cycles of TDD.

Существует несколько утверждений, критикующих TDD:

  1. TDD портит архитектуру. Утверждается, что при использовании этого подхода разработчик думает в первую очередь о том, как сделать код изолированным для упрощения тестирования. В результате чего появляются дополнительные слои, зависимости, моки в тестах и т.п. (Test-induced design damage)

  2. Тесты становятся хрупкими. В тот момент, когда у вас уже много тестов, вы изменяете сигнатуру одного метода, и все тесты, которые используют данный метод, тоже необходимо изменить. (Fragile Tests)

  3. Сложность изменения продакшн-кода и увеличение стоимости поддержки и изменения. Чем больше кодовая база, тем больше тестов. Любое изменение продакшн-кода влечет починку большого количества тестов.

  4. Следствие пунктов 2 и 3: при изменении большого объема продакшн-кода пропадает уверенность в том, что после рефакторинга код работает также, т.к. приходится изменить еще больший объем тестов.

Со всеми этими проблемами нам случилось познакомиться. Наверное, в меньшей степени с первой, поскольку при разработке мы, как правило, вначале проектируем то, что будем разрабатывать, и придерживаемся определенных правил и практик. В то же время утверждения 2,3,4 нам теперь очень хорошо знакомы по собственному опыту.

Возьмем простой пример. Предположим, у нас есть бар и пивоналивочный аппарат в нем :) Аппарат может наливать пиво нескольких сортов, если, конечно, бочка с этим сортом не пуста. Клиент подходит к аппарату, выбирает сорт пива, и тот наливает ему кружку.

Очень упрощенно класс, содержащий эту логику, можно описать следующим образом:

public class BeerStation
{
   private IBeerBarrel beerBarrel;

   public Option<Beer> FillTheMug(int beerKind)
   {
       var isAvailable = this.beerBarrel.IsAvailable(beerKind);

       if (isAvailable)
       {
           return this.beerBarrel.Fill(beerKind).Some();
       }
      
       return Option.None<Beer>();
   }
}

Сначала проверяем, есть ли нужный сорт пива, если есть — наливаем. Все просто.

Если использовать TDD и следовать принципам “строчка теста — строчка кода”, R-G-R, то у вас, скорее всего, будет класс BeerStationTests, который будет содержать как минимум следующий набор тестов:

  1. ReturnsNoneIfNoBeer. Проверяет и, если нет пива, вернет None.

  2. ReturnsBeer. Проверяет и, если пиво есть, аппарат его нальет.

Приведу пример только 1-го теста:

[Fact]
public void ReturnsNoneIfNoBeer()
{
   var beerKind = 123;

   var beerBarrel = new Mock<IBeerBarrel>();
   beerBarrel
       .Setup(x => x.IsAvailable(beerKind))
       .Returns(false);

   var sut = new BeerStation(beerBarrel.Object);

   var actual = sut.FillTheMug(beerKind);

   Assert.Equal(expected: Option.None<Beer>(), actual: actual);
}

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

public Option<Beer> FillTheMug(int beerKind, int tare);

Что теперь? Все тесты (к счастью, их пока немного) нужно изменить. Хотя очевидно, что для них тип тары не важен. Мы должны добавить новые тесты на проверку того, что аппарат налил ожидаемое количество пива. 

Наш пример очень простой, но даже на нем мы заметили, что малейшие изменения вынуждают нас менять тесты. 

Можно продолжить улучшения и, например, добавить возможность разливать пиво в одноразовые кружки (как вендинговый кофе-аппарат). Не уверен, что такие есть, но будем считать, что их специально производят для подобных аппаратов :) В нашем классе появится новая зависимость — контейнер с кружками. Нам снова нужно будет изменить все тесты и Setup теста, потому что появляется новая зависимость. Ее нужно “замокать”. Возможно, изменится и сигнатура метода.

Но действительно ли проблема в TDD или в чем-то другом? Кажется, что дело не в TDD, а в том, что мы транслируем структуру кода на структуру тестов, файл продакшн-кода и файл с тестами. Тестируем тем самым каждый класс. Идея может быть подсмотрена в статье Test Contra-variance.

Шаг к тестированию “черного ящика”

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

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

На этом этапе мы для себя определили следующее:

  1. Модуль тестируется целиком, со всеми его зависимостями;

  2. При создании модуля используются реализации зависимостей без “моков” и “стабов”;

  3. Исключениями являются зависимости, находящиеся на границе модуля. Например, БД. Данный слой мы могли “замокать”;

  4. Вводится слой TAL (Test Access Layer), предоставляющий тестовый API, содержащий методы создания, предустановки данных, вызова непосредственно тестируемого модуля. Данный слой призван абстрагировать тесты от структуры кода.

Приведу пример TestApi:

public class Tal
{
   public void Setup(
       int beerKind,
       bool isAvailable)
   {
       if (isAvailable)
       {
           this.beerBarrel.Renew(beerKind);
       }
       else
       {
           this.beerBarrel.Empty(beerKind);
       }
   }

   public Option<Beer> Fill(
       int beerKind,
       Option<int> tare = default)
   {
       return this.beerStation.FillTheMug(
           beerKind: beerKind,
           tare: tare.ValueOr(alternative: 1));
   }
}

Setup производит настройку и подготовку данных. Может принимать различные необязательные параметры. Различие в самих тестах только в том, что ранее вызывался бы метод тестируемого класса FillTheMug напрямую, а тут — обертка с необязательными параметрами. И это так! 

Однако обертка позволяет избавиться от необходимости менять все тесты, как и Setup.

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

Плюсы, которые получаем:

  1. Тесты стали чуть менее хрупкими;

  2. Рефакторинг стал несколько проще;

Пожалуй, все. Однако осталось еще много проблем:

  1. Нет четких критериев выделения модуля для тестирования. Где проходит граница: контроллер, адаптер, usecase бизнес-логики, query или command?

  2. Рефакторинг стал проще, но при большом рефакторинге приходилось изменять композицию нескольких модулей по отдельности.

  3. Оставались непокрытые тестами классы, которые тоже нужно тестировать.

  4. Композиция рабочего приложения никак не проверялась.

  5. В некоторых случаях приходится отдельно писать тесты на взаимодействие с инфраструктурой. Например, правила нейминга полей в mongoDb.

Тестирование черного ящика

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

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

На текущий момент у нас есть несколько правил:

  1. Используем боевую композицию приложения (все коннекшены, конечно же, тестовые).

  2. Мы больше не “мокаем” инфраструктуру, если приложение использует БД. Тесты также используют тестовую/разработческую или локальную/docker-версию БД (следствие п.1).

  3. Для тестирования используем варианты использования подсистемы. Например, если в результате обработки сообщения мы ожидаем, что в системе будет зарегистрирован пользователь, для валидации этого мы будем использовать вариант использования “FindUser”. Этот вариант будет использован потребителем подсистемы (например, другим приложением) вместо запроса к БД в тестах. По сути, теперь мы группируем тесты вокруг вариантов использования и описываем требования в терминах предметной области. Согласно старому подходу, использовалась группировка вокруг структуры кода.

  4. Специфичные тесты на валидацию выделяем отдельно (пример — валидация данных в запросе http).

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

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

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

Покрытие

Измерять покрытие мы стали практически с появлением в dotnet core для *nix этой возможности (изначально замер покрытия в dotnet core поддерживался только в Windows). Ранее мы просто не видели в этом необходимости, но в какой-то момент столкнулись с ошибкой в продакшне, которую бы не допустили, измеряя покрытие каждый раз при прохождении тестов и мониторя снижение этого показателя.

Выделяется 4 типа покрытия:

  • Statement Coverage. Оценивается покрытие выражений.

  • Line Coverage. Оценивается покрытие строк кода. Отличие Line и Statement Coverage в том, что на одной строке может быть 2 выражения (var x = 10; var y = x * 2;), а в некоторых случаях выражение может никогда не выполняться.

  • Branch Coverage. Оценивается покрытие всех возможных ветвлений во время выполнения. Например, if-else, switch case, циклы. Все эти конструкции увеличивают количество ветвлений в коде.

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

Мы используем только Line Coverage и ко всему новому коду предъявляем требование в 100% покрытии. Цель этого покрытия  сигнализировать нам о том, что мы забыли написать какой-то тест. Если после коммита покрытие стало меньше 100%, значит, был добавлен непокрытый тестами код, и билд упадет.

Итоги

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

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

  • Мы предъявляем требования к 100% покрытию тестами нового кода и при этом избавились от хрупких тестов.

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

  • Тестируем не только бизнес-логику, но и композицию, а также взаимодействие с инфраструктурой.

А как вы тестируете свои продукты? Было бы очень интересно узнать о чужом опыте. Предлагаю в комментариях подискутировать, кто какой подход использует и почему.

Автор: Артем Асташкин, Руководитель группы разработки Retail Rocket