вторник, 14 января 2014 г.

Microsoft Fakes. Тестирование состояния

Все приведенные примеры можно найти на github.

Microsoft Fakes – это очередной изоляционный фреймворк, который является логическим продолжением экспериментального проекта Microsoft Moles.

Все изоляционные фреймворки делятся на несколько категорий, в зависимости от времени генерации «подделок» и их типу:

По времени генерации:

  • генерация на лету во время исполнения тестов;
  • во время компиляции;

По типу генерируемых «подделок»:

  • на основе полиморфизма, что позволяет «мокать» лишь полиморфные методы;
  • на основе CLR Profiling API, что позволяет мокать невиртуальные методы, включая статические.

Так, например, Moq, Rhino Mocks, FakeItEasy и многие другие генерируют «моки» на лету и позволяют переопределять лишь виртуальные методы. Microsoft Fakes, с другой стороны, генерирует «подделки» во время компиляции, а также позволяет «мокать» невиртуальные и статические методы.

Это значит, что для того, чтобы получить фейковые классы, нужно выбрать в “Solution Explorer” нужную вам сборку и выбрать пункт меню “Add Fakes Assembly”, в результате чего для указанной сборки будет сгенерирована соответствующая сборка со стабами:

clip_image002

Аналогично Microsoft Moles, Microsoft Fakes поддерживает два вида подделок: Стабы (Stubs), которые позволяют переопределять виртуальные члены и Шимы (Shims), позволяющие «подделывать» невиртуальные и статические члены.

Стабы (Stubs)

В качестве примеров давайте рассмотрим создание стабов для интерфейса ILoggerDependency, аналогичный тому, что был рассмотрен в заметке “Moq. Примеры использования”:

public interface ILoggerDependency
{
   
string
GetCurrentDirectory();
   
string GetDirectoryByLoggerName(string
loggerName);
   
string DefaultLogger { get
; }

   
event EventHandler
RollingRequired;

   
T GetConfigValue<T>();
}

Предположим, что этот интерфейс передается классу Logger в качестве аргументов конструктора и использует его в процессе функционирования. Однако для простоты тестов мы будем использовать стаб интерфейса ILoggerDependency напрямую, а не косвенно, через класс Logger.

При вызове “Add Fakes Assemblies” будет сгенерирован следующий стаб для интерфейса ILoggerDependency:

public class StubILoggerDependency 
: StubBase<Samples.ILoggerDependency>, 
Samples.ILoggerDependency
{
    //
    // Summary:
    //     Sets the stub of ILoggerDependency.get_DefaultLogger()
    public FakesDelegates.Func<string> DefaultLoggerGet;
    //
    // Summary:
    //     Sets the stub of ILoggerDependency.GetCurrentDirectory()
    public FakesDelegates.Func<string> GetCurrentDirectory;
    //
    // Summary:
    //     Sets the stub of
// ILoggerDependency.GetDirectoryByLoggerName(String loggerName)

    public FakesDelegates.Func<string, string> 
GetDirectoryByLoggerNameString;



   
public EventHandler RollingRequiredEvent;

   
//
    // Summary:
    //     Sets stubs of GetConfigValue<T>()
    public void GetConfigValueOf1<T>(FakesDelegates.Func<T> stub);
}

Ниже будет представлен набор примеров задания поведения стабов и тесты, проверяющие ожидаемое поведение.

1. Стаб метода GetCurrentDirectory

var stub = new StubILoggerDependency();
stub
.GetCurrentDirectory = () => "D:\\Temp";

// StubILoggerDependency реализует интерфейс ILoggerDependency
ILoggerDependency loggerDependency = stub;
var currentDirectory = loggerDependency.GetCurrentDirectory();


Assert.That(currentDirectory, Is.EqualTo("D:\\Temp"
));
2. Стаб метода GetCurrentDirectoryByLoggerName, всегда возвращающий один результат
var stub = new StubILoggerDependency();
// При любом аргументе стаб вернет "C:\\Foo".
stub.GetDirectoryByLoggerNameString = (string logger) => "C:\\Foo";

ILoggerDependency loggerDependency = stub;

string directory = loggerDependency.GetDirectoryByLoggerName("Any Value");


Assert.That(directory, Is.EqualTo("C:\\Foo"
));

3. Стаб метода GetCurrentDirectoryByLoggerName, возвращающий результат в зависимости от аргумента

var stub = new StubILoggerDependency();
// Данный стаб аналогичен следующему коду:
// public string GetDirectoryByLoggername(string s) { return "C:\\" + s; }

stub.GetDirectoryByLoggerNameString = (string logger) => "C:\\" + logger;
ILoggerDependency loggerDependency = stub;
// Это параметризованный тест, loggerName является аргументом теста
string directory = loggerDependency.GetDirectoryByLoggerName(loggerName);

Assert.That(directory, Is.EqualTo("C:\\" + loggerName
));

4. Стаб свойства DefaultLogger

var stub = new StubILoggerDependency();
stub
.DefaultLoggerGet = () => "DefaultLogger";
ILoggerDependency loggerDependency = stub;


string defaultLogger = loggerDependency.DefaultLogger;

Assert.That(defaultLogger, Is.EqualTo("DefaultLogger"
));

5. Стаб события RollingRequiredEvent

var stub = new StubILoggerDependency();
ILoggerDependency loggerDependency = stub;

bool eventOccurred = false
;
loggerDependency
.RollingRequired += (sender, args) => eventOccurred = true;
// Вызываем событие вручную
stub.RollingRequiredEvent(this, EventArgs.Empty);

Assert.IsTrue(eventOccurred, "Event should be raised!"
);

6. Стаб обобщенного метода GetConfigValue<T>

var stub = new StubILoggerDependency();
stub
.GetConfigValueOf1<string>(() => "default");

ILoggerDependency loggerDependency = stub;
var str = loggerDependency.GetConfigValue<string>();



Assert.That(str, Is.EqualTo("default"
));
Шимы (Shims)

Одной из главных киллер-фич Microsoft Fakes является возможность «подделывать» поведение любых классов и методов, включая экземплярные невиртуальные методы, а также статические методы.

ПРИМЕЧАНИЕ
Нужно четко понимать, что использование подобных инструментов может привести к тому, что мы сможем покрыть тестами любой код, не важно, насколько он для этого предназначен и насколько его дизайн хорош. Мы можем замокать DateTime.Now, статические методы чтения данных из базы данных и т.п. Но нужно четко понимать, что это может привести к хрупким тестам и отбить желание исправить проблемы в дизайне, которые бы позволили покрыть код тестами без таких ухищрений.
Тем не менее, когда речь касается унаследованного кода, то такие инструменты могут помочь на первых этапах: мы можем покрыть нетестируемый код тестами с помощью Shims, а уже потом начать его рефакторинг для улучшения дизайна, используя существующий набор тестов, как страховку.

В качестве примера давайте рассмотрим конкретную зависимость класса LoggerSealedLoggerDependency, «замокать» которую за счет полиморфизма невозможно:

/// <summary>
/// Класс аналогичен интерфейсу
ILoggerDependency
за исключением того,
// что это закрытый класс
.
///
 </summary>
public sealed class SealedLoggerDependency
{
    public string GetCurrentDirectory()
    {
        throw new NotImplementedException();
    }

   
public string GetDirectoryByLoggerName(string loggerName)
    {
        throw new NotImplementedException();
    }

   
public string DefaultLogger
    {
        get { throw new NotImplementedException(); }
    }

   
public event EventHandler RollingRequired;

   
public T GetConfigValue<T>()
    {
        throw new NotImplementedException();
    }

   
public static string GetDefaultDirectory()
    {
        throw new NotImplementedException();
    }

}

Класс SealedLoggerDependency аналогичен интерфейсу ILoggerDependency за исключением того, что класс является «закрытым» (sealed) и вдобавок, содержит статический метод GetDefaultDirectory.

Для класса SealedLoggerDependency Microsoft Fakes сгенерирует ShimSealedLoggerDependency, очень похожий на класс StubILoggerDependency с небольшими отличиями, которые будут понятны по примерам. Главное же отличие «шимов» от «стабов» заключается в том, что «шимы» нужно запускать в специальном контексте, который инициализирует всю необходимую магию.

1. Стаб метода GetCurrentDirectory

using (ShimsContext.Create())
{

    var shim = new ShimSealedLoggerDependency();
    shim.GetCurrentDirectory = () => "D:\\Temp";

 
   // Существует неявное преобразование типа от Shim-а к объекту
    SealedLoggerDependency loggerDependency = shim;
    var currentDirectory = loggerDependency.GetCurrentDirectory();



    Assert.That(currentDirectory, Is.EqualTo("D:\\Temp"));
}

2. Стаб метода GetCurrentDirectory, всегда возвращающий один результат для всех экземпляров SealedLoggerDependency

using (ShimsContext.Create())
{


    ShimSealedLoggerDependency.AllInstances.GetCurrentDirectory =
 
       
(SealedLoggerDependency d) => "D:\\Temp";
    var loggerDependency = new SealedLoggerDependency();
               
    var currentDirectory = loggerDependency.GetCurrentDirectory();


    Assert.That(currentDirectory, Is.EqualTo("D:\\Temp"));
}

