Este es el tercer artículo de la serie Domion - Un sistema para desarrollar aplicaciones en .NET Core. En el artículo anterior desarrollamos los componentes iniciales de la aplicación modelo, haciendo énfasis en el patrón de repositorio con Entity Framework Core (EF Core), que implementamos y denominamos, de forma general, como EntityManagers.

En esta ocasión nos vamos en enfocar en desarrollar unas pruebas básicas de integración para los EntityManagers, no sólo con el objetivo de verificar su funcionamiento, sino también para que sirvan como los bloques básicos para construir las pruebas de aceptación con SpecFlow, siguiendo el enfoque BDD, que veremos más adelante en la serie.

También aprovecharemos luego este aprendizaje para desarrollar nuevas plantillas para generar este tipo de componentes y seguir ampliando el alcance de Domion.

Puntos Importantes
  1. Pruebas de integración con xUnit y FluentAssertions.

  2. Mover o renombrar proyectos de una solución en VS 2017.

  3. Cambiar el TargetFramework en proyectos .NET Core.

  4. Entender la forma correcta de hacer pruebas de integración sobre un DbContext.

  5. Aplicar el patrón de pruebas Arrange / Act / Assert.

  6. Refactorización usando delegados.

  7. Estandarizar pruebas típicas como paso previo a generarlas usando MDA.

Al terminar el artículo tendremos una buena estructura para organizar las pruebas de integración y podremos apreciar las ventajas de trabajar con el enfoque “Code First” en Entity Framework Core.

Para el artículo siguiente tenemos planeado trabajar con:

  1. Inyección de dependencias usando AutoFac.

Programas fuente

Artículo: Domion.Net-3.0.zip (release 3.0 del repositorio)
Repositorio: https://github.com/mvelosop/Domion.Net

Importante

Si quiere realizar el tutorial paso a paso, le recomiendo que comience con los fuentes del release 2.0 (.zip)

El tiempo estimado para realizar este tutorial es de aproximadamente una hora.

Contexto

Después de varios años desarrollando productos (que requieren mantenimiento) y haber pagado el precio de haber sido un poco laxo con el tema de las pruebas, he llegado a la conclusión de que las pruebas automatizadas son una de las mejores inversiones que podemos realizar para mejorar la productividad de la empresa, cuando hay que dar mantenimiento a los programas y no se factura por hora.

Esto puede parecer contradictorio, ya que las pruebas requieren tiempo adicional para desarrollarlas y luego para mantenerlas, pero, en mi humilde opinión, sólo hace falta tener un enfoque pragmático en las pruebas.

En mi segunda iteración profesional en desarrollo de software, usando .NET, lo que me ha dado mejor resultado valor/costo, ha sido trabajar casi todas las historias de usuario partiendo desde las pruebas de aceptación usando BDD con SpecFlow, pero hacerlo desde la capa de negocio, incluyendo la base de datos, sin pasar por la interfaz de usuario.

Además, en cuanto a las pruebas unitarias, hacerlas sólo en los casos complejos, como algunas máquinas de estado, por ejemplo.

Y la verdad es que, aunque me había funcionado bien, me daba cierta vergüenza decirlo, hasta que un día escuché a Scott Allen en .Net Rocks y luego encontré esta respuesta en Stack Overflow del mismísimo Kent Beck, considerado el padre de TDD.

Todavía no vamos a hablar sobre BDD, sino que vamos a comenzar con las pruebas de integración, como ya lo hemos mencionado.

Sin embargo, estamos apuntando a facilitar el desarrollo de las pruebas o, mejor dicho, las especificaciones con SpecFlow.

Herramientas y plataforma

Paquetes NuGet utilizados

  • FluentAssertions - 4.19.2
  • Microsoft.EntityFrameworkCore - 1.1.2
  • Microsoft.EntityFrameworkCore.Design - 1.1.2
  • Microsoft.EntityFrameworkCore.SqlServer - 1.1.2
  • Microsoft.NET.Test.Sdk - 15.0.0
  • NLog - 5.0.0-beta07
  • System.ComponentModel.Annotations - 4.3.0
  • xunit - 2.2.0
  • xunit.runner.visualstudio - 2.2.0

A - Mover proyecto de pruebas en la solución

Preparando el artículo me di cuenta que las pruebas que vamos a hacer corresponden al proyecto de prueba y no directamente a las librerías, aunque, obviamente al probar la aplicación también estamos probándolas, pero me parece más adecuado renombrar la carpeta tests a samples.tests, para reflejar más claramente lo que son.

Importante

Estrictamente hablando, probablemente sería más fácil eliminar y crear los proyectos de nuevo, porque están vacíos, pero es útil saber cómo hacer esta operación, para cuando sea realmente necesario.

A-1 - Renombrar carpetas en Visual Studio

Desde el explorador de la solución:

  1. Cambiar nombre de la carpeta tests por samples.test
  2. Cerar la solución, salvando el archivo .sln cuando Visual Studio pregunte si quiere salvar los cambios

A-2 - Renombrar carpetas del sistema de archivos

