Поделиться через


Используйте шимы для изоляции вашего приложения для модульного тестирования

Типы Shim, один из двух ключевых технологий, используемых Microsoft Fakes Framework, играют важную роль в изоляции компонентов приложения во время тестирования. Они работают путем перехвата и перенаправления вызовов к определенным методам, которые затем можно направлять к пользовательскому коду в рамках теста. Эта функция позволяет управлять результатом этих методов, обеспечивая согласованность и прогнозируемость результатов во время каждого вызова независимо от внешних условий. Этот уровень контроля упрощает процесс тестирования и помогает достичь более надежных и точных результатов.

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

(Более подробное описание для заглушек см. в разделе "Использование заглушек для изоляции частей приложения друг от друга при модульном тестировании".)

Ограничения для шимов

Важно отметить, что прокладки имеют свои ограничения.

Шимы нельзя использовать для всех типов из определенных библиотек в базовом классе .NET, в частности mscorlib и System в .NET Framework, а также в System.Runtime в .NET Core или .NET 5+. Это ограничение следует учитывать во время этапа планирования тестирования и проектирования, чтобы обеспечить успешную и эффективную стратегию тестирования.

Создание Shim: пошаговое руководство

Предположим, что ваш компонент содержит вызовы System.IO.File.ReadAllLines:

// Code under test:
this.Records = System.IO.File.ReadAllLines(path);

Создание библиотеки классов

  1. Откройте Visual Studio и создайте Class Library проект

    Снимок экрана: проект библиотеки классов NetFramework в Visual Studio.

  2. Установить имя проекта HexFileReader

  3. Задайте имя решения ShimsTutorial.

  4. Установка целевой платформы проекта на .NET Framework 4.8

  5. Удаление файла по умолчанию Class1.cs

  6. Добавьте новый файл HexFile.cs и добавьте следующее определение класса:

    // HexFile.cs
    public class HexFile
    {
        public string[] Records { get; private set; }
    
        public HexFile(string path)
        {
            this.Records = System.IO.File.ReadAllLines(path);
        }
    }
    

Создание тестового проекта

  1. Щелкните правой кнопкой мыши решение и добавьте новый проект MSTest Test Project

  2. Установить имя проекта TestProject

  3. Установка целевой платформы проекта на .NET Framework 4.8

    Снимок экрана: проект теста NetFramework в Visual Studio.

Добавление сборки Fakes

  1. Добавьте ссылку на проект HexFileReader

    Снимок экрана: команда

  2. Добавление сборки Fakes

    • В обозревателе решений

      • Для более старого проекта .NET Framework (не SDK-стиля) разверните элемент Ссылки проекта модульного теста.

      • Для проекта в стиле SDK, предназначенного для .NET Framework, .NET Core или .NET 5+, разверните узел зависимостей, чтобы найти сборку, которую вы хотите сделать подложной в сборках, проектах или пакетах.

      • Если вы работаете в Visual Basic, выберите "Показать все файлы " на панели инструментов обозревателя решений , чтобы просмотреть узел "Ссылки ".

    • Выберите сборку System , содержащую определение System.IO.File.ReadAllLines.

    • В контекстном меню выберите "Добавить фиктивную сборку".

    Снимок экрана: команда Add Fakes Assembly.

Так как сборка приводит к некоторым предупреждениям и ошибкам, так как не все типы могут использоваться с шимами, необходимо изменить содержимое Fakes\mscorlib.fakes , чтобы исключить их.

<Fakes xmlns="http://schemas.microsoft.com/fakes/2011/" Diagnostic="true">
  <Assembly Name="mscorlib" Version="4.0.0.0"/>
  <StubGeneration>
    <Clear/>
  </StubGeneration>
  <ShimGeneration>
    <Clear/>
    <Add FullName="System.IO.File"/>
    <Remove FullName="System.IO.FileStreamAsyncResult"/>
    <Remove FullName="System.IO.FileSystemEnumerableFactory"/>
    <Remove FullName="System.IO.FileInfoResultHandler"/>
    <Remove FullName="System.IO.FileSystemInfoResultHandler"/>
    <Remove FullName="System.IO.FileStream+FileStreamReadWriteTask"/>
    <Remove FullName="System.IO.FileSystemEnumerableIterator"/>
  </ShimGeneration>
