Страницы

среда, 21 марта 2012 г.

Эффективное тестирование. Скажи нет мock’ам – инфраструктура спешит на помощь.

  В прошлый раз я показал, к чему может приводить разнузданное использование mock-объектов. Один вид теста вводит меня в перманентную депрессию, а так хочется радоваться жизни. Откуда взялись болевые точки, мерзкие флюиды негатива, и как от них избавиться читайте в этом сообщении Улыбка.

mock не принесут мне счастья.

   Тесты с использованием Mock-объектов всегда основаны на поведении объектов, и подсчитывают вызванные методы и проверяют их аргументы в процессе выполнения некоего действия. Взгляните еще раз на код теста. Львиную долю кода занимают установки разного рода ответов на вызовы методов зависимостей. Очевидно, что мы должны уменьшить количество вызываемых методов для упрощения теста. Основной способ – агрегирование сервисов, т.е. к примеру в нашем случае можно собрать вызовы к ISession, и спрятать за IRepository. Но такой подход противен резким раздроблением кода на сверхмелкие, не оправдывающие своего существования  классы (уменьшение cohesion). И  последующим адом зависимостей. Четыре строки NHibernate-вызовов не заслуживают того, чтобы поместить их в отдельный класс. Так если не использовать Mock-объекты (и поведенческое тестирования), то остаётся один выход: заглушки (Stub).

Заглушки могли бы, но…

  Принципиальная разница между использованием заглушек и mock-объектов в подходе: мы проверяем не поведение, а состояние. Такой подход намного проще и понятнее, однако требует своей цены: заглушки придётся писать вручную, что дорого и не слишком эффективно. Давайте подумаем еще немного: если писать заглушки дорого – значит надо тестировать систему с максимальным вовлечением реальных объектов, и использовать минимальное количество заглушек. Но некоторые из них всё равно слишком сложно и дорого написать (Одно определение интерфейса ISession содержит несколько десятков свойств и методов, не говоря уже о других элементах API вроде IQuery, ISqlQuery и т.д.).  Похоже мы пришли в тупик? Нет! мы забыли ключевой элемент системы – инфраструктура.

седлаем инфраструктуру.

  Все проекты можно разделить на два вида инфраструктурные и бизнес-проекты. Бизнес проекты всегда пишутся поверх инфраструктурных проектов, и активно их используют. Выбранная инфраструктура прямо влияет на то, как вы тестируете (или можете ли тестировать) приложения, ибо код полностью зависит от нее. Из этого вытекает следствие: правильно выбирайте, и/или пишите свою инфраструктуру. Хорошая инфраструктура – ключ к лёгкому и быстрому тестированию. Основная мысль в том, чтобы отказаться от чистого модульного тестирования, и перейти к полу-интеграционному тестированию: инфраструктура переведена в специальный режим для обеспечения скорости и простоты тестирования, а при развёртывании приложения переходит в рабочий режим.
  Вернёмся к нашему примеру. Взглянем, какие зависимости есть у UpdatePricesTask.
public UpdatePricesTask(ISessionFactory sessionFactory, IClock clock, IRateProvider rateProvider)

  Я классифицирую их так:




  • ISessionFactory и IClock – инфраструктурные класс.
  • IRateProvider – удалённый сервис.

  На ISessionFactory мы повлиять не можем, но IClock – полностью рукописный класс, и при его реализации мы обязаны проектировать его с мыслью о тестирование в голове. Вот так могла бы выглядеть простейшая реализация IClock:
public interface IClock
{
    DateTime Today {get;}
    
    DateTime Now {get;}
}
public class Clock : IClock
{
    public DateTime Today
    {
        get { return DateTime.Today;}
    }
    
    public DateTime Now 
    {
        get { return DateTime.Now;}
    }
}


  Готов поспорить, ваша первая реализация выглядела именно так (и моя первая тоже). Они отлично работают, но совершенно непригодны для тестирования. Исправить ситуация просто, давайте подумаем, что нам надо от часов?




  • Возможность работы в нормальном режиме.
  • Возможность работы в тестовом режиме: фиксировать и менять значение текущего времени и даты.
  • Делать предыдущих два пункта простым способом.


 



Не жалейте хороший код для себя



  Если с реальным режимом работы всё понятно, то над тестовым есть где раскинуть мозгом. Какой обычный сценарий в большинстве тестов? Вызвать операцию над некоторым объектом, и затем проверить, что в состоянии этого объекта присутствует  текущее время. Т.е. часы должны иметь возможность установки какого-либо “текущего” времени. Но я ленивый, поэтому хочу сделать немного больше – пусть часы сами фиксируют текущее время, и дают возможность его прочитать. Оставим слова, лучше ближе к коду:
public class Clock : IClock
{
    public enum ClockMode 
    {
        RealTime, 
        FixedTime
    }
    
    /// <summary>
    /// Current ClockMode
    /// </summary>
    public readonly ClockMode Mode;
    
    private readonly object _latch;
    private DateTime? _freezedValue;
    public static readonly Clock RealTime;
    public static readonly Clock FixedTime;
    static Clock()
    {
        RealTime = new Clock();
        FixedTime = new Clock(ClockMode.FixedTime);
    }
    /// <summary>
    /// Creates new instance of <see cref="Clock"/> using realtime mode. 
    /// </summary>
    public Clock() : this(ClockMode.RealTime) 
    {
    }
    /// <summary>
    /// Creates new instance of <see cref="Clock"/> using supplied mode. 
    /// </summary>
    public Clock(ClockMode mode)
    {
        Mode = mode;
        _latch = new object();
    }
    /// <summary>
    /// Returns current date.
    /// </summary>
    public DateTime Today
    {
        get { return GetNow().Date;}
    }
    
    /// <summary>
    /// Returns current date and time. 
    /// </summary>
    public DateTime Now 
    {
        get { return GetNow();}
    }
    
    /// <summary>
    /// Sets user supplied datetime as current time.
    /// </summary>
    public void SetCurrentTime(DateTime value)
    {
        SetFreezedValue(value);
    }
    /// <summary>
    /// Forces <see cref="Clock"/> to pick and fix current datetime at the next call to Now or Today properties.
    /// </summary>
    public void NextValue()
    {
        SetFreezedValue(null);
    }
    private DateTime GetNow()
    {
        if(Mode == ClockMode.RealTime) return DateTime.Now;
            
        lock(_latch)
        {
            if(_freezedValue.HasValue == false)
            {
                SetFreezedValue(DateTime.Now);
            }
            return _freezedValue.Value;
        }
    }
    private void EnsureFixedMode()
    {
        if(Mode == ClockMode.RealTime)
            throw new InvalidOperationException("This operation is possible in Fixed mode only.");
    }
    private void SetFreezedValue(DateTime? value)
    {
        EnsureFixedMode();
        lock(_latch)
        {
            _freezedValue = value;
        }
    }
}


  На написание кода я потратил минут 20 максимум, но они окупятся неоднократно, экономя на установке ожиданий в каждом тесте. Он более дружественен по отношению к пользователю, позволяет разные варианты использования. Так я бы использовал его для тестов:
var task = new UpdatePricesTask(sessionFactoryMock.Object, Clock.FixedTime, rateProviderMock.Object);


   Я знаю, пример с часами слишком прост, но для демонстрации такой и нужен. Самую меньшую из проблем побороли, в следующий раз займёмся NHibernate в лице – ISessionFactory.



  Код из блога по-прежнему можно найти на github’e.

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

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