Desde el explorador de archivos:

  1. Cambiar nombre de la carpeta tests por samples.test
  2. Editar el archivo Domion.Net.sln y cambiar las línea donde aparecen las rutas originales de los proyectos de pruebas por las rutas nuevas:

Rutas originales ("tests\...)

Project("{FAE ... FBC}") = "DFlow.Budget.Lib.Tests", "tests\DFlow.Budget.Lib.Tests\DFlow.Budget.Lib.Tests.csproj", ...

Project("{9A1 ... 556}") = "DFlow.Transactions.Lib.Tests", "tests\DFlow.Transactions.Lib.Tests\DFlow.Transactions.Lib.Tests.csproj", ...

Rutas nuevas ("samples.tests\...)

Project("{FAE ... FBC}") = "DFlow.Budget.Lib.Tests", "sample.tests\DFlow.Budget.Lib.Tests\DFlow.Budget.Lib.Tests.csproj", ...

Project("{9A1 ... 556}") = "DFlow.Transactions.Lib.Tests", "samples.tests\DFlow.Transactions.Lib.Tests\DFlow.Transactions.Lib.Tests.csproj", ...

A-3 - Abrir la solución y recompilar

Al abrir la solución se deberían ver los proyectos en el explorador de la solución y recompilar sin errores.

Pruebas de integración con xUnit y Entity Framework Core /posts/images/devenv_2017-06-16_12-06-49.png

A-4 - Cambiar las referencias de los proyectos

En nuestro caso, todavía no hay ninguna referencia hacia los proyectos de prueba, pero si las hubiese, sería necesario cambiarlas en los archivos .csproj de los proyectos correspondientes.

A-5 - En caso de emergencia

En caso de que no logre realizar este proceso con éxito, probablemente lo mejor es borrar el proyecto por completo y crearlo de nuevo.

Importante

Recuerde que al crear el proyecto sobre una carpeta de solución en Visual Studio, debe seleccionar a mano la carpeta en el sistema de archivos.

B - Preparación del ambiente

Importante

Al escribir el artículo (15/06/2017), por alguna razón que no logré identificar y corregir, falló el explorador de pruebas de Visual Studio 2017 y, al no encontrar las pruebas, no podía ejecutarlas.

Sin embargo pude encontrar una vuelta, cambiando el TargetFramework de los proyectos a .NET Framework 4.6.2.

En esta sección entonces vamos a cambiar el TargetFramework de los proyectos, además de instalar los paquetes necesarios, para no tener que enfrentarnos con el problema mencionado.

B-1 - Cambiar el TargetFramework (plataforma)

¿Qué significa cambiar el target framework?

Significa que después de hacerlo, aunque vamos a seguir desarrollando en .NET Core, sólo vamos a poder correr los programas en Windows.

Si el target framework fuera .NET Core, podríamos correrlo en cualquier plataforma soportada, por ejemplo, Linux.

De todas formas, eventualmente resolverán este problema y podremos volver a .NET Core.

Importante

Cuando trabajamos con .NET Core podemos incluso utilizar varios TargetFrameworks, por ejemplo .NET Core y .NET Framework 4.6.2, y así generar los ejecutables para ambas plataformas.

Lamentablemente el explorador de pruebas tampoco funcionaba al trabajar con las dos plataformas simultáneamente.

B-1.1 - Descargar todos los proyectos de la solución

En teoría se debería poder hacer sin necesidad de descargar los proyectos, pero me ha resultado más rápido hacerlo así.

  1. Seleccionar todos los proyectos en el explorador de la solución
  2. [Botón derecho > Unload Project]
Pruebas de integración con xUnit y Entity Framework Core /posts/images/2017-06-15_13-51-33.png

B-1.2 - Modificar DFlow.Budget.Core.csproj

El archivo .csproj se modifica con [Botón derecho > Edit {nombre del proyecto}.csproj] sobre el proyecto en el explorador de la solución.

Para cambiar la plataforma hay que cambiar el tag del TargetFramework de .NET Core 1.1 (netcoreapp1.1)

<PropertyGroup>
	<TargetFramework>netcoreapp1.1</TargetFramework>
</PropertyGroup>

a .NET Framework 4.6.2 (net462)

<PropertyGroup>
	<TargetFramework>net462</TargetFramework>
</PropertyGroup>

Además de cambiar la plataforma, en el proyecto DFlow.Budget.Core, es necesario incluir un referencia a externa a System.ComponentModel.Annotations cuando el target framework sea “net462”, así que el archivo DFlow.Budget.Core.csproj debe quedar así:

DFlow.Budget.Core.csproj
[Domion.Net-3.0] samples\DFlow.Budget.Core\
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>net462</TargetFramework>
    </PropertyGroup>

    <ItemGroup Condition="'$(TargetFramework)'=='net462'">
        <PackageReference Include="System.ComponentModel.Annotations" Version="4.3.0" />
    </ItemGroup>

    <ItemGroup>
        <ProjectReference Include="..\..\src\Domion.Core\Domion.Core.csproj" />
    </ItemGroup>

</Project>

Note que la inclusión de la referencia está condicionada al TargetFramework net462

No es necesario hacer esto para netcoreapp1.1, porque el paquete System.ComponentModel.Annotations ya está incluído en .NET Core.

B-1.3 - Editar archivos .csproj

Para el resto de los proyectos sólo es necesario hacer el cambio de la plataforma, editando el archivo .csproj para cambiar netcoreapp1.1 por net462.

B-1.4 - Recargar todos los proyectos de la solución

  1. Seleccionar todos los proyectos en el explorador de la solución
  2. [Botón derecho > Reload Project]
Pruebas de integración con xUnit y Entity Framework Core /posts/images/2017-06-16_12-47-07.png

En este momento debería poder compilar la solución sin errores.

B-2 - Crear proyecto src\Domion.FluentAssertions

Como estamos preparando el ambiente, vamos a crear de una vez el proyecto indicado, porque lo vamos a necesitar en un momento.

El proyecto se debe crear como Class Library (.NET Core)

Importante

Recuerde que debe seleccionar manualmente la carpeta “src” al crear el proyecto.

Para este proyecto también tenemos que hacer el cambio de plataforma e incluir el paquete System.ComponentModel.Annotations, así que vamos a modificar el archivo Domion.FluentAssertions.csproj a esto:

Domion.FluentAssertions.csproj
[Domion.Net-3.0] src\Domion.FluentAssertions\
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>net462</TargetFramework>
    </PropertyGroup>

    <ItemGroup Condition="'$(TargetFramework)'=='net462'">
        <PackageReference Include="System.ComponentModel.Annotations" Version="4.3.0" />
    </ItemGroup>

    <ItemGroup>
        <PackageReference Include="FluentAssertions" Version="4.19.2" />
    </ItemGroup>

</Project>

Con esto también instalaremos de una vez el paquete FluentAssertions que vamos a necesitar.

B-3 - Configurar proyecto de pruebas

B-3.1 - Crear archivo de configuración de xUnit

Crear el archivo xunit.runner.json en la raíz del proyecto de pruebas:

xunit.runner.json
[Domion.Net-3.0] samples.tests\DFlow.Budget.Lib.Tests\
1
2
3
{
  "methodDisplay": "method"
}

Este archivo hace que el explorador de pruebas muestre el sólo nombre de los métodos de prueba (en vez de mostrar también el nombre completo de la clase):

Ajustar las propiedades del archivo ([Alt]+[Enter] o [Botón derecho > Properties] sobre el explorador de la solución) para que siempre se copie a la carpeta de salida (ejecutables).

Pruebas de integración con xUnit y Entity Framework Core /posts/images/devenv_2017-06-15_18-26-36.png

C - Pruebas de integración básicas

En esta sección del artículo vamos desarrollar una versión inicial básica de las pruebas de integración.

Incluso, vamos a comenzar con una versión que ni siquiera ha pasado por una refactorización, para explicar el proceso de llegar a una estructura mucho más cómoda de usar.

C-1 - Trabajar con DbContext

Importante

Es importante tener en cuenta la forma adecuada de trabajar con un DbContext, ya que no seguir las recomendaciones nos puede traer problemas difíciles de diagnosticar y resolver.

Un DbContext es, entre otras cosas, un cache, con sus ventajas e inconvenientes, así que es necesario estar consciente de eso.

Entonces, como para efectos de esta serie estamos enfocados en el desarrollo de aplicaciones web, donde la vida de cada DbContext está limitada a un request, tenemos que simular ese ciclo de vida en las pruebas, para que éstas se parezcan más a las condiciones reales de producción.

Esto quiere decir que un DbContext (que implementa IDisposable) se debe utilizar dentro de una estructura “using” (using (var dbContext = new DbContext()) { }) para “delimitar” los pasos que normalmente se realizarían durante un request y estar seguros que el DbContext se descarta al terminar el using.

C-1.1 - BudgetClassData - Datos de prueba

Para facilitar el manejo de los datos de prueba, vamos a usar una clase que representa los datos ingresados por el usuario.

Además, como veremos en un artículo posterior, esto es especialmente útil cuando tenemos que hacer referencia a otros objetos

BudgetClassData.cs
[Domion.Net-3.0] samples.tests\DFlow.Budget.Lib.Tests\Helpers\
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
using DFlow.Budget.Core.Model;

namespace DFlow.Budget.Lib.Tests.Helpers
{
    public class BudgetClassData
    {
        public BudgetClassData(string name, TransactionType transactionType)
        {
            Name = name;

            TransactionType = transactionType;
        }

        public string Name { get; set; }

        public TransactionType TransactionType { get; set; }
    }
}

C-1.2 - Estructura de las pruebas con un DbContext

Importante

Para efectos de las pruebas de todo tipo, tanto unitarias como de integración y aceptación o comportamiento (BDD), se usa el patrón Arrange / Act / Assert, en el cuál:

  • Arrange: Crea el contexto para realizar la prueba.
  • Act: Ejecuta lo que se quiere probar.
  • Assert: Verifica que se hayan obtenido los resultados esperados.

Al combinar esto con lo indicado en el punto anterior, resulta que una prueba típica tiene la siguiente estructura, donde lo que más destaca es un using () { } para cada fase (Arrange, Act, Assert) como se muestra a continuación:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
[Fact]
public void TryInsert_InsertsRecord_WhenValidData()
{
    IEnumerable<ValidationResult> errors = null;

    // Arrange ---------------------------

    var data = BudgetClassData("Insert-Success-Valid - Inserted", TransactionType.Income);

    // Ensure entitiy does not exist
    using (var dbContext = dbSetupHelper.GetDbContext())
    {
        var manager = new BudgetClassManager(dbContext);

        var entity = manager.SingleOrDefault(bc => bc.Name == data.Name);

        if (entity != null)
        {
            errors = manager.TryDelete(entity);

            errors.Should().BeEmpty();

            manager.SaveChanges();
        }
    }

    // Act -------------------------------

    // Insert entity
    using (var dbContext = dbSetupHelper.GetDbContext())
    {
        var manager = new BudgetClassManager(dbContext);

        BudgetClass entity = new BudgetClass { Name = data.Name, TransactionType = data.TransactionType };

        errors = manager.TryInsert(entity);

        manager.SaveChanges();
    }

    // Assert ----------------------------

    errors.Should().BeEmpty();

    // Verify entity exists
    using (var dbContext = dbSetupHelper.GetDbContext())
    {
        var manager = new BudgetClassManager(dbContext);

        var entity = manager.SingleOrDefault(bc => bc.Name == data.Name);

        entity.Should().NotBeNull();
    }
}

Según lo que hemos comentado, debería ser bastante clara la necesidad del using () {} en la fase Act, pero ¿Por qué en el Arrange y el Assert?

Porque de esa forma, al usar instancias diferentes del DbContext, nos aseguramos de evitar resultados incorrectos (tanto falsos positivos como negativos) por objetos que pueden quedar en el ChangeTracker del DbContext, como resultado de las operaciones anteriores.

C-2 - Refactorización

Con una inspección rápida del código anterior es evidente que hay varias oportunidades de refactorización.

Lo primero que vamos a trabajar son las fases de Arrange y Assert, en éstas encontramos cuatro casos fundamentales.

Para que las pruebas sean repetibles es necesario:

  1. Asegurar que algunas entidades existan en la base de datos y/o
  2. Asegurar que algunas entidades no existan en la base de datos

Y para terminar, después de ejecutar que función que se está probando, en la mayoría de los casos, es necesario:

  1. Verificar si existen algunas entidades en la base de datos y/o
  2. Verificar si no existen algunas entidades en la base de datos

No vamos a ver ahora los detalles de implementación de esos métodos, porque son bastante obvios, pero lo importante es que vamos a usar una clase “Helper” que resuelva esos detalles y ésta va a necesitar una instancia del EntityManager para hacerlo.

C-2.1 - Manager Helper

El punto importante es que, después de implementar lo que sería el BudgetClassManagerHelper se reducen significativamente las secciones:

Arrange

// Ensure entitiy does not exist
using (var dbContext = dbSetupHelper.GetDbContext())
{
	var manager = new BudgetClassManager(dbContext);
	var helper = new BudgetClassManagerHelper(manager);

	helper.EnsureEntitiesDoNotExist(data);
}

Assert

// Verify entity exists
using (var dbContext = dbSetupHelper.GetDbContext())
{
	var manager = new BudgetClassManager(dbContext);
	var helper = new BudgetClassManagerHelper(manager);

	helper.AssertEntitiesExist(data);
}

Sin embargo, todavía hay algo por mejorar ahí, aunque a lo mejor no es tan evidente cómo hacerlo.

La solución se basa en el uso de delegados, específicamente un Action, porque en ambos casos el contexto del using es el mismo, sólo cambia lo que se ejecuta dentro.

C-2.2 - Refactorizando el contexto y la verificación

Importante

Aquí vamos a ver una estrategia interesante de refactorización usando delegados.

Entonces sólo tenemos que implementar un método que resuelva el contexto y reciba como parámetro lo que se va a ejecutar.

Además, como sabemos que se va a trabajar con el helper, se lo podemos pasar como parámetro al Action para que sea todavía más fácil usarlo:

private void UsingManagerHelper(Action<BudgetClassManagerHelper> action)
{
	using (var dbContext = dbSetupHelper.GetDbContext())
	{
		var manager = new BudgetClassManager(dbContext);
		var helper = new BudgetClassManagerHelper(manager);

		action.Invoke(helper);
	}
}

Después, con este nuevo método, ahora sí se reduce significativamente el código y el nombre de los métodos es completamente explicativo:

Arrange

// Ensure entitiy does not exist
UsingManagerHelper(helper =>
{
	helper.EnsureEntitiesDoNotExist(data);
});

Assert

// Verify entity exists
UsingManagerHelper(helper =>
{
	helper.AssertEntitiesExist(data);
});

C-2.3 - Refactorizando la sección de prueba (Act)

De forma similar, también podemos refactorizar la sección de prueba para obtener esto:

private void UsingManager(Action<BudgetClassManager> action)
{
	using (BudgetDbContext dbContext = DbSetupHelper.GetDbContext())
	{
		var manager = new BudgetClassManager(dbContext);

		action.Invoke(manager);
	}
}

Ahora, aplicando todas las refactorizaciones, la prueba queda bastante simplificada respecto a la versión inicial:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
[Fact]
public void TryInsert_InsertsRecord_WhenValidData()
{
    IEnumerable<ValidationResult> errors = null;

    // Arrange ---------------------------

    var data = BudgetClassData("Insert-Success-Valid - Inserted", TransactionType.Income);

    UsingManagerHelper(helper =>
    {
        helper.EnsureEntitiesDoNotExist(data);
    });

    // Act -------------------------------

    UsingManager(manager =>
    {
        BudgetClass entity = new BudgetClass { Name = data.Name, TransactionType = data.TransactionType };

        errors = manager.TryInsert(entity).ToList();

        manager.SaveChanges();
    });

    // Assert ----------------------------

    errors.Should().BeEmpty();

    UsingManagerHelper(helper =>
    {
        helper.AssertEntitiesExist(data);
    });
}

C-3 - Refactorización y versión final

Para no extender demasiado el artículo, a continuación presentamos las versiones finales de las clases resultantes.

Las clases que se muestran a continuación se pueden copiar e incluir directamente en el proyecto.

C-3.1 - BudgetClassDataMapper - Convertidor entre datos y entidades

Entre esta clase y BudgetClassData, se implementa el patrón Mapper para establecer la comunicación entre las pruebas y las librerías (.Core y .Lib) del módulo, haciendo la conversión BudgetClass <–> BudgetClassData.

Este patrón, va a resultar especialmente útil en los escenarios más complejos que veremos más adelante en la serie.

BudgetClassDataMapper.cs
[Domion.Net-3.0] samples.tests\DFlow.Budget.Lib.Tests\Helpers\
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using DFlow.Budget.Core.Model;

namespace DFlow.Budget.Lib.Tests.Helpers
{
    public class BudgetClassDataMapper
    {
        public BudgetClassData CreateData(BudgetClass entity)
        {
            var data = new BudgetClassData(entity.Name, entity.TransactionType);

            return data;
        }

        public BudgetClass CreateEntity(BudgetClassData data)
        {
            var entity = new BudgetClass();

            return UpdateEntity(entity, data);
        }

        public BudgetClass UpdateEntity(BudgetClass entity, BudgetClassData data)
        {
            entity.Name = data.Name;
            entity.TransactionType = data.TransactionType;

            return entity;
        }
    }
}

C-3.2 - BudgetClassManagerHelper - Asistente del EntityManager

En esta clase se implementan los cuatro puntos mencionados en C-2 - Refactorización.

Adicionalmente a lo que se indicó en ese punto, se puede ver que en los métodos Assert… se están creando nuevos DbContext y EntityManager.

Esto se hace para poder llamarlos desde los Ensure… y estar seguros que no estamos trabajando con el mismo DbContext donde se realizó la operación.

BudgetClassManagerHelper.cs
[Domion.Net-3.0] samples.tests\DFlow.Budget.Lib.Tests\Helpers\
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
using DFlow.Budget.Core.Model;
using DFlow.Budget.Lib.Data;
using DFlow.Budget.Lib.Services;
using DFlow.Budget.Setup;
using Domion.Lib.Extensions;
using FluentAssertions;

namespace DFlow.Budget.Lib.Tests.Helpers
{
    public class BudgetClassManagerHelper
    {
        /// <summary>
        ///     Creates a Helper for BudgetClassManager to help in the test's Arrange and Assert sections
        /// </summary>
        public BudgetClassManagerHelper(
            BudgetClassManager classBudgetClassManager,
            BudgetDbSetupHelper budgetDbSetupHelper)
        {
            BudgetClassManager = classBudgetClassManager;
            BudgetDbSetupHelper = budgetDbSetupHelper;

            BudgetClassMapper = new BudgetClassDataMapper();
        }

        private BudgetClassManager BudgetClassManager { get; }

        private BudgetClassDataMapper BudgetClassMapper { get; }

        private BudgetDbSetupHelper BudgetDbSetupHelper { get; }

        /// <summary>
        ///     Asserts that entities with the supplied key data values do not exist
        /// </summary>
        /// <param name="dataSet">Data for the entities to be searched for</param>
        public void AssertEntitiesDoNotExist(params BudgetClassData[] dataSet)
        {
            using (BudgetDbContext dbContext = BudgetDbSetupHelper.GetDbContext())
            {
                var manager = new BudgetClassManager(dbContext);

                foreach (BudgetClassData data in dataSet)
                {
                    BudgetClass entity = manager.SingleOrDefault(e => e.Name == data.Name);

                    entity.Should().BeNull(@"because BudgetClass ""{0}"" MUST NOT EXIST!", data.Name);
                }
            }
        }

        /// <summary>
        ///     Asserts that entities equivalent to the supplied input data classes exist
        /// </summary>
        /// <param name="dataSet">Data for the entities to be searched for</param>
        public void AssertEntitiesExist(params BudgetClassData[] dataSet)
        {
            using (BudgetDbContext dbContext = BudgetDbSetupHelper.GetDbContext())
            {
                var manager = new BudgetClassManager(dbContext);
                var mapper = new BudgetClassDataMapper();

                foreach (BudgetClassData data in dataSet)
                {
                    BudgetClass entity = manager.SingleOrDefault(e => e.Name == data.Name);

                    entity.Should().NotBeNull(@"because BudgetClass ""{0}"" MUST EXIST!", data.Name);

                    BudgetClassData entityData = mapper.CreateData(entity);

                    entityData.ShouldBeEquivalentTo(data);
                }
            }
        }

        /// <summary>
        ///     Ensures that the entities do not exist in the database or are succesfully removed
        /// </summary>
        /// <param name="dataSet">Data for the entities to be searched for and removed if necessary</param>
        public void EnsureEntitiesDoNotExist(params BudgetClassData[] dataSet)
        {
            foreach (BudgetClassData data in dataSet)
            {
                BudgetClass entity = BudgetClassManager.SingleOrDefault(e => e.Name == data.Name);

                if (entity == null) continue;

                var errors = BudgetClassManager.TryDelete(entity);

                errors.Should().BeEmpty(@"because BudgetClass ""{0}"" has to be removed!", data.Name);
            }

            BudgetClassManager.SaveChanges();

            AssertEntitiesDoNotExist(dataSet);
        }

        /// <summary>
        ///     Ensures that the entities exist in the database or are succesfully added
        /// </summary>
        /// <param name="dataSet"></param>
        /// <param name="dataSet">Data for the entities to be searched for and added or updated if necessary</param>
        public void EnsureEntitiesExist(params BudgetClassData[] dataSet)
        {
            foreach (BudgetClassData data in dataSet)
            {
                BudgetClass entity = BudgetClassManager.SingleOrDefault(e => e.Name == data.Name);

                entity = entity == null ? BudgetClassMapper.CreateEntity(data) : BudgetClassMapper.UpdateEntity(entity, data);

                var errors = BudgetClassManager.TryUpsert(entity);

                errors.Should().BeEmpty(@"because BudgetClass ""{0}"" has to be added!", data.Name);
            }

            BudgetClassManager.SaveChanges();

            AssertEntitiesExist(dataSet);
        }
    }
}

C-3.3 - FluentAssertionsExtensions - Extensión para facilitar la verificación de errores

En esta clase se implementa una extensión sobre la librería FluentAssertions, para facilitar la verificación de los mensajes de error en las validaciones.

Aquí se compara si en la colección de mensajes recibidos, hay alguno que comience con la parte constante del mensaje esperado.

La parte constante del mensaje va desde el comienzo del string hasta la posición del primer parámetro de sustitución (el primer “{”).

Esta clase la vamos a incluir en el proyecto src\Domion.FluentAssertions que creamos anteriormente.

FluentAssertionsExtensions.cs
[Domion.Net-3.0] src\Domion.FluentAssertions\Extensions\
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using FluentAssertions.Collections;
using System.ComponentModel.DataAnnotations;
using System.Linq;

namespace Domion.FluentAssertions.Extensions
{
    public static class FluentAssertionsExtensions
    {
        /// <summary>
        ///     Asserts that the ValidationError collection contains a message that starts with the constant part of errorMessage,
        ///     i.e. up to the first substitution placeholder ("{.*}"), if any.
        /// </summary>
        /// <param name="assertion"></param>
        /// <param name="errorMessage">Error message text, will be trimmed up to the first substitution placeholder ("{.*}").</param>
        /// <param name="because">The reason why the predicate should be satisfied.</param>
        /// <param name="becauseArgs">The parameters used when formatting the reason message.</param>
        public static void ContainErrorMessage(this GenericCollectionAssertions<ValidationResult> assertion, string errorMessage, string because = "", params object[] becauseArgs)
        {
            var errorMessageStart = errorMessage.Split('{')[0];

            assertion.Match(c => c.Any(vr => vr.ErrorMessage.StartsWith(errorMessageStart)), because, becauseArgs);
        }
    }
}

C-3.4 - BudgetClassManager_IntegrationTests - Pruebas de integración

Finalmente llegamos a las pruebas de integración, donde implementamos las siguientes pruebas básicas:

  1. Se pueden insertar entidades.
  2. Se pueden modificar entidades.
  3. Se pueden eliminar entidades.
  4. No se puede insertar una entidad con nombre duplicado.
  5. No se puede modificar el nombre de una entidad si ya existe otra con el nuevo nombre, porque se duplicaría.
BudgetClassManager_IntegrationTests.cs
[Domion.Net-3.0] samples.tests\DFlow.Budget.Lib.Tests\Tests\
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
using DFlow.Budget.Core.Model;
using DFlow.Budget.Lib.Data;
using DFlow.Budget.Lib.Services;
using DFlow.Budget.Lib.Tests.Helpers;
using DFlow.Budget.Setup;
using Domion.FluentAssertions.Extensions;
using Domion.Lib.Extensions;
using FluentAssertions;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Xunit;

namespace DFlow.Budget.Lib.Tests.Tests
{
    [Trait("Type", "Integration")]
    public class BudgetClassManager_IntegrationTests
    {
        private const string ConnectionString = "Data Source=localhost;Initial Catalog=DFlow.Budget.Lib.Tests;Integrated Security=SSPI;MultipleActiveResultSets=true";

        private static readonly BudgetDbSetupHelper DbSetupHelper;
        private static readonly Lazy<BudgetClassDataMapper> LazyBudgetClassEntityHelper;

        static BudgetClassManager_IntegrationTests()
        {
            DbSetupHelper = SetupDatabase(ConnectionString);

            LazyBudgetClassEntityHelper = new Lazy<BudgetClassDataMapper>(() => new BudgetClassDataMapper());
        }

        public BudgetClassDataMapper Mapper => LazyBudgetClassEntityHelper.Value;

        [Fact]
        public void TryDelete_DeletesRecord_WhenValidData()
        {
            // Arrange ---------------------------

            var data = new BudgetClassData("Delete-Success-Valid - Inserted", TransactionType.Income);

            UsingManagerHelper(helper =>
            {
                helper.EnsureEntitiesExist(data);
            });

            // Act -------------------------------

            IEnumerable<ValidationResult> errors = null;

            UsingManager(manager =>
            {
                BudgetClass entity = manager.SingleOrDefault(bc => bc.Name == data.Name);

                errors = manager.TryDelete(entity).ToList();

                manager.SaveChanges();
            });

            // Assert ----------------------------

            errors.Should().BeEmpty();

            UsingManagerHelper(helper =>
            {
                helper.AssertEntitiesDoNotExist(data);
            });
        }

        [Fact]
        public void TryInsert_Fails_WhenDuplicateKeyData()
        {
            // Arrange ---------------------------

            var data = new BudgetClassData("Insert-Error-Duplicate - Inserted", TransactionType.Income);

            UsingManagerHelper(helper =>
            {
                helper.EnsureEntitiesExist(data);
            });

            // Act -------------------------------

            IEnumerable<ValidationResult> errors = null;

            UsingManager(manager =>
            {
                BudgetClass entity = Mapper.CreateEntity(data);

                errors = manager.TryInsert(entity).ToList();
            });

            // Assert ----------------------------

            errors.Should().ContainErrorMessage(BudgetClassManager.duplicateByNameError);
        }

        [Fact]
        public void TryInsert_InsertsRecord_WhenValidData()
        {
            IEnumerable<ValidationResult> errors = null;

            // Arrange ---------------------------

            var data = new BudgetClassData("Insert-Success-Valid - Inserted", TransactionType.Income);

            UsingManagerHelper(helper =>
            {
                helper.EnsureEntitiesDoNotExist(data);
            });

            // Act -------------------------------

            UsingManager(manager =>
            {
                BudgetClass entity = Mapper.CreateEntity(data);

                errors = manager.TryInsert(entity).ToList();

                manager.SaveChanges();
            });

            // Assert ----------------------------

            errors.Should().BeEmpty();

            UsingManagerHelper(helper =>
            {
                helper.AssertEntitiesExist(data);
            });
        }

        [Fact]
        public void TryUpdate_Fails_WhenDuplicateKeyData()
        {
            // Arrange ---------------------------

            var data = new BudgetClassData("Update-Error-Duplicate - Inserted first", TransactionType.Income);
            var update = new BudgetClassData("Update-Error-Duplicate - Inserted second", TransactionType.Income);

            UsingManagerHelper(helper =>
            {
                helper.EnsureEntitiesExist(data, update);
            });

            // Act -------------------------------

            IEnumerable<ValidationResult> errors = null;

            UsingManager(manager =>
            {
                BudgetClass entity = manager.SingleOrDefault(bc => bc.Name == data.Name);

                entity = Mapper.UpdateEntity(entity, update);

                errors = manager.TryUpdate(entity).ToList();
            });

            // Assert ----------------------------

            errors.Should().ContainErrorMessage(BudgetClassManager.duplicateByNameError);
        }

        [Fact]
        public void TryUpdate_UpdatesRecord_WhenValidData()
        {
            // Arrange ---------------------------

            var data = new BudgetClassData("Update-Success-Valid - Inserted", TransactionType.Income);
            var update = new BudgetClassData("Update-Success-Valid - Updated", TransactionType.Income);

            UsingManagerHelper(helper =>
            {
                helper.EnsureEntitiesExist(data);
                helper.EnsureEntitiesDoNotExist(update);
            });

            // Act -------------------------------

            IEnumerable<ValidationResult> errors = null;

            UsingManager(manager =>
            {
                BudgetClass entity = manager.SingleOrDefault(bc => bc.Name == data.Name);

                entity = Mapper.UpdateEntity(entity, update);

                errors = manager.TryUpdate(entity).ToList();

                manager.SaveChanges();
            });

            // Assert ----------------------------

            errors.Should().BeEmpty();

            UsingManagerHelper(helper =>
            {
                helper.AssertEntitiesExist(update);
            });
        }

        private static BudgetDbSetupHelper SetupDatabase(string connectionString)
        {
            var dbHelper = new BudgetDbSetupHelper(connectionString);

            dbHelper.SetupDatabase();

            return dbHelper;
        }

        private void UsingManager(Action<BudgetClassManager> action)
        {
            using (BudgetDbContext dbContext = DbSetupHelper.GetDbContext())
            {
                var manager = new BudgetClassManager(dbContext);

                action.Invoke(manager);
            }
        }

        private void UsingManagerHelper(Action<BudgetClassManagerHelper> action)
        {
            using (BudgetDbContext dbContext = DbSetupHelper.GetDbContext())
            {
                var manager = new BudgetClassManager(dbContext);
                var helper = new BudgetClassManagerHelper(manager, DbSetupHelper);

                action.Invoke(helper);
            }
        }
    }
}
Importante

Uno de los aspectos más interesantes de estas pruebas, es que logramos un nivel de estandarización, con el que podemos generar con MDA los bloques básicos de pruebas, que luego nos ayuden a desarrollar más rápidamente especificaciones ejecutables con SpecFlow para usar el enfoque BDD como pruebas de aceptación.

C-4 - Ejecución de las pruebas

Después de compilar la solución, se debe ver todas las pruebas en el explorador de pruebas ([Menú Principal > TEST > Windows > Test Explorer]):

Pruebas de integración con xUnit y Entity Framework Core /posts/images/devenv_2017-06-16_16-27-11.png

Y se deben ejecutar todas (Run All) correctamente:

Pruebas de integración con xUnit y Entity Framework Core /posts/images/devenv_2017-06-15_19-49-29.png

Y si consultamos la base de datos que se creó usando el string de conexión de la clase de pruebas BudgetClassManager_IntegrationTests, deberíamos ver esto:

Pruebas de integración con xUnit y Entity Framework Core /posts/images/Ssms_2017-06-16_16-35-52.png
Importante

Uno de los aspectos más interesantes de esta forma de trabajo es que no nos hemos tenido que ocupar de la base de datos, aparte del string de conexión a utilizar.

Al correr las pruebas se va a crear automáticamente la base de datos y siempre va a tener los mismos datos y al final de las pruebas siempre va a quedar en el mismo estado (si todo va bien), así que cuando tengamos algún problema será mucho más fácil hacer el diagnóstico.

C-5 - Repetición de las pruebas

Las pruebas se pueden ejecutar tantas veces como se quiera y siempre darán los mismos resultados, con la única diferencia de los Id de algunos registros que cambiarán con cada ejecución, por la eliminación de algunos registros en las secciones de Arrange de las pruebas.

En caso necesario, se puede eliminar por completo la base de datos como se indica a continuación, porque se creará de nuevo automáticamente al ejecutar las pruebas:

Pruebas de integración con xUnit y Entity Framework Core /posts/images/2017-06-16_16-42-31.png
Pruebas de integración con xUnit y Entity Framework Core /posts/images/Ssms_2017-06-16_16-44-53.png

Resumen

En este artículo trabajamos le desarrollo de pruebas básicas de integración trabajando con xUnit y Entity Framework Core y construimos las bases para otros escenarios más complejos e interesantes que exploraremos más adelante.

También aprendimos algunos detalles importantes sobre el trabajo y las pruebas con los DbContext.


Espero que este artículo le haya resultado útil y le invito a darme su opinión en la sección de comentarios.

Gracias,

Miguel.

Enlaces relacionados

Action
https://msdn.microsoft.com/en-us/library/018hxwa8(v=vs.110).aspx

AutoFac
https://autofac.org/

BDD
https://en.wikipedia.org/wiki/Behavior-driven_development

DbContext Life cycle
https://msdn.microsoft.com/en-us/library/jj729737(v=vs.113).aspx#Anchor_1

DbContext
https://docs.microsoft.com/en-us/ef/core/api/microsoft.entityframeworkcore.dbcontext

Entity Framework Core
https://docs.microsoft.com/en-us/ef/core/index

FluentAssertions
http://fluentassertions.com/

Kent Beck
https://en.wikipedia.org/wiki/Kent_Beck

Kent Beck, sobre TDD en Stack Overflow
https://stackoverflow.com/questions/153234/how-deep-are-your-unit-tests#answer-153565

MDA
https://en.wikipedia.org/wiki/Model-driven_architecture

Patrón de repositorio
https://martinfowler.com/eaaCatalog/repository.html

Patrón Mapper
https://martinfowler.com/eaaCatalog/mapper.html

Scott Allen en .Net Rocks
http://www.dotnetrocks.com/?show=1405

Scott Allen
http://odetocode.com/about/scott-allen

SpecFlow
http://specflow.org/

TDD
https://en.wikipedia.org/wiki/Test-driven_development

xUnit
https://xunit.github.io/