Pull to refresh

Microsoft Moles Isolation Framework, копаем глубже

Reading time15 min
Views3.4K
Как вы поняли из названия, речь пойдет о продукте от Microsoft Research – Microsoft Moles Isolation Framework. Я познакомился с ним впервые после прочтения поста хабраюзера alek_sys. Моль мне настолько понравилась, что я решил поделиться своим опытом её использования.

Зачем?


Для начала попробуем определиться, для каких целей предназначена Microsoft.Moles и чего мы можем с ней добиться:
  • Полная изоляция тестируемой логики от внешнего окружения.
  • Возможность быстрого и просто создания юнит-тестов, при чём тестирование логики класса становится возможным даже при отсутсвии реализации классов, пользователем которых является тестируемый класс.
  • Становится просто организовать наборы тестовых данных или моделировать состояние связанных обьектов для создания тестовых условий
  • В разы сокращается время выполнения юнит-тестов, становится реальным частый запуск тестов
  • Нарушение логики юнита не влечет за собой падение сотни-другой не предназначенных для его тестирования тестов
  • Удобное тестирование методов со сложным workflow


О юнит тестировании вообще


Если у вас вышеописанные цели вызвают сомнения, стоит немного освежить в памяти основные моменты юнит тестирования. У кого сомнений не возникло — можете пропустить перейти к следующему разделу.

Для чего нужно юнит тестирование
  • Изолировать отдельные юниты программы и показать, что по отдельности юнит (или совокупность нескольких юнитов) работоспособны.
  • Документирование логики юнитов – для того чтоб понять как функционирует юнит — достаточно прочесть код его тестов.
  • Упрощение рефакторинга, — можно легко проверить что код работает без ошибок после внесения изменений.
  • Сокращение времени багфикса за счет исправления большинства ошибок в процессе девелопмента. Минимизация возможности регрессии.
Требования к юнит тестам
  • Тест должен тестировать минимальный обьем кода класса, при чем количество тестов для данного кода должно быть пропрорционально количеству путей выполнения кода.
  • Реализация теста должна быть максимально простой и не требовать инициализации тестового окружения вне теста(например подготовки тестовых данных в БД).
  • Тест должен быть самодокументируемым – посторонний разработчик должен понять логику юнита, прочитав реализованные для этого юнита тесты. Без необходимости изучения содержимого БД или других источников тестовых данных
  • Тест должен быть максимально прост – гораздо легче разобраться в десятке простых тестов, реализованных для каждого отдельного тестового случая, чем в одном тесте, который тестирует все эти тестовые случаи.
  • Падение теста не должно быть связано с логикой, для тестирования которой он не предназначен
  • Прохождение теста не должно зависеть от внешних систем(сервера баз данных, веб-сервера, файловая система, контроллеры внешних устройств), если он не предназначен для тестирования взаимодействия с этими системами.
  • Необходимо максимальное покрытие кода тестами с минимальным временем их выполнения

Какие проблемы возникают при использовании юнит тестов
  • Сложности подготовки тестовой среды – создание больших тестовых наборов данных, создание различных подключений к серверам, описание конфигурационных файлов, необходимость создания эмуляторов внешних устройств и тд.
  • Сложность тестирования конкретного класса, поскольку зачастую класс является пользователем других классов, и наличие ошибок в их работе влечет за собой падение теста, что не соответствует парадигме юнит тестирования – тест должен падать в случае если нарушена тестируемая логика.
  • Сложность обеспечения 100% покрытия кода тестами(как правило, это связано с ленью разработчика и первыми двумя проблемами)
  • Сложность сопровождения(отладки, фикса) тестов которая исходит из первых двух причин.
  • Невозможность доступа(кроме рефлексии) к скрытым членам классов, невозможность инициализации readonly членов тестовыми значениями.
  • Зачастую падение теста связано с тем, что была нарушена логика другого юнита (а не тестируемого). Приходится тратить время на поиски ошибок не в тех местах.
  • Время выполнения тестов – основным требованием к юнит тестам является их частое выполнение – (сделал изменение в юните, прогнал тесты), но зачастую это делать невозможно из-за слишком длительного выполнения тестов, для больших проектов это время может измеряться часами. При чем большую часть времени занимают процессы не связанные непосредственно с тестированием – чтение/запись бд, файловой системы, веб-серверов и тд, инициализация связанной логики, которая не имеет непосредственнго отношения к тесту.

