Моки и стабы

    Существует категория классов, которые тестировать весьма просто. Если класс зависит только от примитивных типов данных и не имеет никаких связей с другими бизнес-сущностями, то достаточно создать экземпляр этого класса, «пнуть» его некоторым образом путем изменения свойства или вызова метода и проверить ожидаемое состояние.

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

    Но для начала давайте рассмотрим более типичный случай, когда логика по работе с базой данных или внешним сервисом, а также логика обработки этих данных сосредоточена в одном месте.


    // Модель представления, предназначенная для управления входом 
    // пользователя в систему
    public class LoginViewModel
    {
        public LoginViewModel()
        {
            // Читаем имя последнего пользователя
            UserName = ReadLastUserName();
        }
    
        // Имя пользователя; может быть изменено пользователем
        public string UserName { get; set; }
    
        // Логиним пользователя UserName 
        public void Login()
        {
            // Не обращаем внимание на дополнительную логику, которая должна быть 
            // выполнена. Считаем что нам достаточно просто сохранить имя текущего
            // пользователя
            SaveLastUserName(UserName);
        }
    
        // Читаем имя последнего залогиненного пользователя
        private string ReadLastUserName()
        {
            // Не важно, как она на самом деле реализована ...
            // Просто возвращаем что-нибудь, чтобы компилятор не возражал
            return "Jonh Doe";
        }
    
        // Сохраняем имя последнего пользователя
        private void SaveLastUserName(string lastUserName)
        {
            // Опять таки, нам не интересно, как она реализована
        }
    }
    


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

    ПРИМЕЧАНИЕ
    Не нужно бросать в меня камнями с криками «Да кто сегодня вообще такую хрень написать можно? Ведь уже столько всего написано о вреде такого подхода, да и вообще, у нас есть юнити-шмунити и другие полезности, так что это нереальный баян двадцатилетней давности!». Кстати, да, это баян, но, во-первых, речь не юнитях и других контейнерах, а о базовых принципах, а во-вторых, подобное «интеграционное» тестирование все еще невероятно популярно, во всяком случае, среди многих моих «зарубежных» коллег.


    Создания «швов» для тестирования приложения


    Даже если не задумываться о том, какое количество новомодных принципов проектирования нарушает наша вью-модель, четко видно, что ее дизайн несколько … убог. Ведь даже если проектировать старым дедовским бучевским методом, то становится понятно, что всю работу по сохранению имени последнего пользователя, логику по работе с базой данных (или другим внешним источником данных) нужно спрятать подальше с глаз долой и сделать это «проблемой» кого-то другого и использовать уже этого «кого-то» в качестве «строительного блока» для получения более высокоуровневого поведения:

    internal class LastUsernameProvider
    { 
        // Читаем имя последнего пользователя из некоторого источника данных
        public string ReadLastUserName() { return "Jonh Doe"; } 
        // Сохраняем это имя, откуда его можно будет впоследствии прочитать
        public void SaveLastUserName(string userName) { }
    }
     
    public class LoginViewModel
    { 
        // Добавляем поле для получения и сохранения имени последнего пользователя
        private readonly LastUsernameProvider _provider = 
                             new LastUsernameProvider(); 
      
        public LoginViewModel() 
        { 
            // Теперь просто вызываем функцию нового вспомогательного класса
            UserName = _provider.ReadLastUserName(); 
        } 
        
        public string UserName { get; set; } 
        
        public void Login() 
        { 
            // Все действия по сохранению имени последнего пользователя также
            // делегируем новому классу
            _provider.SaveLastUserName(UserName); 
        }
    }
    


    Пока что написание модульного теста все еще остается затруднительным, но становится понятным, как можно достаточно просто «подделать» реальную реализацию класса LastUsernameProvider и сымитировать нужное для нас поведение. Достаточно выделить методы этого класса в отдельный интерфейс или просто сделать их виртуальными и переопределить в наследнике. После чего останется лишь «прикрутить» нужный нам объект в нашу вью-модель.

    ПРИМЕЧАНИЕ

    Честно говоря, я не большой фанат изменений в дизайне только ради «тестируемости» кода. Как показывает практика, нормальный ОО дизайн либо уже является достаточно «тестируемым» или же требует лишь минимальных телодвижений, чтобы сделать его таковым. Некоторые дополнительные мысли по этому поводу можно найти в заметке
    «Идеальная архитектура».

    Даже не прибегая ни к каким сторонним библиотекам для «инджекта» зависимостей мы можем сделать это самостоятельно несколько простыми способами. Нужную зависимость можно передать через дополнительный конструктор, через свойство или создать фабричный метод, который будет возвращать интерфейс ILastUsernmameProvider.

    Давайте рассмотрим вариант с конструктором, который является довольно простым и популярным (при небольшом количестве внешних зависимостей он работает просто прекрасно).

    // Выделяем методы в интерфейс
    internal interface ILastUsernameProvider
    { 
        string ReadLastUserName(); 
        void SaveLastUserName(string userName);
    }
     
    internal class LastUsernameProvider : ILastUsernameProvider
    { 
        // Читаем имя последнего пользователя из некоторого источника данных
        public string ReadLastUserName() { return "Jonh Doe"; } 
        // Сохраняем это имя, откуда его можно будет впоследствии прочитать
        public void SaveLastUserName(string userName) { }
    }
     
    public class LoginViewModel
    { 
        private readonly ILastUsernameProvider _provider; 
        
        // Единственный открытый конструктор создает реальный провайдер
        public LoginViewModel() 
            : this(new LastUsernameProvider()) 
        {} 
        
        // "Внутренний" предназначен только для тестирования и может принимать "фейк"
        internal LoginViewModel(ILastUsernameProvider provider) 
        { 
            _provider = provider; 
            UserName = _provider.ReadLastUserName(); 
        } 
      
        public string UserName { get; set; } 
        
        public void Login() 
        { 
            _provider.SaveLastUserName(UserName); 
        }
    }
    


    Поскольку дополнительный конструктор является внутренним (internal), то он доступен только внутри этой сборке, а также «дружеской» сборке юнит-тестов. Конечно, если тестируемые классы являются внутренними не будет не какой, но поскольку все «клиенты» внутреннего класса находятся в одной сборке, то и контролировать их проще. Подобный подход, основанный на добавлении внутреннего метода для установки «фальшивого» поведения является разумным компромиссом упрощения тестирования кода, не налагая ограничения на использования более сложных механизмов управления зависимостями, типа IoC контейнеров.

    ПРИМЕЧАНИЕ
    Одним из недостатков при работе с интерфейсами является падение читабельности, поскольку не понятно, сколько реализаций интерфейса существует и где находится реализация того или иного метода интерфейса. Такие инструменты, как Решарпер существенно смягчают эту проблему, поскольку поддерживают не только навигацию к объявлению метода (Go To Declaration), но также и навигацию к реализации метода (Go To Implementation):




    Проверка состояния vs проверка поведения


    Теперь давайте попробуем написать юнит-тест вначале для конструктора класса LoginViewModel, который получает имя последнего залогиненного пользователя, а потом юнит-тест для метода Login, после выполнения которого, имя последнего пользователя должно быть сохранено.

    Для нормальной реализации этих тестов нам нужна «фейковая» реализация интерфейса, при этом в первом случае, нам нужно вернуть произвольное имя последнего пользователя в методе ReadLastUserName, а во втором случае – удостовериться, что вызван метод SaveLastUserName.

    Именно в этом и отличаются два типа «фейковых» классов: стабы предназначены для получения нужного состояния тестируемого объекта, а моки применяются для проверки ожидаемого поведения тестируемого объекта.

    Стабы никогда не применяются в утверждениях, они простые «слуги», которые лишь моделируют внешнее окружение тестового класса; при этом в утверждениях проверяется состояние именно тестового класса, которое зависит от установленного состояния стаба.

    // Стаб возвращающее указанное имя последнего пользователя
    internal class LastUsernameProviderStub : ILastUsernameProvider
    { 
        // Добавляем публичное поле, для простоты тестирования и 
        // возможности повторного использования этого класса
        public string UserName; 
        
        // Реализация метода очень простая - просто возвращаем UserName
        public string ReadLastUserName() 
        { 
            return UserName; 
        } 
        
        // Этот метод в данном случае вообще не интересен
        public void SaveLastUserName(string userName) { }
    }
     
    [TestFixture]
    public class LoginViewModelTests
    { 
        // Тестовый метод для проверки правильной реализации конструктора вью-модели
        [Test] 
        public void TestViewModelConstructor() 
        { 
            var stub = new LastUsernameProviderStub(); 
            
            // "моделируем" внешнее окружение
            stub.UserName = "Jon Skeet"; // Ух-ты!!
            var vm = new LoginViewModel(stub); 
            
            // Проверяем состояние тестируемого класса
            Assert.That(vm.UserName, Is.EqualTo(stub.UserName));
         }    
    }
    


    У моков же другая роль. Моки «подсовываются» тестируемому объекту, но не для того, чтобы создать требуемое окружение (хотя они могут выполнять и эту роль), а прежде всего для того, чтобы потом можно было проверить, что тестируемый объект выполнил требуемые действия. (Именно поэтому такой вид тестирования называется behaviortesting, в отличие от стабов, которые применяются для state-basedtesting).

    // Мок позволяет проверить, что метод SaveLastUserName был вызван 
    // с определенными параметрами
    internal class LastUsernameProviderMock : ILastUsernameProvider
    { 
        // Теперь в этом поле будет сохранятся имя последнего сохраненного пользователя
        public string SavedUserName; 
        
        // Нам все еще нужно вернуть правильное значение из этого метода,
        // так что наш "мок" также является и "стабом"
        public string ReadLastUserName() { return "Jonh Skeet";} 
        
        // А вот в этом методе мы сохраним параметр в SavedUserName для 
        public void SaveLastUserName(string userName) 
        { 
            SavedUserName = userName; 
        }
    }
     
    // Проверяем, что при вызове метода Login будет сохранено имя последнего пользователя
    [Test]
    public void TestLogin()
    { 
        var mock = new LastUsernameProviderMock(); 
        var vm = new LoginViewModel(mock); 
        
        // Изменяем состояние вью-модели путем изменения ее свойства
        vm.UserName = "Bob Martin"; 
        // А затем вызываем метод Login
        vm.Login(); 
        // Теперь мы проверяем, что был вызван метод SaveLastUserName
        Assert.That(mock.SavedUserName, Is.EqualTo(vm.UserName));
    }
    


    А зачем мне знать об этих отличиях?


    Действительно, разница в понятиях может показаться незначительной, особенно если вы реализуете подобные «фейки» руками. В этом случае знание этих паттернов лишь позволит говорить с другими разработчиками на одном языке и упростит наименование фейковых классов.

    Однако рано или поздно вам может надоесть это чудесное занятие по ручной реализации интерфейсов и вы обратите свое внимание на один из Isolation фреймворков, таких как Rhino Mocks, Moq или Microsoft Moles. Там эти термины встретятся обязательно и понимание отличий между этими типами фейков вам очень пригодится.

    Я осознанно не касался ни одного из этих фреймворков, поскольку каждый из них заслуживает отдельной статьи и ИМО лишь усложнит понимание этих понятий. Но если вам все же интересно посмотреть на некоторые из этих фреймворков более подробно, то об одном из них я писал более подробно: “Microsoft Moles”.
    Поделиться публикацией
    Похожие публикации
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 14
      +6
      Хорошая статья, но для полного понимания читателям, можно было бы сослаться на книгу, где про stub, mock и юнит-тестирование просто и подробно написано The Art of Unit Testing: With Examples in .Net
        +3
        «Однако рано или поздно вам может надоесть это чудесное занятие по ручной реализации интерфейсов и вы обратите свое внимание на один из Isolation фреймворков, таких как Rhino Mocks, Moq или Microsoft Moles. Там эти термины встретятся обязательно и понимание отличий между этими типами фейков вам очень пригодится.»
        Вот как раз в Moq специально сделано так, чтобы не было отличий между стабами и моками.

        «Как показывает практика, нормальный ОО дизайн либо уже является достаточно «тестируемым» или же требует лишь минимальных телодвижений, чтобы сделать его таковым.»
        Отнюдь. Вот на выходных рефакторил очередной ощутимый кусок кода под unit-testing. С ОО там все было хорошо, а вот для полной изоляции там пришлось добавить еще нехилый объем прослоек.

        Обычно (не всегда, но часто) нормальное изолированное тестирование требует, чтобы каждый изолируемый кусок был выделен как интерфейс, что и создает совершенно ненужный код.
          0
          А наследование для тестирования не пробовали применять? Наследуемся от тестируемого класса, переопределяя конструкторы и сеттеры, так чтобы они использовали моки для разрешения зависимостей. Исходный код при этом практически не изменяется (разве что поменять private на protected).
            0
            «А наследование для тестирования не пробовали применять?»
            Это будет работать только в том случае, когда все нужные методы можно переопределить. А это далеко не всегда так.

            «Исходный код при этом практически не изменяется (разве что поменять private на protected). „
            А это нарушает принцип максимального сокрытия информации.
          +1
          А насколько имеет смысл тестировать конструкторы, а также сеттеры? То есть имеет ли смысл тестировать состояние? Ведь приходится нарушать инкапсуляцию, либо небезопасно (изменяя видимость), либо вводя геттеры (увеличения объема кода тестируемого класса лишь для тестирования).

          Когда только осваивал юнит-тестирование, то тестировал всё, потом понял, что в подавляющем большинстве случаев (подстраховка :) ) достаточно тестировать только поведение: создали объект, настроили, вызвали тестируемый метод, проверили, что он возвращает и, при необходимости, что вызывает то, что нужно. Что происходит внутри нас как бы и не волнует — тестовый клиент такой же клиент как и другие.

          И, кстати, такая Assert.That(mock.SavedUserName, Is.EqualTo(mock.SavedUserName)); проверка разве является достаточной для того, чтобы быть уверенным, что вызывался метод SaveLastUserName, а не значение mock.SavedUserName было присвоено напрямую или другим методом?
            0
            Очепятку в коде я поправил, смысла тестировать, что mock.SavedUserName равен mock.SavedUserName мало смысла. А вот сравнить это поле со значением UserName вью-модели смысл имеет.

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

            Да, и +100 500 по поводу, что все тестировать не нужно. Разумно тестировать только наиболее важные части кода. При этом, кстати, не обязательно, что наиболее важным является поведение; иногда это так, иногда, важным является состояние. Но идея, правильное.

            В одном из подкастов Кент Бек выразил очень прикольную мысль о том, что каждый тест должен рассказывать полезную и интересную историю о тестируемом классе, чтобы читатель этого теста мог не только понять, что «ага, эта хрень, кажись, работает», а и то, что именно она делает полезного в данном конкретном случае. Т.е. тест — это еще и отличный источник спецификации.
              +1
              А вашу опечатку я прочел как будто её нет :) Но дело не в логике теста, а в том, что, например, в LoginViewModel вместо вызова _provider.SaveLastUserName(UserName); происходит присваивание _provider.SavedUserName = UserName; — Тест работать будет, а настоящий провайдер, вероятно, нет.
                +1
                Сам понял, что ступил — тут же сильная типизация и язык компилируемый — компилятор ошибку выдаст. Слаботипизированные интерпретируемые языки заставляют строже к тестам относиться.
                  0
                  Да, там все ок, поскольку в интерфейсе с которым работает вью-модель нет свойства SavedUserName.
                0
                покрывать тестами нужно весь код на 100%. Не на 90, 95 или 99, а на 100%. Не вводите новичков в заблуждение, если вы сами так не делаете. А то это звучит примерно как «вы знаете, наша машина отлично работает. Мы протестировали её и можем гарантировать, что руль поворачивается, а колёса крутятся. Ведь это самые важные моменты, не так ли?!»
                  0
                  Я правильно понимаю, что вы покрываете тестами и подобный код:

                  class MyClass
                  {
                  private object someData;

                  pulbic MyClass(object someData)
                  {
                  if (someData == null)
                  throw new ArgumentNullException("someData");

                  this.someData = someData;
                  }

                  public override string ToString()
                  {
                  return someData.ToString();
                  }
                  }


                  И теперь в тесте:
                  [Test(ExpectedException(typeof(ArgumentNullException))]
                  public void MyClass_Constructor_Failure()
                  {
                  var myClass = new MyClass(null);
                  }

                  [Test]
                  public void MyClass_Custructor_Success()
                  {
                  var myClass = new MyClass(new object());
                  }

                  [Test]
                  public void MyClass_ToString_CallsObjectToString()
                  {
                  object o = new object();
                  var myClass = new MyClass(o);
                  Assert.AreEquals(myClass.ToString(), o.ToString());
                  }


                  Ведь если этого не сделать, то 100% покрытия тестами у вас не будет:) Я, например, первый вариант предпочитаю «проверять» с помощью контрактов и статического анализатора, в результате, если что-то пойдет не так, то у меня код просто не скомпилится (ну, скомпилится, но я ошибку я увижу во время компиляции). Кроме того, натравите на свой собственный код какой-нить анализатор, у вас ведь тоже не 100%;)

                  Я продолжу свою мысль (не столь категоричную, как вашу): тестов должно быть _достаточно_.

                  Знаете, что самое интересное: Кент Бек — небезызвестный папа TDD, не является фанатом 100% покрытия тестами (пруф в подкасте от SERadio). Не все тесты приносят что-то полезное, они могут и захламлять код тестов, пряча при этом за грудой ненужных тестов парочку важных, которые найти в подобном хаосе будет не просто.

                  Так что да, я не хочу идеального качества, потому что идеальное качество требует бесконечного количества усилий для его достижения;) Прагматизм рулит и юнит-тесты в этом вопросе не исключение.
                    0
                    Я бегло глянул ваш код. Скорее всего да, нужно писать именно в таком ключе, проверяя каждый экспешен. И меня ни раз спасало именно это от длительных дебагов и овертаймов. Ведь написать тест нужно ну от силы секунд 40. (Если всё правильно писалось до этого, с предварительной инициализацией, паттернами и т.д. А если нет, то TDD уж очень сложно идёт)
                    Вы подняли вопрос красоты кода (разделение разных тестов «а-ля сам дурак» и тестов поведения по бизнес кейсам ) в рамках одного тестового класса. Тест нужно искать. Написал и забыл, пока он не упал. Если уж очень надо, то нужно правильно именовать его: имяМетода_входныеПараметры_ожидаемыйРезультат.

                    анализатор — хорошая вещь, но это дополнение.
              0
              Давайте рассмотрим вариант с конструктором, который является довольно простым и популярным (при небольшом количестве внешних зависимостей он работает просто прекрасно).
              А что делать, если зависимостей много? Разбивать класс на несколько мелких, использующих каждый мало внешних зависимостей?
                +1
                Часть из этих зависимостей будет примитивными, типа примитивных типов данных или простых классов данных. С ними, ессно, делать ничего не нужно. А если класс зависит от 8 других тяжелых сущностей, которые нужно мокать, то это признак того, что с дизайном чего-то не то.
                Да, нужно разбить класс на более мелкие, объединить все зависимости в одну сущность (если это имеет смысл) и поднять зависимостям уровень абстракции.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое