Автор: Sergey Teplyakov
Все приведенные примеры можно найти на github.
Microsoft Fakes это очередной изоляционный фреймворк, который является логическим продолжением экспериментального проекта Microsoft Moles.
Все изоляционные фреймворки делятся на несколько категорий, в зависимости от времени генерации подделок и их типу:
По времени генерации:
- генерация на лету во время исполнения тестов,
- во время компиляции,
По типу генерируемых подделок:
- на основе полиморфизма, что позволяет мокать лишь полиморфные методы,
- на основе CLR Profiling API, что позволяет мокать невиртуальные методы, включая статические.
Так, например, Moq, Rhino Mocks, FakeItEasy и многие другие генерируют моки на лету и позволяют переопределять лишь виртуальные методы. Microsoft Fakes, с другой стороны, генерирует подделки во время компиляции, а также позволяет мокать невиртуальные и статические методы.
Это значит, что для того, чтобы получить фейковые классы, нужно выбрать в Solution Explorer нужную вам сборку и выбрать пункт меню Add Fakes Assembly, в результате чего для указанной сборки будет сгенерирована соответствующая сборка со стабами:
Аналогично 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. Стаб метода GetCurrentDirectoryvar stub = new StubILoggerDependency(),stub.GetCurrentDirectory = () =>, 'D:\\Temp',// StubILoggerDependency реализует интерфейс ILoggerDependencyILoggerDependency 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. Стаб свойства DefaultLoggervar stub = new StubILoggerDependency(),stub.DefaultLoggerGet = () =>, 'DefaultLogger',ILoggerDependency loggerDependency = stub,string defaultLogger = loggerDependency.DefaultLogger,Assert.That(defaultLogger, Is.EqualTo('DefaultLogger')),
5. Стаб события RollingRequiredEventvar 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, а уже потом начать его рефакторинг для улучшения дизайна, используя существующий набор тестов, как страховку.
В качестве примера давайте рассмотрим конкретную зависимость класса Logger SealedLoggerDependency, замокать которую за счет полиморфизма невозможно:/// <,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. Стаб метода GetCurrentDirectoryusing (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, всегда возвращающий один результат для всех экземпляров SealedLoggerDependencyusing (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. Стаб свойства DefaultLoggerusing (ShimsContext.Create()){ var shim = new ShimSealedLoggerDependency(), shim.DefaultLoggerGet = () =>, 'DefaultLogger', var loggerDependency = shim.Instance, string defaultLogger = loggerDependency.DefaultLogger, Assert.That(defaultLogger, Is.EqualTo('DefaultLogger')),}
5. Стаб события RollingRequiredEventusing (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. Стаб статического метода GetDefaultDirectoryusing (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.
Дополнительные ссылки
- Moq. Примеры использования
- Microsoft Moles
- Стабы и моки