Размышления о тестировании за пределами «идеального сценария» (happy path).

Разработка через тестирование (TDD) — это отличный метод, который позволяет быстро получать обратную связь по идеям дизайна и реализации, а также быстрее прийти к работающему решению.

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

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

Выбрасывание исключений с динамическим mock-объектом 

В вопросе к другой статье AmirB спрашивает, как использовать Fake Object для тестирования исключений. В частности, поскольку Fake — это тестовый дублёр с согласованным контрактом, будет неуместно позволять ему выбрасывать исключения, относящиеся к разным реализациям.

Это довольно абстрактно, поэтому давайте рассмотрим конкретный пример.

В статье, в которой AmirB задал свой вопрос, в качестве примера использовался следующий интерфейс:

public interface IUserRepository
{
    User Read(int userId);
 
    void Create(int userId);
}

Конечно, он немного странный, но для текущей задачи его должно быть достаточно. Как написал AmirB:

«В сценариях, где используются mock-объекты (потипу Moq), мы можем замокать метод так, чтобы он выбрасывал исключение, что позволит нам протестировать ожидаемое поведение тестируемой системы (System Under Test, SUT)».

Конкретно это может выглядеть так при использовании Moq:

[Fact]
public void CreateThrows()
{
    var td = new Mock<IUserRepository>();
    td.Setup(r => r.Read(1234)).Returns(new User { Id = 0 });
    td.Setup(r => r.Create(It.IsAny<int>())).Throws(MakeSqlException());
    var sut = new SomeController(td.Object);
 
    var actual = sut.GetUser(1234);
 
    Assert.NotNull(actual);
}

Это всего лишь пример, но суть в том, что так как вы можете заставить динамический mock-объект делать всё, что вы можете определить в коде, вы также можете использовать его для симуляции исключений базы данных. Этот тест имитирует ситуацию, когда IUserRepository выбрасывает SqlException из метода Create.

Возможно, реализация GetUser теперь выглядит следующим образом:

public User GetUser(int userId)
{
    var u = this.userRepository.Read(userId);
    if (u.Id == 0)
        try
        {
            this.userRepository.Create(userId);
        }
        catch (SqlException)
        {
        }
    return u;
}

Я не удивлюсь, если вы посчитаете этот пример надуманным. Интерфейс IUserRepository, класс User и метод GetUser, который их объединяет, по-своему примитивны. Изначально я создал этот небольшой пример кода, чтобы обсудить проверку потоков данных, а теперь использую его не по назначению. Надеюсь, вы сможете не обращать на это внимания. Основная мысль, которую я пытаюсь донести, более общая и не зависит от деталей.

Fake

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

public sealed class FakeUserRepository : Collection<User>, IUserRepository
{
    public void Create(int userId)
    {
        Add(new User { Id = userId });
    }
 
    public User Read(int userId)
    {
        var user = this.SingleOrDefault(u => u.Id == userId);
        if (user == null)
            return new User { Id = 0 };
        return user;
    }
}

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

Стоит ли нам усложнять FakeUserRepository, добавив ему возможность выбрасывать определённые исключения?

Выбрасывание исключений из тестовых дублёров

Я понимаю, почему AmirB задаёт этот вопрос, ведь это действительно выглядит неправильным. Для начала, это противоречит принципу единой ответственности (Single Responsibility Principle). FakeUserRepository тогда имел бы более одной причины для изменения: вам пришлось бы изменять его, если изменится интерфейс IUserRepository, но также пришлось бы изменять его, если вы захотите смоделировать другую ситуацию с ошибкой.

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

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

Однако я бы счёл это признаком «дурного кода», если это происходит слишком часто. Не «дурного теста», а именно «дурного кода».

Является ли исключение частью контракта? 

Вы можете задаться вопросом, является ли определённый тип исключения частью контракта объекта. Как я всегда делаю, когда использую слово «контракт», я имею в виду набор инвариантов, предусловий и постусловий, беря пример с Object-Oriented Software Construction (построения объектно-ориентированного ПО). 

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