Инструментарий для юнит-тестирования.

На данный момент существует большое количество фреймворков(NUnit, JUnit, DUnit, xUnit, MSTest) для множества языков программирования, предназначенных для создания и использования автоматических тестов разных назначений(Unit Tests, Integration Tests, Functional Tests, UI Tests). Как правило, они не позволяют создавать полноценные юнит-тесты в чистом виде, поскольку не могут обеспечить полной изоляции тестируемого кода от окружающей логики. Разработчикам приходится прибегать к различным ухищрениям, создавать тучу дополнительных mock классов, использовать рефлексию, и тд. И тут на помощь приходят Isolation Frameworks, которые позволяют достаточно удобно и быстро, организовать изолированное окружение для выполнения юнит-тестов, и Microsoft Moles является одной из них.

Что умеет Microsoft.Moles?


Попробую продемонстрировать возможности, которые нам предоставляет Moles на примере(я пытался сделать его максимально простым но при этом достаточно сложным, чтоб использование Moles в нем было оправдано).

Предположим что у нас есть класс FileUpdater, с единственным методом UpdateFileFromService(string fileId), задачей которого является обновить файл в локальном хранилище файлом, скачанным с удаленного хранилища, при несовпадении локального и удаленного хешей файла.
Как видно из приведенного листинга, метод UpdateFileFromService использует классы FileManager и StorageService для доступа к локальному и удаленному хранилищам. Я намеренно не привожу реализацию методов классов FileManager и StorageService, так нам достаточно знать только их интерфейс.

public class FileUpdater
{
    private StorageService storageService;

    public StorageService Service
    {
        get
        {
            if (storageService == null)
            {
                storageService = new StorageService();
            }
            return storageService;
        }
    }

    public void UpdateFileFromService(string fileId)
    {
        if (string.IsNullOrEmpty(fileId))
        {
            throw new Exception("fileId is empty");
        }
        var fileManager = new FileManager();
        string localFileHash = fileManager.GetFileHash(fileId);
        string remoteFileHash = Service.GetFileHash(fileId);
        if (localFileHash != remoteFileHash)
        {
            FileStream file = Service.DownloadFile(fileId);
            fileManager.SaveFile(fileId, remoteFileHash, file);
        }
    }
}

public class FileManager
{
    public string GetFileHash(string fileId)
    {
        throw new NotImplementedException();
    }

    public void SaveFile(string fileId, string remoteFileHash, FileStream file)
    {
        throw new NotImplementedException();
    }
}

public class StorageService
{
    public string GetFileHash(string fileId)
    {
        throw new NotImplementedException();
    }

    public FileStream DownloadFile(string fileId)
    {
        throw new NotImplementedException();
    }
}


* This source code was highlighted with Source Code Highlighter.


Очевидны следующие варианты выполнения метода UpdateFileFromService:
  1. fileId — пустая строка
  2. хеши локального и удаленного файла не идентичны
  3. хеши локального и удаленного файла идентичны
Остальные ситуации мы рассматривать не будем, так как данная реализация класса FileUpdater не содержит их обработку.
Попробуем создать тесты для вариантов 2 и 3 с использованием Microsoft.Moles, так как реализация теста для первого случая элементарна с использованием стандартных средств любого тестового фреймворка.

Генерация Mole и Stub классов

Первым делом необходимо сгенерировать Moled assembly для библиотек в которых находятся классы, логику которых мы будем подменять кастомными делегатами(иначе Stub-методами). Это можно сделать с помощью командной строки используя mole.exe, который идет в установочном пакете с Microsoft.Moles, а также через интерфейс Visual Studio 2008/2010. Воспользуемся вторым способом.
Как видно, после установки Microsoft.Moles, для референсных сборок появился новый пункт контекстного меню «Add Moles Assembly»(Рис.1).



