Что-то не то с тестированием в .NET (Java и т.д.)

в 9:24, , рубрики: .net, database, development, тест, тестирование, Тестирование IT-систем

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

Если ваш язык программирования строго-типизированный и в нем есть интерфейсы — почти наверняка вы будете работать с абстракциями. В динамических языках разработчики предпочитают работать с реальной базой.

В .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

Источник

Поделиться

* - обязательные к заполнению поля