</Fakes>

Создание модульного теста

  1. Измените файл UnitTest1.cs по умолчанию, чтобы добавить следующее. TestMethod

    [TestMethod]
    public void TestFileReadAllLine()
    {
        using (ShimsContext.Create())
        {
            // Arrange
            System.IO.Fakes.ShimFile.ReadAllLinesString = (s) => new string[] { "Hello", "World", "Shims" };
    
            // Act
            var target = new HexFile("this_file_doesnt_exist.txt");
    
            Assert.AreEqual(3, target.Records.Length);
        }
    }
    

    В обозревателе решений отображаются все файлы.

    Снимок экрана: обозреватель решений, показывающий все файлы.

  2. Откройте обозреватель тестов и запустите тест.

Важно правильно удалить каждый шим-контекст. Как правило, вызовите ShimsContext.Create внутри using инструкции, чтобы обеспечить правильную очистку зарегистрированных шима. Например, можно зарегистрировать схим для метода тестирования, заменяющего метод делегатом, который DateTime.Now всегда возвращает первое января 2000 года. Если вы забыли очистить зарегистрированный шим в методе теста, остальные тесты всегда будут возвращать первое января 2000 года в качестве значения DateTime.Now. Это может быть удивительно и запутано.


Соглашения об именовании для классов Shim

Имена классов Shim составляются путем добавления префикса Fakes.Shim к имени исходного типа. Имена параметров добавляются к имени метода. (Вам не нужно добавлять ссылку на сборку для System.Fakes.)

    System.IO.File.ReadAllLines(path);
    System.IO.Fakes.ShimFile.ReadAllLinesString = (path) => new string[] { "Hello", "World", "Shims" };

Понимание работы шимов

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

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


Шимы для различных видов методов

Шимы поддерживают различные типы методов.

Статические методы

При замещении статических методов свойства, содержащие эти замещения, размещаются в соответствующем типе замещения. Эти свойства имеют только сеттер, который используется для присоединения делегата к целевому методу. Например, если у нас есть класс MyClass со статическим методом MyMethod:

//code under test
public static class MyClass {
    public static int MyMethod() {
        ...
    }
}

Мы можем прикрепить прокладку к MyMethod, чтобы она постоянно возвращала 5:

// unit test code
ShimMyClass.MyMethod = () => 5;

Методы экземпляра (для всех объектов)

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

// code under test
public class MyClass {
    public int MyMethod() {
        ...
    }
}

Мы можем подключить схим MyMethod , чтобы он последовательно возвращал 5, независимо от экземпляра:

// unit test code
ShimMyClass.AllInstances.MyMethod = () => 5;

Структура созданного ShimMyClass типа будет отображаться следующим образом:

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass> {
    public static class AllInstances {
        public static Func<MyClass, int>MyMethod {
            set {
                ...
            }
        }
    }
}

В этом сценарии Fakes передает экземпляр среды выполнения в качестве первого аргумента делегата.

Методы экземпляра (единственный экземпляр среды выполнения)

Методы экземпляра также можно использовать с помощью разных делегатов в зависимости от приемника вызова. Это позволяет одному и тому же методу экземпляра проявлять разное поведение для каждого экземпляра типа. Свойства, связанные с этими прокладками, являются методами экземпляра типа самих прокладок. Каждый экземпляр типа shim связан с необработанным экземпляром типа shimmed.

Например, учитывая класс MyClass с методом MyMethod экземпляра:

// code under test
public class MyClass {
    public int MyMethod() {
        ...
    }
}

Мы можем создать два типа шима для MyMethod, которые позволяют первому последовательно возвращать 5, а второму — 10.

// unit test code
var myClass1 = new ShimMyClass()
{
    MyMethod = () => 5
};
var myClass2 = new ShimMyClass { MyMethod = () => 10 };

