Любое приложение изобилует методами, возвращающими ссылочные типы или изменяющими их состояние в некотором источнике. В момент тестирования таких методов разработчик сталкивается с весьма неприятной задачей – в 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) &&
string.Equals(Title, other.Title) &&
string.Equals(Preview, other.Preview) &&
Created.Equals(other.Created) &&
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) &&
string.Equals(article.Title, other.Title) &&
string.Equals(article.Preview, other.Preview) &&
article.Created.Equals(other.Created) &&
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, их тут нет и такой подход наиболее предпочтителен.