После выполнения данной команды на сборке ClassLibrary1, получаем новую группу файлов ClassLibrary1.moles(Рис.2), из которой именно файл ClassLibrary1.moles, является описателем Moled assembly, который содержит параметры её генерации и который можно изменить при необходимости. Остальные файлы автоматически перегенерируются при каждом построении, и нет смысла их редактировать.



Microsoft.Moles позволяет использовать два вида классов заместителей — Stubs & Moles.
  • Stub предоставляет легковесный isolation framework, который герерирует fake реализации абстрактных, виртуальных методов и членов интерфейсов. Подходит для подмены виртуальных методов или членов, которые являются реализациями интерфейсов
  • Mole использует мощный фреймворк перенаправления вызовов, использующий code profiler APIs для перехвата вызовов к используемым классам, который редиректит вызовы к fake обьекту. Позволяет перенаправить вызов к любому члену класса
Лично для меня оба варианта оказались одинаково удобными, и никаких сложностей в использовании не вызвали.
В сгенерированной сборке ClassLibrary1.Moles.dll по умолчанию содержатся пары классов M%ClassName% и S%ClassName% для каждого из классов, которые содержатся в сборке ClassLibrary1.dll.
где:
  • M%ClassName% — Mole класс
  • S%ClassName% — Stub класс

При желании, внеся изменения в файл ClassLibrary1.moles, можно добиться, чтоб генерировались Moles или Stubs только для определенных классов(что очень сократит время генерации), либо вообще отключить генерацию либо Moles либо Stubs, если вы его не используете, а также задать другие параметры генерирования(Intellisence подскажет список допустимых параметров и их назначение). Файлом ClassLibrary1.moles можно также воспользоваться и при генерации с использованием командной строки moles.exe(со списком параметров moles.exe можно ознакомиться запустив moles.exe без параметров).

Использование Mole классов

Так как использование Moles и Stubs в некотором роде схоже, а статья не претендует на полное описание всего функционала Microsoft.Moles — остановимся только на примерах использования Mole.
Чтоб примеры были более понятны, сразу отмечу особенности именования членов сгенерированных Mole и Stub классов, — сначала идет имя члена оригинального класса, а потом к нему прибавляются имена типов передаваемых параметров. Например MFileManager.AllInstances.GetFileHashString это свойство для замещения метода FileManager.GetFileHash(string fileId). Думаю такой стиль связан с необходимостью обеспечить уникальность членов, сгенерированных для перегруженных методов.
С Mole мы получаем возможность замещения любых методов и свойств классов различными способами:
  • Замещение всех инстансов класса
    MFileManager.AllInstances.GetFileHashString = (fileManager, fileId) =>
    {
        //данное лямбда выражение будет выполняться вместо метода GetFileHash(string fileId) всех инстансов класса FileManager
        return Guid.NewGuid().ToString();
    };


    * This source code was highlighted with Source Code Highlighter.


    Замещение static классов и static свойств выполняется аналогично, только без указания AllInstances.
  • Замещение конкретного инстанса класса
    var storageServiceMole = new MStorageService();
    storageServiceMole.GetFileHashString = (fileId) =>
    {
        //данное лямбда выражение будет выполняться вместо метода GetFileHash(string fileId) обьекта storageService класса StorageService
        return testRemoteHash;
    };
    var storageService = storageServiceMole.Instanc


    * This source code was highlighted with Source Code Highlighter.

    или так, с передачей в конструкторе инициализированного объекта класса
    var storageService = new StorageService();
    var storageServiceMole = new MStorageService(storageService);
    storageServiceMole.GetFileHashString = (fileId) =>
    {
        //данное лямбда выражение будет выполняться вместо метода GetFileHash(string fileId) обьекта storageService класса StorageService
        return testRemoteHash;
    };


    * This source code was highlighted with Source Code Highlighter.
  • Замещение конструктора класса с целью перекрытия необходимых членов класса Mole обьектами для всех инстансов класса.
    MFileUpdater.Constructor = (@this) =>
    {
        var mole = new MFileUpdater(@this);
        mole.ServiceGet = (x) => initializedServiceInstance;
    };


    * This source code was highlighted with Source Code Highlighter.
  • Замещение с дальнейшим обращением к оригинальной реализации класса. Если вам необходимо перед вызовом оригинального метода класса произвести некоторые проверки входящих значений, воспользуйтесь следующим способом
    var fileUpdaterMole = new MFileUpdater() { InstanceBehavior = MoleBehaviors.Fallthrough };
    fileUpdaterMole.UpdateFileFromServiceString = (y) =>
    {
        fileUpdaterMole.UpdateFileFromServiceString = null;//отменяем перенаправление вызова на кастомный делегат
        fileUpdaterMole.Instance.UpdateFileFromService(y);//теперь вызов будет обработан оригинальным методом FileUpdater.UpdateFileFromService
    };
    fileUpdaterMole.Instance.UpdateFileFromService(Guid.NewGuid().ToString());


    * This source code was highlighted with Source Code Highlighter.

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

