Uso de correcciones de compatibilidad (shim) para aislar la aplicación para pruebas unitarias

Los tipos de correcciones de compatibilidad (shim), una de las dos tecnologías clave del marco Microsoft Fakes, son fundamentales a la hora de aislar los componentes de la aplicación durante las pruebas. Funcionan interceptando y desviando llamadas a métodos específicos, que luego se pueden dirigir al código personalizado dentro de la prueba. Esta característica permite administrar el resultado de estos métodos, lo que garantiza que los resultados sean coherentes y predecibles durante cada llamada, independientemente de las condiciones externas. Este nivel de control simplifica el proceso de prueba y ayuda a lograr unos resultados más confiables y precisos.

Use correcciones de compatibilidad (shim) cuando necesite establecer un límite entre el código y los ensamblados que no forman parte de la solución. Si el objetivo es aislar los componentes de la solución entre sí, se recomienda usar códigos auxiliares.

(Para obtener una descripción más detallada, vea Uso de código auxiliar para aislar partes de la aplicación entre sí en pruebas unitarias).

Limitaciones de las correcciones de compatibilidad (shim)

Es importante saber que las correcciones de compatibilidad tienen sus limitaciones.

Las correcciones de compatibilidad (shim) no se pueden usar en todos los tipos de algunas bibliotecas de la clase base de .NET, concretamente, en mscorlib y System en .NET Framework, y en System.Runtime en .NET Core o .NET 5+. Esta limitación debe tenerse en cuenta durante la fase de diseño y planeamiento de pruebas para que la estrategia de pruebas sea correcta y eficaz.

Creación de correcciones de compatibilidad (shim): guía paso a paso

Supongamos que el componente contiene las llamadas a System.IO.File.ReadAllLines:

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

Creación de una biblioteca de clases

  1. Abra Visual Studio y cree un proyecto Class Library.

    Screenshot of NetFramework Class Library project in Visual Studio.

  2. Establezca el nombre de proyecto HexFileReader.

  3. Establezca el nombre de solución ShimsTutorial.

  4. Establezca la plataforma de destino del proyecto en .NET Framework 4.8.

  5. Elimine el archivo predeterminado Class1.cs.

  6. Agregue un nuevo archivo HexFile.cs y agregue la siguiente definición de clase:

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

Creación de un proyecto de prueba

  1. Haga clic con el botón derecho en la solución y agregue un nuevo proyecto MSTest Test Project.

  2. Establezca el nombre de proyecto TestProject.

  3. Establezca la plataforma de destino del proyecto en .NET Framework 4.8.

    Screenshot of NetFramework Test project in Visual Studio.

Agregar un ensamblado de Fakes

  1. Agregue una referencia de proyecto a HexFileReader.

    Screenshot of the command Add Project Reference.

  2. Agregar un ensamblado de Fakes

    • En el Explorador de soluciones:

      • Para un proyecto de .NET Framework anterior (que no sea de estilo SDK), expanda el nodo Referencias del proyecto de pruebas unitarias.

      • En un proyecto de estilo SDK que tenga como destino .NET Framework, .NET Core o .NET 5+, expanda el nodo Dependencias para buscar el ensamblado que quiere imitar en Ensamblados, Proyectos o Paquetes.

      • Si está trabajando en Visual Basic, seleccione Mostrar todos los archivos en la barra de herramientas del Explorador de soluciones para ver el nodo Referencias.

    • Seleccione el ensamblado System que contiene la definición de System.IO.File.ReadAllLines.

    • En el menú contextual, seleccione Agregar ensamblado de Fakes.

    Screnshot of the command Add Fakes Assembly.

Como la compilación genera algunas advertencias y errores —porque no todos los tipos se pueden usar con correcciones de compatibilidad (shim)—, tendrá que modificar el contenido de Fakes\mscorlib.fakes para excluirlos.

<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>

