Страницы

воскресенье, 18 марта 2012 г.

Эффективное тестирование. Скажи нет мock’ам – корни зла.

  В прошлый раз я рассказывал про подмену зависимостей с использованием mock-объектов. Настало время предупредить об опасноcтях этого пути. Пока я писал это сообщение, очень хотелось быть англоязычным блоггером, и озаглавить его незабвенным “Mocks considered harmful”. Я не первый: есть как минимум два единомышленника. И к своим выводам мы пришли независимо, основываясь на горьком опыте. Давайте посмотрим на пример, и потом объясню, почему считаю mock-объекты вредными.

Код

  Давайте представим себе задачу: некий планировщик раз в сутки запускает задачу по пересчёту цен из урюпинских ёжиков в деревянные рубли. Такая задача может выглядеть примерно так:

public class UpdatePricesTask : ITask
{
    private ISessionFactory _sessionFactory;
    private IClock _clock;
    private IRateProvider _rateProvider;
    
    public UpdatePricesTask(ISessionFactory sessionFactory, IClock clock, IRateProvider rateProvider)
    {
        _sessionFactory = sessionFactory;
        _clock = clock;
        _rateProvider = rateProvider;
    }
    
    public void Run()
    {
        var rate = _rateProvider.GetRateOn(_clock.Today);
        
        using(var session = _sessionFactory.OpenSession())
        using(var tx = session.BeginTransaction())
        {
            var prices = session
                .CreateQuery("from Price p where p.ValidFrom >= :currentDate and :currentDate <= p.ValidTo")
                .SetParameter("currentDate", _clock.Today)
                .List<Price>();
            foreach(var price in prices)
            {
                price.UpdateLocalPriceUsing(rate);
            }
            session.Flush();
            tx.Commit();
        }
    }
}


  Просто, неправда ли? А теперь давайте посмотрим, как будет выглядеть классический тест с использованием mock’ов:

[Test]
public void TestUsingMocks()
{
    //Arrange.
    var currentDate = DateTime.Today;
    var price = new Price(10);
    var pricesToUpdate = new List<Price>{ price };
        
    var repo = new MockRepository(MockBehavior.Loose);
        
    var transactionMock = repo.Create<ITransaction>();
    transactionMock
        .Setup(x => x.Commit())
        .Verifiable();
    var queryMock = repo.Create<IQuery>();
    queryMock
        .Setup(x => x.SetParameter("currentDate", currentDate))
        .Returns(queryMock.Object);
            
    queryMock
        .Setup(x => x.List<Price>())
        .Returns(pricesToUpdate);
           
        
    var sessionMock = repo.Create<ISession>();
        
    sessionMock
        .Setup(x => x.BeginTransaction())
        .Returns(transactionMock.Object)
        .Verifiable();
        
    sessionMock
        .Setup(x => x.CreateQuery("from Price p where p.ValidFrom >= :currentDate and :currentDate <= p.ValidTo"))
        .Returns(queryMock.Object);
        
        
    var sessionFactoryMock = repo.Create<ISessionFactory>();
    sessionFactoryMock
        .Setup(x => x.OpenSession())
        .Returns(sessionMock.Object);
        
    var clockMock = repo.Create<IClock>();
    clockMock
        .SetupGet( x => x.Today)
        .Returns(currentDate);
        
    var rateProviderMock = repo.Create<IRateProvider>();
        
    rateProviderMock
        .Setup(x => x.GetRateOn(currentDate))
        .Returns(2);
        
    var task = new UpdatePricesTask(sessionFactoryMock.Object, clockMock.Object, rateProviderMock.Object);
        
    //Act.
    task.Run();
        
    //Assert.
    Assert.That(price.LocalAmount, Is.EqualTo(20));
    repo.VerifyAll();
}


  Ух-ты! Целый экран на один тест! В несколько раз больше тестируемого кода! Выглядит пострашнее, чем подделки под документальные фильмы от телекомпании НТВлжёт!  И это еще упрощённый случай. Ведь не тестируется обработка исключений, логгировние, и т.д. Каждый из этих случаев заслуживает отдельной портянки mock’-тестов. Давайте огласим весь список проблем по порядку.



Проблемы



  Сложность и многословность установки ожиданий затрудняет понимание теста. Эту проблему можно облегчить, вынеся установку ожидний в отдельные методы. Тест станет более читаемым, но сложность установки ожиданий никуда не денется. Ведь по-прежнему придётся лазить в код класса и код теста, чтобы понять логику теста. Это особенно сложно, когда надо учитывать последовательность вызовов, и менять возвращаемое значение на каждый вызов.



  Жесткая привязка к деталям реализации затрудняет рефакторинг. Тест знает каждую деталь реализации. И это ужасно. Представим себе, что я прознал про новое API NHibernate – QueryOver, и хочу переписать код так:

var prices = session.QueryOver<Price>
    .Where(x => x.Validrom >= _clock.Today && _clock.Today <= x.ValidTo)
    .List<Price>();


  Очевидно, код по-прежему рабочий, функциональность не сломана. Но тест красный. Надо снова вгрызаться в мельчайшие детали Setup’ов. Это муторная демотивирующая работа. Разработчики будут стараться не трогать тест и функциональность, лишь бы не мучить себя этой нудной задачей, либо отключать тест до лучших времён (да-да, лучшие времена обычно не настают, тест лежит мёртвым грузом). И с этим невозможно ничего поделать. Если что-то сложно делать – оно не будет сделано вообще, или сделано из рук вон плохо, для галочки. Как следствие код будет постепенно деградировать.



  Логика установки ожиданий дублирует логику класса. Каждый вызыванный метод, каждый аргумент, буквально на каждую строчку в рабочем тесте есть несколько строк в коде теста. Фактически, мы не тестируем результат работы класса, а тестируем идентичность кода класса коду установки ожиданий.



  Не гарантирует работу всей системы. Mock’и подменяют зависимости на другие компоненты. Но большая часть ошибок приходится именно на неправильное взамодействивия между компонентами, особенно в случае сторонних компонентов. Поэтому моки ничего нам не гарантируют. Нам надо заново перетестировать всё в процессе интеграционного тестирования. В теории это звучит хорошо, но на самом деле чрезвычайно трудоёмко. К тому же интеграционной тест будет частично дублировать логику модульного теста. Ваш клиент готов оплачивать вам неделю на тестирование одного класса? Боюсь что нет.



  Не позволяет тестировать все аспекты класса. Посмотирте внимальнее на запрос к БД. Мы ищем все цены, которые действительны на заданную дату. И мы устанавливаем в качестве результа некий список цен. А как мы проверим тот факт, что в реальности в него войдут только корректные цены? Никак. Совсем никак. Только интеграционное тестирование может выявить этот момент.



Выводы



  Главная цель автоматизированного тестирования – облегчение разработки, поддержка действий программиста. Mock’и не только не помогают решать их, но и активно мешают этому процессу. Избегайте их всеми силами.



  Единственный вариант использования, который не расстраивал меня, если я могу ограничиться кодом:

[Test]
public void FooWorksFine()
{
    var fooMock = new Mock<IFoo>();
    fooMock.SetupAllProperties();
    //other stuff
}


  Думаю, мой читатель уже затаил вопрос: если выкинуть mock’и, то как тестировать? Об этом в следующий раз.



  Полностью код блога доступен на github.

Комментариев нет:

Отправить комментарий