InstanceBehavior

Стоит отметить также возможность указания поведения членов класса которым мы явно не задаем замещающий делегат. Каждый сгенерированный Mole класс имеет свойство InstanceBehavior, которое может принимать следующие значения
  • MoleBehaviors.DefaultValue — незамещенные члены класса будут замещены пустым делегатом и возвращать дефолтное значение типа возвращаемого результата
    var storageService = new StorageService();
    var storageServiceMole = new MStorageService(storageService) { InstanceBehavior = MoleBehaviors.DefaultValue };
    //результатом следующего вызова будет null;
    var result = storageService.GetFileHash(Guid.NewGuid().ToString());


    * This source code was highlighted with Source Code Highlighter.
  • MoleBehaviors.NotImplemented — при обращении к незамещенному члену будет возникать исключение NotImplementedException
    var storageService = new StorageService();
    var storageServiceMole = new MStorageService(storageService) { InstanceBehavior = MoleBehaviors.NotImplemented };
    //следующий вызов породит исключение NotImplementedException
    storageService.GetFileHash("328576BA-7345-4847-84AC-170EF03FFA7A");


    * This source code was highlighted with Source Code Highlighter.
  • MoleBehaviors.Fallthrough — вызовы к незамещенным членам будут обработаны согласно оригинальной реализации их в замещаемом классе
    var storageService = new StorageService();
    var storageServiceMole = new MStorageService() {InstanceBehavior = MoleBehaviors.Fallthrough};
    //следующий вызов будет обработан согласно оригинальной реализации метода StorageService.GetFileHash(string fileId)
    storageService.GetFileHash("328576BA-7345-4847-84AC-170EF03FFA7A");


    * This source code was highlighted with Source Code Highlighter.
  • MoleBehaviors.Current — дефолтное поведение незамещенных методов после перекрытия инстанса класса Mole классом. При вызове незамещенного члена, будет порождено исключение MoleNotImplementedException
    var storageService = new StorageService();
    var storageServiceMole = new MStorageService() {InstanceBehavior = MoleBehaviors.Current};
    //следующий вызов породит исключение MoleNotImplementedException
    storageService.GetFileHash("328576BA-7345-4847-84AC-170EF03FFA7A");


    * This source code was highlighted with Source Code Highlighter.
  • MoleBehaviors.CurrentProxy оставляет текущее поведение для всех неперекрытых членов перекрываемого инстанса класса
    var storageService = new StorageService();
    var storageServiceMole1 = new MStorageService(storageService) { InstanceBehavior = MoleBehaviors.NotImplemented };
    storageServiceMole1.GetFileHashString = (fileId) =>
    {
        return Guid.NewGuid().ToString();
    };
    var storageServiceMole2 = new MStorageService(storageService) { InstanceBehavior = MoleBehaviors.CurrentProxy };
    storageServiceMole2.DownloadFileString = (fileId) =>
    {
        return testFileStream;
    };
    //следующий вызов будет перенаправлен обработчику storageServiceMole1.GetFileHashString
    storageService.GetFileHash("328576BA-7345-4847-84AC-170EF03FFA7A");
    //следующий вызов будет перенаправлен обработчику storageServiceMole2.GetFileHashString
    storageService.DownloadFile("328576BA-7345-4847-84AC-170EF03FFA7A");


    * This source code was highlighted with Source Code Highlighter.
  • Кастомная реализация поведения — возможна если вы создадите собственную реализацию интерфейса IMoleBehavior