Структура созданного ShimMyClass типа будет отображаться следующим образом:

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass> {
    public Func<int> MyMethod {
        set {
            ...
        }
    }
    public MyClass Instance {
        get {
            ...
        }
    }
}

Доступ к фактическому экземпляру типа shimmed можно получить с помощью свойства Instance:

// unit test code
var shim = new ShimMyClass();
var instance = shim.Instance;

Тип shim также включает неявное преобразование в shimmed-тип, что позволяет напрямую использовать shim-тип.

// unit test code
var shim = new ShimMyClass();
MyClass instance = shim; // implicit cast retrieves the runtime instance

Конструкторы

Конструкторы не являются исключением в случае шимминга; они также могут поддерживать шимминг, чтобы присоединить типы шимм к создаваемым объектам, которые будут созданы в будущем. Например, каждый конструктор представлен как статический метод, именованный Constructorв типе shim. Рассмотрим класс MyClass с конструктором, принимаюющим целое число:

public class MyClass {
    public MyClass(int value) {
        this.Value = value;
    }
    ...
}

Тип shim для конструктора можно настроить таким образом, чтобы независимо от значения, переданного конструктору, каждый будущий экземпляр вернет -5 при вызове метода получения значения:

// unit test code
ShimMyClass.ConstructorInt32 = (@this, value) => {
    var shim = new ShimMyClass(@this) {
        ValueGet = () => -5
    };
};

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

// unit test code
public ShimMyClass() { }
public ShimMyClass(MyClass instance) : base(instance) { }

Структуру созданного типа ShimMyClass можно проиллюстрировать следующим образом:

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass>
{
    public static Action<MyClass, int> ConstructorInt32 {
        set {
            ...
        }
    }

    public ShimMyClass() { }
    public ShimMyClass(MyClass instance) : base(instance) { }
    ...
}

Доступ к элементам базового класса

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

Например, рассмотрим класс MyBase с экземплярным методом MyMethod и подтипом MyChild:

public abstract class MyBase {
    public int MyMethod() {
        ...
    }
}

public class MyChild : MyBase {
}

Шим MyBase может быть настроен путем инициирования нового ShimMyBase шима:

// unit test code
var child = new ShimMyChild();
new ShimMyBase(child) { MyMethod = () => 5 };

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

Структура созданного типа для ShimMyChild и ShimMyBase может быть похожа на следующий код:

// Fakes generated code
public class ShimMyChild : ShimBase<MyChild> {
    public ShimMyChild() { }
    public ShimMyChild(Child child)
        : base(child) { }
}
public class ShimMyBase : ShimBase<MyBase> {
    public ShimMyBase(Base target) { }
    public Func<int> MyMethod
    { set { ... } }
}

Статические конструкторы

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

Финализаторы

Финализаторы не поддерживаются в Fakes.

Частные методы

Генератор кода Fakes создает свойства схима для частных методов, которые имеют только видимые типы в сигнатуре, то есть типы параметров и видимый тип возвращаемого типа.

Интерфейсы привязки

Когда подставной тип реализует интерфейс, генератор кода создает метод, позволяющий привязать все члены этого интерфейса одновременно.

Например, учитывая класс MyClass , реализующий IEnumerable<int>:

public class MyClass : IEnumerable<int> {
    public IEnumerator<int> GetEnumerator() {
        ...
    }
    ...
}

В MyClass можно вставить реализации IEnumerable<int>, вызвав метод Bind.

// unit test code
var shimMyClass = new ShimMyClass();
shimMyClass.Bind(new List<int> { 1, 2, 3 });

Структура созданного ShimMyClass типа похожа на следующий код:

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass> {
    public ShimMyClass Bind(IEnumerable<int> target) {
        ...
    }
}

Изменение поведения по умолчанию

Каждый созданный тип прослойки включает экземпляр интерфейса IShimBehavior, доступный через свойство ShimBase<T>.InstanceBehavior. Это поведение вызывается всякий раз, когда клиент вызывает член экземпляра, который не был явно замещен.

По умолчанию, если определенное поведение не задано, он использует экземпляр, возвращаемый статическим ShimBehaviors.Current свойством, который обычно вызывает NotImplementedException исключение.

Это поведение можно изменить в любое время, изменив InstanceBehavior свойство для любого экземпляра shim. Например, следующий фрагмент кода изменяет поведение, чтобы ничего не делать или возвращать значение по умолчанию возвращаемого типа, т. е. default(T):

// unit test code
var shim = new ShimMyClass();
//return default(T) or do nothing
shim.InstanceBehavior = ShimBehaviors.DefaultValue;

Вы можете также глобально изменить поведение для всех экземпляров, в которых InstanceBehavior свойство не было явно определено, задавая статическое ShimBehaviors.Current свойство:

// unit test code
// change default shim for all shim instances where the behavior has not been set
ShimBehaviors.Current = ShimBehaviors.DefaultValue;

Определение взаимодействий с внешними зависимостями

Чтобы определить, когда код взаимодействует с внешними системами или зависимостями (называемыми environmentзависимостями), можно использовать шимы для назначения определенного поведения всем членам типа. Сюда входят статические методы. Задав ShimBehaviors.NotImplemented поведение для статического Behavior свойства типа shim, любой доступ к члену этого типа, который не был явно удален, вызовет NotImplementedExceptionисключение. Это может служить полезным сигналом во время тестирования, указывая, что код пытается получить доступ к внешней системе или зависимости.

Ниже приведен пример настройки в коде модульного теста:

// unit test code
// Assign the NotImplementedException behavior to ShimMyClass
ShimMyClass.Behavior = ShimBehaviors.NotImplemented;

Для удобства также предоставляется сокращенный метод для достижения того же эффекта:

// Shorthand to assign the NotImplementedException behavior to ShimMyClass
ShimMyClass.BehaveAsNotImplemented();

Вызов исходных методов из методов Shim

В некоторых сценариях может потребоваться выполнить исходный метод во время выполнения метода shim. Например, может потребоваться написать текст в файловую систему после проверки имени файла, переданного методу.

Один из способов обработки этой ситуации заключается в том, чтобы инкапсулировать вызов исходного метода с помощью делегата и ShimsContext.ExecuteWithoutShims(), как показано в следующем коде:

// unit test code
ShimFile.WriteAllTextStringString = (fileName, content) => {
  ShimsContext.ExecuteWithoutShims(() => {

      Console.WriteLine("enter");
      File.WriteAllText(fileName, content);
      Console.WriteLine("leave");
  });
};

Кроме того, можно аннулировать shim, вызвать оригинальный метод, а затем восстановить shim.

// unit test code
ShimsDelegates.Action<string, string> shim = null;
shim = (fileName, content) => {
  try {
    Console.WriteLine("enter");
    // remove shim in order to call original method
    ShimFile.WriteAllTextStringString = null;
    File.WriteAllText(fileName, content);
  }
  finally
  {
    // restore shim
    ShimFile.WriteAllTextStringString = shim;
    Console.WriteLine("leave");
  }
};
// initialize the shim
ShimFile.WriteAllTextStringString = shim;

Обработка параллелизма с помощью типов Shim

Типы Shim работают во всех потоках в AppDomain и не обладают привязкой к потоку. Это свойство имеет решающее значение, если вы планируете использовать средство выполнения теста, поддерживающее параллелизм. Стоит отметить, что тесты, включающие типы шима, не могут выполняться параллельно, хотя это ограничение не применяется средой выполнения Fakes.

Подмена System.Environment

Если вы хотите модифицировать класс System.Environment, вам потребуется внести некоторые изменения в файл mscorlib.fakes. После элемента Assembly добавьте следующее содержимое:

<ShimGeneration>
    <Add FullName="System.Environment"/>
</ShimGeneration>

После внесения этих изменений и перестроив решение, теперь доступны методы и свойства в System.Environment классе. Ниже приведен пример назначения поведения GetCommandLineArgsGet методу:

System.Fakes.ShimEnvironment.GetCommandLineArgsGet = ...

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