Crear una prueba unitaria

  1. Modifique el archivo predeterminado UnitTest1.cs para agregar el siguiente 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);
        }
    }
    

    Aquí tenemos el Explorador de soluciones con todos los archivos.

    Screenshot of Solution Explorer showing all files.

  2. Abra el Explorador de pruebas y ejecute la prueba.

Esto es fundamental para eliminar correctamente el contexto de cada corrección de compatibilidad. Como regla general, llame a ShimsContext.Create dentro de una instrucción using para asegurarse de borrar correctamente las correcciones de compatibilidad (shim) registradas. Por ejemplo, puede registrar una corrección de compatibilidad para un método de prueba que reemplaza el método DateTime.Now con un delegado que siempre devuelve el uno de enero de 2000. Si se olvida de borrar la corrección de compatibilidad (shim) registrada en el método de prueba, el resto de la ejecución de prueba devolverá siempre el uno de enero de 2000 como valor de DateTime.Now. Esto puede tener efectos inesperados y sorprendentes.


Convenciones de nomenclatura de las clases de correcciones de compatibilidad (shim)

Los nombres de clase Shim se componen anteponiendo Fakes.Shim al nombre de tipo original. Los nombres de parámetro se anexan al nombre del método. (No es necesario agregar referencias de ensamblado a System.Fakes).

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

Descripción del funcionamiento de las correcciones de compatibilidad (shim)

Las correcciones de compatibilidad (shim) funcionan incluyendo desvíos en el código base de la aplicación que se está probando. Siempre que haya una llamada al método original, el sistema de Fakes interviene para redirigir esa llamada, lo que hace que se ejecute el código de correcciones de compatibilidad (shim) personalizado en lugar del método original.

Es importante decir que estos desvíos se crean y quitan dinámicamente en tiempo de ejecución. Los desvíos siempre deben crearse mientras dure un ShimsContext. Cuando ese ShimsContext se elimine, también se quitarán las correcciones de compatibilidad (shim) activas que se crearon mientras estaba vigente. Para controlar esto eficazmente, se recomienda encapsular la creación de desvíos dentro de una instrucción using.


Correcciones de compatibilidad (shim) para diferentes tipos de métodos

Las correcciones de compatibilidad (shim) admiten varios tipos de métodos.

Métodos estáticos

Cuando se usan correcciones de compatibilidad (shim) en métodos estáticos, las propiedades que contengan correcciones de compatibilidad (shim) se colocan dentro de un tipo de correcciones de compatibilidad (shim). Estas propiedades solo poseen un establecedor, que se usa para asociar un delegado al método de destino. Por ejemplo, si tenemos una clase llamada MyClass con un método estático MyMethod:

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

Podemos asociar una corrección de compatibilidad (shim) a MyMethod de modo que devuelva constantemente 5:

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

Métodos de instancia (para todas las instancias)

Al igual que sucede con los métodos estáticos, los métodos de instancia también se pueden procesar con correcciones de compatibilidad (shim) para todas las instancias. Las propiedades que contienen estas correcciones de compatibilidad (shim) se colocan en un tipo anidado denominado AllInstances para evitar confusiones. Si tenemos una clase MyClass con un método de instancia MyMethod:

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

Podemos asociar una corrección de compatibilidad (shim) a MyMethod de forma que siempre devuelva 5, independientemente de la instancia:

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

La estructura de tipo generada de ShimMyClass tendría el siguiente aspecto:

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

En este escenario, Fakes pasa la instancia en tiempo de ejecución como primer argumento del delegado.

Métodos de instancia (instancia en tiempo de ejecución única)

Los métodos de instancia también se pueden procesar con correcciones de compatibilidad (shim) usando diferentes delegados, en función del receptor de la llamada. Esto permite que el mismo método de instancia se comporte de distinta forma con cada instancia del tipo. Las propiedades que contienen estas correcciones de compatibilidad (shim) son métodos de instancia del propio tipo de corrección de compatibilidad. Cada instancia del tipo de corrección de compatibilidad (shim) está ligada a una instancia sin procesar de un tipo corregido de la corrección de compatibilidad (shim) en cuestión.