3. Стаб метода GetCurrentDirectoryByLoggerName, возвращающий результат в зависимости от аргумента

using (ShimsContext.Create())
{

    var shim = new ShimSealedLoggerDependency();
    shim.GetDirectoryByLoggerNameString = (string logger) => 
logger + "C:\\Foo";



   
var loggerDependency = shim.Instance;

   
// loggerName – это аргумент параметризованного теста
    string directory = loggerDependency.GetDirectoryByLoggerName(loggerName);

    Assert.That(directory, Is.EqualTo(loggerName + "C:\\Foo"));
}

4. Стаб свойства DefaultLogger

using (ShimsContext.Create())
{

    var shim = new ShimSealedLoggerDependency();
    shim.DefaultLoggerGet = () => "DefaultLogger";
    var loggerDependency = shim.Instance;

    string defaultLogger = loggerDependency.DefaultLogger;


    Assert.That(defaultLogger, Is.EqualTo("DefaultLogger"));
}

5. Стаб события RollingRequiredEvent

using (ShimsContext.Create())
{


    var shim = new ShimSealedLoggerDependency();
    var loggerDependency = shim.Instance;

    // Мы не можем переопределить событие напрямую,
    // а может лишь переопределить подписку и отписку
    EventHandler backingDelegate = null;
    shim.RollingRequiredAddEventHandler = handler => 
backingDelegate += handler;

   
bool eventOccurred = false;
    loggerDependency.RollingRequired += (sender, args) => 
eventOccurred = true;
               
   
// Поскольку backingDelegate содержит все наши подписки
    // то мы можем сэмулировать генерацию события путем
    // “запуска” этого делегата!
   
backingDelegate(this, EventArgs.Empty);

   
Assert.IsTrue(eventOccurred, "Event should be raised!");
}

6. Стаб обобщенного метода GetConfigValue<T>

using (ShimsContext.Create())
{

    var shim = new ShimSealedLoggerDependency();
    shim.GetConfigValueOf1(() => "default");

 
   var loggerDependency = shim.Instance;
    var str = loggerDependency.GetConfigValue<string>();


    Assert.That(str, Is.EqualTo("default"));
}

7. Стаб статического метода GetDefaultDirectory

using (ShimsContext.Create())
{

    ShimSealedLoggerDependency.GetDefaultDirectory = () => "C:\\Windows";

 
    var defaultDirectory = SealedLoggerDependency.GetDefaultDirectory();
    Console.WriteLine("Default directory is '{0}'", defaultDirectory);

 
    Assert.That(defaultDirectory, Is.EqualTo("C:\\Windows"));
}

8. Вызов оригинального метода

Давайте изменим класс SealedLoggerDependency, чтобы исходная версия метода GetDefaultDirectory увеличивал счетчик GetDefaultDirectoryCallsCount:

public static int GetDefaultDirectoryCallsCount;

public static string 
GetDefaultDirectory()
{

    GetDefaultDirectoryCallsCount++;
    return GetDefaultDirectoryCallsCount.ToString();
}

Теперь мы можем написать тест, вызывающий базовую версию метода:

using (ShimsContext.Create())
{


    int initialCallsCount = 
SealedLoggerDependency.GetDefaultDirectoryCallsCount;
    ShimSealedLoggerDependency.GetDefaultDirectory = () => "C:\\Windows";


    // Вызываем подмененную версию
    SealedLoggerDependency.GetDefaultDirectory();
    Assert.That(SealedLoggerDependency.GetDefaultDirectoryCallsCount,
        Is.EqualTo(initialCallsCount));

   
// Вызываем оригинальную версию
    var defaultValue = ShimsContext.ExecuteWithoutShims(
        () => SealedLoggerDependency.GetDefaultDirectory());


    // Вызов оригинальной версии должен увеличить количество вызовов

   
Assert.That(SealedLoggerDependency.GetDefaultDirectoryCallsCount,
        Is.EqualTo(initialCallsCount + 1));
}

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

ПРИМЕЧАНИЕ
На данный момент использовать «шимы» можно только со встроенным тест-раннером, а попытка запустить их с помощью Resharper приводит к ошибке.

Не сложно заметить, что во всех приведенных примерах «подделки» эмулировали требуемое возвращаемые значения и использовались для проверки граничных условий тестируемых классов. Помимо эмуляции состояния иногда бывает нужным убедиться в том, что тестируемый класс при определенных условиях выполняет некоторые действия. Это называется проверкой поведения и здесь Microsoft Fakes практически ничего предложить не может.

В следующий раз мы рассмотрим, как проверить поведение с помощью Microsoft Fakes.

Дополнительные ссылки

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

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