Валидация объектов в тестах

Любое приложение изобилует методами, возвращающими ссылочные типы или изменяющими их состояние в некотором источнике. В момент тестирования таких методов разработчик сталкивается с весьма неприятной задачей – в assert’ах необходимо проверить все данные. В простых примерах с примитивными типами вся проверка сводилась к одному утверждению, в объекте же могут быть десятки примитивов, а в особо страшных случаях (в промышленном программировании постоянно, чего уж)) – другие объекты.

Постановка: необходимо протестировать следующий метод

public async Task<int> Create(Article article)
{
    await _context.AddAsync(article);
    await _context.SaveChangesAsync();
    return article.Id;
}

Класс Article описывается следующей моделью:

public class Article
{
    public string Text { get; set; }
    public string Title { get; set; }
    public string Preview { get; set; }
    public DateTime Created { get; set; }
    public bool Published { get; set; }
}
Раздуваем тест

Самый простой и прямолинейный подход, который выбирают очень и очень многие – сделать один тест со множеством утверждений. На практике это выглядит примерно так:

[Fact]
public async Task Create_Success ()
{
    var article = FakeFactory.Fixture.Create<Article>();
    //
    var result = await _repository.Create(article);
    //
    var expected = _context.Articles.First(e => e.Text == article.Text);
    Assert.True(result == expected.Id);
    Assert.True(article.Text == expected.Text);
    Assert.True(article.Title == expected.Title);
    Assert.True(article.Created == expected.Created);
    Assert.True(article.Preview == expected.Preview);
    Assert.True(article.Published == expected.Published);
}

*Если вы не в курсе, что это за FakeFactory и как она работает, советую обратиться к предыдущей статье.

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

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

Читать все это тоже становится удовольствием ниже среднего и тут уж никаких лайфхаков нет – если при чтении теста тебе приходится пользоваться скроллом, то пора бить тревогу.

Понятное дело, любое изменение в модели ведет к необходимости править все подобные тесты. И, что гораздо важнее, можно забыть добавить новое поле в тесты и они все равно будут проходить! Вот так и теряется доверие к тестирующему проекту…

Итого: сложно писать, сложно читать, сложно поддерживать.

Много, очень много тестов

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

Единственная возможная мотивация здесь – попытка соблюсти принцип лаконичности. Попытка, в целом успешная, но бесмыссленная…

Проще ли стало читать тест? Ничуть теперь их стало больше и скролл точно понадобится.

Проще ли писать? Ничего не изменилось, времени на написание будет затрачено примерно столько же, и, вероятно, чуть больше.

Поддержка? И снова без изменений.

Вторгаемся в функциональный код

Раздражение описанными подходами ведет нас к корню проблемы: наша задача проверить равенство объектов, и каждый программист знает, что у класса object есть метод Equals… Совпадение? Не думаю!

Все, что требуется – переопределить метод Equals в классе Article:

public bool Equals(Article other)
{
    if (ReferenceEquals(null, other)) return false;
    if (ReferenceEquals(this, other)) return true;
    return string.Equals(Text, other.Text) &amp;&amp;
           string.Equals(Title, other.Title) &amp;&amp;
           string.Equals(Preview, other.Preview) &amp;&amp;
           Created.Equals(other.Created) &amp;&amp;
           Published == other.Published;
}

public override bool Equals(object obj)
{
    if (ReferenceEquals(null, obj)) return false;
    if (ReferenceEquals(this, obj)) return true;
    if (obj.GetType() != this.GetType()) return false;
    return Equals((Article) obj);
}

Тест снова один и выглядит он гораздо опрятнее.

[Fact]
public async Task Create_Success ()
{
    var article = FakeFactory.Fixture.Create<Article>();
    //
    var result = await _repository.Create(article);
    //
    var expected = _context.Articles.First(e => e.Text == article.Text);
    Assert.True(result == expected.Id);
    Assert.True(article.Equals(expected));
}

Итак, чего же мы добились?

Читаемость выроста колоссально, тест лаконичен и понятен с первого взгляда.

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

Тесты стало легко поддерживать – все ключевые данные собраны компактно. К тому же гораздо сложнее забыть добавить новое поле в метод Equals, лежащий в этом же классе Article, нежели куда-то в тестирующий код.

Мы нашли «серебряную пулю»?) Отнюдь. Один из важнейших, впрочем, не писанных, принципов тестирования запрещает вмешательство в тестируемый код. А именно этим мы и занимаемся – мы вынесли обслуживающие тестирование методы в функциональный код! На первый взгляд это может показаться не существенным, а может и на второй… Много вопросов появится в тот момент, когда бизнес-требования потребуют использования метода Equals)

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

Специальные классы в тестирующем проекте

Создание расширений для моделей в тестовом проекте – одна из альтернатив. Для всех моделей создаются специальные методы сравнения:

public static class ArticleComparer
{
    public static bool IsEqual(this Article article, Article other)
    {
        if (article == null || other == null) return false;
        return string.Equals(article.Text, other.Text) &amp;&amp;
               string.Equals(article.Title, other.Title) &amp;&amp;
               string.Equals(article.Preview, other.Preview) &amp;&amp;
               article.Created.Equals(other.Created) &amp;&amp;
               article.Published == other.Published;
    }
}

Тест в данном случае практически не отличается от предыдущего примера:/p>

[Fact]
public async Task Create_Success ()
{
    var article = FakeFactory.Fixture.Create<Article>();
    //
    var result = await _repository.Create(article);
    //
    var expected = _context.Articles.First(e => e.Text == article.Text);
    Assert.True(result == expected.Id);
    Assert.True(article.IsEqual(expected));
}

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

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

Итог: легко писать, легко читать, довольно легко поддерживать.

Автоматизация

Последний наш вариант это использование специальных библиотек и мой личный выбор – SemanticComparison (конечно же он включен в "джентельменский набор" автотестера ).

Эта библиотека вовсе избавляет нас от необходимости вручную создавать какие-то реализации методов сравнения. Вся работа выполняется на лету и метод Equals переопределяется в ходе выполнения теста. Есть ли потери скорости? Безусловно. Но они не критичны, а чаще не заметны, а вот выгода от использования колоссальна.

Как уже говорилось, никаких спец.методов нам реализовывать уже не нужно, потому просто пишем тест:

[Fact]
public async Task Create_Success()
{
    var article = FakeFactory.Fixture.Create<Article>();
    //
    var result = await _repository.Create(article);
    //
    var expected = _context.Articles.First(e => e.Text == article.Text);
    var source = expected.AsSource().OfLikeness<Article>().Without(e => e.Created);
    Assert.True(result == expected.Id);
    Assert.True(source.Equals(article));
}

Единственное изменение – добавление нового объекта source, в его определении и заключается вся магия.

И еще один очень приятный момент – возможность выключить из сравнения отдельные поля в конкретной ситуации. Так сравнение Id, или даты создания может происходить на других уровнях системы и при полном сравнении объектов по всем полям выяснится, что они не равны. Но если они не равны в соответствии с ТЗ, архитектурой или бизнес-требованиями? SemanticComparison позволяет исключить из сравнения эти поля простой настройкой. В примере выше, мы исключили дату создания (Created).

Итог?

Читабельность не хуже и не лучше предыдущих двух вариантов (лучше уже вроде и некуда).

Простота написания… Очевидно, что это шаг вперед по сравнению с созданием спец.методов – кода стало меньше, что особенно актуально когда у вас 100+ моделей в проекте.

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

Минусы? Imho, их тут нет и такой подход наиболее предпочтителен.