Por ejemplo, dada una clase MyClass con un método de instancia MyMethod:

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

Se pueden crear dos tipos de correcciones de compatibilidad (shim) para MyMethod, de modo que el primero devuelva constantemente 5 y el segundo, 10:

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

La estructura de tipo generada de ShimMyClass tendría el siguiente aspecto:

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

Puede tener acceso a la instancia real del tipo corregido para compatibilidad a través de la propiedad Instance:

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

El tipo de corrección de compatibilidad (shim) incluye también una conversión implícita al tipo corregido para compatibilidad, por lo que, en general, se puede usar directamente el tipo de corrección de compatibilidad (shim):

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

Constructores

Los constructores no son una excepción al procesamiento con correcciones de compatibilidad (shim); también se pueden procesar de esta manera para asociar tipos de correcciones de compatibilidad (shim) a los objetos que se crearán en el futuro. Por ejemplo, cada constructor se representa como un método estático, denominado Constructor, dentro del tipo de corrección de compatibilidad (shim). Vamos a analizar una clase MyClass con un constructor que acepta un entero:

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

Se puede configurar un tipo de corrección de compatibilidad (shim) para el constructor de forma que, independientemente del valor pasado al constructor, cada instancia futura devuelva -5 cuando se invoque el captador ValueGet:

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

Cada tipo de corrección de compatibilidad (shim) expone dos tipos de constructores. El constructor predeterminado se debe usar cuando se necesita una nueva instancia, mientras el constructor que toma como argumento una instancia procesada con correcciones de compatibilidad (shim) se debe usar únicamente en correcciones de compatibilidad (shim) de constructor:

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

La estructura del tipo generado para ShimMyClass se puede ilustrar de la siguiente manera:

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

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

Acceso a miembros base

Para tener acceso a las propiedades de correcciones de compatibilidad (shim) de los miembros base, hay que crear una corrección de compatibilidad (shim) para el tipo base y pasar la instancia secundaria al constructor de la clase base de la corrección de compatibilidad (shim).

Por ejemplo, si tenemos una clase MyBase con un método de instancia MyMethod y un subtipo MyChild:

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

public class MyChild : MyBase {
}

Puede configurar una corrección de compatibilidad (shim) de MyBase iniciando una corrección de compatibilidad (shim) ShimMyBase nueva:

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

Es importante reseñar que, cuando se pasa como parámetro al constructor de correcciones de compatibilidad (shim) base, el tipo de corrección de compatibilidad (shim) secundaria se convierte implícitamente en la instancia secundaria.

La estructura del tipo generado para ShimMyChild y ShimMyBase puede ser similar al código siguiente:

// 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 { ... } }
}

Constructores estáticos

Los tipos de corrección de compatibilidad exponen un método estático StaticConstructor realizar correcciones de compatibilidad del constructor estático de un tipo. Dado que los constructores estáticos se ejecutan una sola vez, debe asegurarse de que la corrección de compatibilidad esté configurada antes de que se tenga acceso a cualquier miembro del tipo.

Finalizadores

Los finalizadores no se admiten en Fakes.

Métodos privados

El generador de código de Fakes crea las propiedades de corrección de compatibilidad para los métodos privados que solo tienen tipos visibles en la firma, es decir, tipos de parámetros y tipo de valor devuelto visibles.

Interfaces de enlace

Cuando un tipo corregido para compatibilidad implementa una interfaz, el generador de código emite un método que le permite enlazar a la vez todos los miembros de esa interfaz.

Por ejemplo, dada una clase MyClass que implementa IEnumerable<int>:

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

Se pueden realizar correcciones de compatibilidad (shim) de las implementaciones de IEnumerable<int> de MyClass llamando al método Bind:

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

La estructura del tipo generado de ShimMyClass es similar al código siguiente:

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

Cambio del comportamiento predeterminado

