Все хотят писать тесты, но мало кто это делает. На мой взгляд причина в существующих рекомендациях и практиках. Большинство усилий при тестировании бизнес-приложений прикладывается к работе с базой данных, это важная часть системы, которая очень тесно связана с основным кодом. Есть два принципиально разных подхода: абстрагировать логику от базы данных или подготавливать реальную базу для каждого теста.
Если ваш язык программирования строго-типизированный и в нем есть интерфейсы — почти наверняка вы будете работать с абстракциями. В динамических языках разработчики предпочитают работать с реальной базой.
В .net интерфейсы есть, а значит выбор очевиден. Я взял пример из замечательной книги Марка Симана “Внедрение зависимостей в .Net”, чтобы показать некоторые проблемы, которые есть в данном подходе.
Необходимо отобразить простой список рекомендуемых товаров, если список просматривает привилегированный пользователь, то цена всех товаров должна быть снижена на 5 процентов.
Реализуем самым простым способом:
public class ProductService
{
private readonly DatabaseContext _db = new DatabaseContext();
public List<Product> GetFeaturedProducts(bool isCustomerPreffered)
{
var discount = isCustomerPreffered ? 0.95m : 1;
var products = _db.Products.Where(x => x.IsFeatured);
return products.Select(p => new Product
{
Id = p.Id,
Name = p.Name,
UnitPrice = p.UnitPrice * discount
}).ToList();
}
}
Чтобы протестировать этот метод нужно убрать зависимость от базы — создадим интерфейс и репозиторий:
public interface IProductRepository
{
IEnumerable<Product> GetFeaturedProducts();
}
public class ProductRepository : IProductRepository
{
private readonly DatabaseContext _db = new DatabaseContext();
public IEnumerable<Product> GetFeaturedProducts()
{
return _db.Products.Where(x => x.IsFeatured);
}
}
Изменим сервис, чтобы он использовал их:
public class ProductService
{
IProductRepository _productRepository;
public ProductService(IProductRepository productRepository)
{
_productRepository = productRepository;
}
public List<Product> GetFeaturedProducts(bool isCustomerPreffered)
{
var discount = isCustomerPreffered ? 0.95m : 1;
var products = _productRepository.GetFeaturedProducts();
return products.Select(p => new Product
{
Id = p.Id,
Name = p.Name,
UnitPrice = p.UnitPrice * discount
}).ToList();
}
}
Все готово для написания теста. Используем mock для создания тестового сценария и проверим, что все работает как ожидается:
[Test]
public void IsPrefferedUserGetDiscount()
{
var mock = new Mock<IProductRepository>();
mock.Setup(f => f.GetFeaturedProducts()).Returns(new[] {
new Product { Id = 1, Name = "Pen", IsFeatured = true, UnitPrice = 50}
});
var service = new ProductService(mock.Object);
var products = service.GetFeaturedProducts(true);
Assert.AreEqual(47.5, products.First().UnitPrice);
}
Выглядит просто замечательно, что же тут не так? На мой взгляд… практически все.
Сложность и разделение логики
Даже такой простой пример стал сложнее и разделился на две части. Но эти части очень тесно связаны и такое разделение только увеличивает когнитивную нагрузку при чтении и отладки кода.
Множество сущностей и трудоемкость
Этот подход генерирует большое количество дополнительных сущностей, которые появились только из-за самого подхода к тестам. К тому же это достаточно трудоемко, как при написании нового кода, так и при попытке протестировать существующий код.
Dependency Injection
Позитивным побочным эффектом мы получили уменьшение связности кода и улучшили архитектуру. На самом деле скорее всего нет. Все действия были продиктованы желанием избавиться от базы, а не улучшением архитектуры и понятности кода. Так как база данных очень сильно связана с логикой, не уверен, что это приведет к улучшению архитектуры. Это настоящий карго культ — добавить интерфейсы и считать, что архитектура улучшилась.
Протестирована только половина
Это самая серьезная проблема — не протестирован репозиторий. Все тесты проходят, но приложение может работать не корректно (из-за внешних ключей, тригеров или ошибках в самих репозиториях). То есть нужно писать еще и тесты для репозиториев? Не слишком ли уже много возни, ради одного метода? К тому же репозиторий все равно придется абстрагировать от реальной базы и все что мы проверим, как хорошо, он работает с ORM библиотекой.
Mock
Выглядят здорово пока все просто, выглядят ужасно когда все сложно. Если код сложный и выглядит ужасно, его никто не будет поддерживать. Если вы не поддерживаете тесты, то у вас нет тестов.
Подготовка окружения для тестов это самая важная часть и она должна быть простой, понятной и легко поддерживаемой.
Абстракции протекают
Если вы спрятали свою ORM за интерфейс, то с одной стороны, она не использует всех своих возможностей, а с другой ее возможности могут протечь и сыграть злую шутку. Это касается подгрузки связанных моделей, сохранение контекста … и т.д.
Как видите довольно много проблем с этим подходом. А что насчет второго, с реально базой? Мне кажется он намного лучше.
Мы не меняем начальную реализацию ProductService. Тестовый фреймворк для каждого теста предоставляет чистую базу данных, в которую необходимо вставить данные необходимые для проверки сервиса:
[Test]
public void IsPrefferedUserGetDiscount()
{
using (var db = new DatabaseContext())
{
db.Products.Add(new Product { Id = 1, Name = "Pen", IsFeatured = true, UnitPrice = 50});
db.SaveChanges();
};
var products = new ProductService().GetFeaturedProducts(true);
Assert.AreEqual(47.5, products.First().UnitPrice);
}
Нет моков, нет разделения логики и протестирована работа с настоящей базой. Такой подход значительно удобнее и понятнее, и к таким тестам больше доверия, что все на самом деле работает как нужно.
Тем не менее, есть небольшая проблема. Настоящая система имеет множество зависимостей в таблицах, необходимо заполнить несколько других таблиц только для вставки одной строки в Продукты. Например, Продукты могут требовать Производителя, а он в свою очередь Страну.
Для этого есть решение: начальные «фикстуры» — текстовые файлы (чаще всего в json), содержащие начальный минимальный набор данных. Большим минусом такого решения является необходимость поддерживать эти файлы вручную (изменения в структуре данных, связь начальных данных друг с другом и с кодом тестов).
При правильном подходе тестирование с реальной базой на порядок проще абстрагирования. А самое главное, что упрощается код сервисов, меньше лишнего бойлерплейт кода. В следующей статье, я расскажу как мы организовали тестовый фреймворк и применили несколько улучшений (например, к фикстурам).
Автор: justserega