[Из песочницы] Что-то не то с тестированием в .NET (Java и т.д.)

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

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

В .net интерфейсы есть, а значит выбор очевиден. Я взял пример из замечательной книги Марка Симана «Внедрение зависимостей в .Net», чтобы показать некоторые проблемы, которые есть в данном подходе.

Необходимо отобразить простой список рекомендуемых товаров, если список просматривает привилегированный пользователь, то цена всех товаров должна быть снижена на 5 процентов.

Реализуем самым простым способом:
public class ProductService
{
        private readonly DatabaseContext _db = new DatabaseContext();
    
        public List 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 GetFeaturedProducts();
}

public class ProductRepository : IProductRepository
{
    private readonly DatabaseContext _db = new DatabaseContext();
    public IEnumerable GetFeaturedProducts()
    {
        return _db.Products.Where(x => x.IsFeatured);
    }
}

Изменим сервис, чтобы он использовал их:
public class ProductService
{
    IProductRepository _productRepository;
    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public List 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();
    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), содержащие начальный минимальный набор данных. Большим минусом такого решения является необходимость поддерживать эти файлы вручную (изменения в структуре данных, связь начальных данных друг с другом и с кодом тестов).

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

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

  • 28 декабря 2016 в 13:16

    0

    Ок. Работаем дальше.
  • 28 декабря 2016 в 13:21

    0

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


    Мы у себя, например, запускаем приложение целиком на чистой базе, а затем пинаем настоящими HTTP-запросами. Тесты, соответственно, проверяют ответы и/или изменения в состоянии. Разного рода сервисы вроде отправлялки почты/смс заменяются на моки при инициализации приложения. Таким образом проверяется, что приложение работает и выполняет поставленные задачи, а не то, что конкретные классы по-отдельности работают так, как это было задумано.


    Но следует учитывать, что юнит-тесты всё ещё имеет смысл использовать если у пишется не контроллер/сервис/репозиторий, которые удобно просто запустить в составе приложения, а некий компонент, пригодный для работы в изолированном окружении, то есть, не имеющий зоопарка зависимостей по 30 методов у каждой.

  • 28 декабря 2016 в 13:25 (комментарий был изменён)

    +1

    На самом деле проблема приведенного подхода — в другом. И начинается она — с постановки задачи.


    если список просматривает привилегированный пользователь, то цена всех товаров должна быть снижена на 5 процентов

    А продавать ему будем по какой цене? По показанной — или по той, которая в базе? Если по той, что в базе — то еще ладно, но в первом-то случае логику формирования цены надо выносить в отдельный класс! И тестировать надо этот самый отдельный класс!


    Надо тестировать бизнес-логику (тот самый алгоритм определения цены одного товара для одного пользователя). Надо тестировать репозиторий — он должен правильно фильтровать получаемые из базы данные. Но нет никакого смысла тестировать код, который делегировал другим всю свою логику работы.

    • 28 декабря 2016 в 13:35

      0

      Но нет никакого смысла тестировать код, который делегировал другим всю свою логику работы.

      На самом деле смысл прогонять весь конвейер обработки запроса есть, ибо может выясниться, что все 15 компонент протестированы по-отдельности, но из-за ошибки в контроллере или мидлвари/http-модуле в итоге что-то не работает, причём только на этом конкретном сценарии

      • 28 декабря 2016 в 13:39

        0

        Да, но это имеет смысл только в интеграционном тесте. Нет смысла тестировать конвейр сам по себе, замокав все этапы.

  • 28 декабря 2016 в 13:33

    0

    Кстати, для тестирования кода, работающего с БД через EF может подойти Effort Сам с ним так и не поработал —, но направление у них считаю правильным.

  • 28 декабря 2016 в 13:44 (комментарий был изменён)

    0

    Вот только не стоит так обобщать.

    Что-то не так не в .Net/Java и т.д., а исключительно в этом конкретном методе.

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

    Этот метод одновременно достает данные из базы, и вычисляет скидки. Вы намешали в него две совершенно разные функции. Чтож теперь жаловаться, что его сложно тестировать?

    P.S. Пока писал — уже ровно тоже самое выше изложили другими словами.

© Habrahabr.ru