Cada tipo de corrección de compatibilidad (shim) generado incluye una instancia de la interfaz IShimBehavior, accesible a través de la propiedad ShimBase<T>.InstanceBehavior. Este comportamiento se invoca siempre que un cliente llama a un miembro de instancia que no se ha procesado explícitamente con correcciones de compatibilidad (shim).

Si no se ha establecido ningún comportamiento específico, se usa de forma predeterminada la instancia devuelta por la propiedad estática ShimBehaviors.Current, que suele producir una excepción NotImplementedException.

Puede modificar este comportamiento en cualquier momento ajustando la propiedad InstanceBehavior de cualquier instancia de correcciones de compatibilidad (shim). Por ejemplo, el siguiente fragmento de código modifica el comportamiento para no hacer nada o para devolver el valor predeterminado del tipo de valor devuelto, es decir, default(T):

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

También puede cambiar globalmente el comportamiento de todas las instancias procesadas con correcciones de compatibilidad (shim) —donde la propiedad InstanceBehavior no se ha definido explícitamente— estableciendo la propiedad estática ShimBehaviors.Current:

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

Identificación de interacciones con dependencias externas

Para ayudar a identificar cuándo el código interactúa con dependencias o sistemas externos (denominados environment), se pueden usar correcciones de compatibilidad (shim) para asignar un comportamiento específico a todos los miembros de un tipo determinado. Esto incluye los métodos estáticos. Al establecer el comportamiento ShimBehaviors.NotImplemented en la propiedad estática Behavior del tipo de correcciones de compatibilidad (shim), cualquier acceso a un miembro de ese tipo que no se haya corregido explícitamente producirá una excepción NotImplementedException. Esto puede servir como una señal útil durante las pruebas que indique que el código está intentando acceder a una dependencia o sistema externo.

Este es un ejemplo de cómo configurar esto en el código de prueba unitaria:

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

Para mayor comodidad, también se proporciona un método abreviado que permite lograr el mismo efecto:

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

Invocación de métodos originales desde métodos de correcciones de compatibilidad (shim)

Podrían haber situaciones en las que puede que tenga que ejecutar el método original durante la ejecución del método de correcciones de compatibilidad (shim). Por ejemplo, podríamos querer escribir texto en el sistema de archivos después de haber validado el nombre de archivo que se ha pasado al método.

Un método para controlar esta situación es encapsular una llamada al método original mediante un delegado y ShimsContext.ExecuteWithoutShims(), como se muestra en el código siguiente:

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

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

Como alternativa, se puede anular la corrección de compatibilidad (shim), llamar al método original y, tras ello, restaurar la corrección de compatibilidad (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;

Cómo controlar la simultaneidad de tipos de correcciones de compatibilidad (shim)

Los tipos de correcciones de compatibilidad (shim) funcionan en todos los subprocesos dentro de AppDomain y carecen afinidad de subproceso. Tener en cuenta esta propiedad es fundamental si tiene previsto usar un ejecutor de pruebas que admita la simultaneidad. Vale la pena señalar que no se pueden ejecutar simultáneamente pruebas donde se usan tipos de correcciones de compatibilidad (shim), aunque esta restricción no procede del runtime de Fakes.

Procesamiento de System.Environment con correcciones de compatibilidad (shim)

Si quiere procesar la clase System.Environment con correcciones de compatibilidad (shim), deberá realizar algunas modificaciones en el archivo mscorlib.fakes. Agregue el siguiente contenido después del elemento Assembly:

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

Una vez que estos cambios se hayan realizado y la solución se haya recompilado, los métodos y las propiedades de la clase System.Environment sí estarán disponibles para procesarse con correcciones de compatibilidad (shim). Este es un ejemplo de cómo asignar un comportamiento al método GetCommandLineArgsGet:

System.Fakes.ShimEnvironment.GetCommandLineArgsGet = ...

Al realizar estas modificaciones, ha abierto la posibilidad de controlar y probar el modo en que el código interactúa con las variables de entorno del sistema, algo esencial para llevar a cabo pruebas unitarias completas.