Пример реализации теста

Вот собственно тест ситуации когда хеши локального и удаленного файла различны.
///
/// Тест ситуации, когда хеши локального и удаленного файлов различны
///
[TestMethod]
[HostType("Moles")]
public void TestUpdateFileNonMatchedHash()
{
    var callOrder = 0; // переменная для проверки очередности вызовов внутри тестируемого метода
    var testFileId = Guid.NewGuid().ToString();
    var testLocalHash = Guid.NewGuid().ToString();
    var testRemoteHash = Guid.NewGuid().ToString();
    var testFileStream = new FileStream(@"c:\testfile.txt", FileMode.OpenOrCreate);

    var storageServiceMole = new MStorageService() { InstanceBehavior = MoleBehaviors.Fallthrough };
    //Замещаем метод GetFileHash класса StorageService
    storageServiceMole.GetFileHashString = (fileId) =>
    {
        Assert.AreEqual(1, callOrder++);//метод должен быть вызван вторым по очереди
        Assert.AreEqual(testFileId, fileId);
        Assert.AreNotEqual(testLocalHash, testRemoteHash);
        return testRemoteHash;
    };
    storageServiceMole.DownloadFileString = (fileId) =>
    {
        Assert.AreEqual(2, callOrder++);
        Assert.AreEqual(testFileId, fileId);
        return testFileStream;
    };
    //замещаем методы класса FileManager.
    //MFileManager.AllInstances используется потому что инстанс класса FileManager создается в контексте метода UpdateFile
    //и у нас нет возможности получить доступ к созданному обьекту, поэтому перекрываем необходимые методы
    //для всех инстансов класса FileManager
    MFileManager.AllInstances.GetFileHashString = (fileManager, fileId) =>
    {
        Assert.AreEqual(0, callOrder++);
        Assert.AreEqual(testFileId, fileId);
        return Guid.NewGuid().ToString();
    };
    MFileManager.AllInstances.SaveFileStringStringFileStream = (fileManager, fileId, fileHash, fileStream) =>
    {
        Assert.AreEqual(3, callOrder++);
        Assert.AreEqual(testFileId, fileId);
        Assert.AreEqual(testRemoteHash, fileHash);
        Assert.AreSame(testFileStream, fileStream);
    };
    var fileUpdaterMole = new MFileUpdater
    {
        InstanceBehavior = MoleBehaviors.Fallthrough,
        //Замещаем getter свойства FileUpdater.Service своим делегатом, который будет возвращать инициализированный ранее moled инстанс класса StorageService
        ServiceGet = () => storageServiceMole.Instance
    };
    var fileUpdater = fileUpdaterMole.Instance;

    fileUpdater.UpdateFileFromService(testFileId);
}


* This source code was highlighted with Source Code Highlighter.


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

Дополнительные сведения

  • Mole типы, не могут быть использованы в многопоточных тестах.
  • Moles не может быть использовано для замещения некоторых типов из mscorlib.
  • Тесты с использованием Microsoft.Moles должны быть инструментированы Moles — для MSTest это атрибут тестового метода HostType(«Moles»). Для других тестовых фреймворков необходимо выполнять запуск тестов с использованием moles.runner.exe(для nunit это будет выглядеть так moles.runner.exe «yourTestDLLName» /runner:nunit-console.exe) Соответственно любителям работать с тестовым фреймворком через GUI тоже прийдется запускать оболочку через mole.exe
  • Пример конфигурации Nant таски для вызова тестов, которые инструментируются moles
    <target name="runTests">
      <exec basedir="${build.dir.unittests}" program="${microsoft.moles.dir}moles.runner.exe">
        <arg value="/Runner:${nunit.console.dir}nunit-console.exe"></arg>
        <arg value="${build.dir.unittests}My.UnitTests.dll"></arg>
      </exec>
    </target>


    * This source code was highlighted with Source Code Highlighter.


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

Источники


Microsoft Moles Reference Manual
Using MS Moles with NUnit, NAnt and .NET 3.5
MSDN — The Moles Framework
Tags:
Hubs:
Total votes 44: ↑36 and ↓8+28
Comments21

Articles