Какие выводы можно сделать из интерфейса IUserRepository? А какие не следует?

Я бы ожидал, что метод Read вернёт объект User. Пример кода был создан в 2013 году, до того как в C# появились nullable reference types (типы ссылок, допускающие значение null). В то время я начал использовать Maybe, чтобы сигнализировать о том, что возвращаемое значение может отсутствовать. Это условность, поэтому читатель должен знать о ней, чтобы правильно понять эту часть контракта. Поскольку метод Read не возвращает Maybe<User>, я мог бы предположить, что гарантируется получение ненулевого объекта User; это постусловие.

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

В результате таких размышлений возникает как минимум два вопроса:

  • Какие типы исключений могут выбрасывать методы?

  • Можно ли вообще обработать такие исключения?

Должен ли SqlException вообще быть частью контракта? Разве это не деталь реализации?

Класс FakeUserRepository не использует SQL Server и не выбрасывает SqlExceptions. Можно представить другие реализации, которые используют базу данных документов или даже просто другую реляционную базу данных, отличную от SQL Server (Oracle, MySQL, PostgreSQL и так далее). Эти реализации не выбрасывали бы SqlException, но, возможно, другие типы исключений.

Согласно принципу инверсии зависимостей,

«Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций». — Роберт К. Мартин, Agile Principles, Patterns, and Practices in C#

Если мы сделаем SqlException частью контракта, то деталь реализации станет частью контракта. Более того, в случае реализации, как в вышеупомянутом методе GetUser, который перехватывает SqlException, мы также нарушаем Принцип подстановки Лискова. Если вы внедрите другую реализацию, которая выбрасывает исключения другого типа, код перестанет работать так, как было задумано.

Слабо связанный код не должен выглядеть подобным образом.

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

Интеграционное тестирование 

Как нам тестировать на уровне юнит-тестов конкретные исключения? На самом деле, не стоит.

«Лично я избегаю использования блоков try-catch в репозиториях или контроллерах и предпочитаю обрабатывать исключения на уровне middleware (например, через ErrorHandler). В таких случаях я пишу отдельные юнит-тесты для middleware. Мне кажется, это наиболее подходящий подход».AmirB

Думаю, что это отличный подход для тех исключений, которые вы решили не обрабатывать явно. Такое middleware, как правило, будет записывать в логи или иным образом уведомлять операторов о возникшей проблеме. Вы также можете написать универсальное middleware, которое выполняет повторные попытки или реализует паттерн Circuit Breaker (автоматический выключатель), но уже существуют библиотеки, которые делают это. Рассмотрите возможность их использования.

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

Я бы предложил интеграционное тестирование.

У меня нет готового примера, связанного с выбрасыванием конкретных исключений, но что-то похожее может быть полезным. Пример кодовой базы, сопровождающий мою книгу Code That Fits in Your Head, моделирует систему онлайн-бронирования столиков в ресторане. Два клиента могут конкурировать за последний столик на определённую дату — типичная ситуация гонки.

Есть несколько способов решить эту проблему. Вы можете решить перестроить архитектуру всего приложения, чтобы надёжно обрабатывать подобные пограничные случаи. Однако для целей примера в книге я счёл архитектуру без блокировок неуместной. Вместо этого я рассматривал решение этой проблемы, используя возможности .NET и SQL Server по поддержке лёгких транзакций через TransactionScope. Хотя это удобное решение, оно полностью зависит от используемого технологического стека. Это хороший пример детали реализации, которую я бы не хотел включать в юнит-тесты.

Вместо этого я написал интеграционный тест, который запускается на реальном экземпляре SQL Server (автоматически развёрнутом и настроенном по запросу). Он тестирует поведение системы, а не детали реализации:

[Fact]
public async Task NoOverbookingRace()
{
    var start = DateTimeOffset.UtcNow;
    var timeOut = TimeSpan.FromSeconds(30);
    var i = 0;
    while (DateTimeOffset.UtcNow - start < timeOut)
        await PostTwoConcurrentLiminalReservations(start.DateTime.AddDays(++i));
}
 
private static async Task PostTwoConcurrentLiminalReservations(DateTime date)
{
    date = date.Date.AddHours(18.5);
    using var service = new RestaurantService();
 
    var task1 = service.PostReservation(new ReservationDtoBuilder()
        .WithDate(date)
        .WithQuantity(10)
        .Build());
    var task2 = service.PostReservation(new ReservationDtoBuilder()
        .WithDate(date)
        .WithQuantity(10)
        .Build());
    var actual = await Task.WhenAll(task1, task2);
 
    Assert.Single(actual, msg => msg.IsSuccessStatusCode);
    Assert.Single(
        actual,
        msg => msg.StatusCode == HttpStatusCode.InternalServerError);
}

Этот тест пытается сделать две параллельные брони на десять человек. Это также максимальная вместимость ресторана: разместить двадцать человек невозможно. Мы хотим, чтобы один из запросов «выиграл эту гонку», в то время как сервер должен отклонить запрос проигравшего.

Тест сосредоточен исключительно на наблюдаемом поведении со стороны клиентов. Поскольку кодовая база содержит сотни других тестов, которые проверяют HTTP-ответы, этот тест фокусируется только на статус-кодах.

Реализация обрабатывает потенциальную ситуацию с превышением бронирования следующим образом:

private async Task<ActionResult> TryCreate(Restaurant restaurant, Reservation reservation)
{
    using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
 
    var reservations = await Repository.ReadReservations(restaurant.Id, reservation.At);
    var now = Clock.GetCurrentDateTime();
    if (!restaurant.MaitreD.WillAccept(now, reservations, reservation))
        return NoTables500InternalServerError();
 
    await Repository.Create(restaurant.Id, reservation);
 
    scope.Complete();
 
    return Reservation201Created(restaurant.Id, reservation);
}

Обратите внимание на использование TransactionScope.

У меня есть иллюзия, будто я могу радикально изменить эту деталь реализации, не нарушив работу этого теста. Правда, эту гипотезу я пока не проверял на практике.

Заключение

Как автоматически тестировать ветки кода, обрабатывающие ошибки? Большинство фреймворков для юнит-тестирования предоставляют API, которые позволяют легко проверить, что было выброшено конкретное исключение, так что это не самая сложная часть. Если конкретное исключение является частью контракта тестируемой системы, просто протестируйте его таким образом.

С другой стороны, если речь идёт об объектах, которые составлены из других объектов, детали реализации могут легко «просочиться» в виде конкретных типов исключений. Я бы дважды подумал, прежде чем писать тест, который проверяет, обрабатывает ли клиентский кода (например, вышеупомянутый SomeController) определённый тип исключений (например, SqlException).

Если такой тест сложно написать, потому что у вас есть только Fake Object (например, FakeUserRepository), это хороший знак. Быстрая обратная связь, которую обеспечивает разработка через тестирование, снова сработала. Прислушайтесь к своим тестам.

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


Если вы хотите углубить знания в автоматизации тестирования, вам может быть полезен курс "QA Automation Engineer". На нём вы научитесь писать автотесты на Java, освоите пул инструментов (Postman, SoapUI, Selenium, IntelliJ IDEA, JUnit, Cucumber и прочие), а также получите практический опыт написания автотестов на реальных кейсах. Посмотреть программу и записи открытых уроков можно на странице курса.

Комментарии (1)


  1. Dacad
    16.09.2024 04:55

    Для таких случаев ещё есть способ с преобразованием исключения реализации в исключение абстракции. Т.е. вместо выбрасывания наружу SqlException нужно преобразовать его во что-то вроде RepositoryException стерев тем самым данные о внутренней реализации и в тестах проверять уже исключение абстракции