Test de aprobación con ApprovalTests. Windows Forms, WPF, ASP y más

¡Hola!
En la anterior entrada dimos un repaso a algunos de los principales Reporters disponibles. Hoy vamos a echar un vistazo a qué cosas/tecnologías podemos testear con ApprovalTests, cómo se hace y si realmente merece la pena.

WinForms

Lo vamos a utilizar para aprobar el aspecto con el que se muestra un formulario Windows Forms. Para visualizar el resultado, en mi caso, voy a utilizar TortoiseIDiff. En cualquier caso utilizando el Reporter DiffReporter se escogerá automáticamente la mejor herramienta que tengas para verificar el resultado. Vemos un ejemplo de test:

1
2
3
4
5
6
7
8
9
10
11
12
13
    [UseReporter(typeof(DiffReporter))]
    public class ApprovalBasicTest
    {
        [Fact]
        public void PersonFormTest()
        {
            PersonForm form = new PersonForm();
            var person = this.GivenAPerson();
            form.SetPerson(person);
 
            WinFormsApprovals.Verify(form);        }
    }

En el test anterior estamos utilizando un verificador para formularios, en concreto WinFormsApprovals. Cuando hacemos WinFormsApprovals.Verify(form), se va a encargar de hacer una captura de pantalla al formulario y compararlo con la anterior captura que haya almacenada (la última que hayamos marcado como correcta). La captura la compara de una forma muy sencilla, simplemente con las rutas de los dos archivos (el recibido y el aprobado) obtiene los bytes con File.ReadAllBytes(path) y comprueba el resultado. Si por ejemplo el formulario ha cambiado, el test no pasaría y se lanzaría el resultado con el Reporter que tengamos configurado. Por ejemplo con TortoiseIDiff, se vería lo siguiente:

En la parte izquierda vemos el resultado recibido y en la derecha el último resultado que marcamos como correcto. En este caso la diferencia es que el cuadro de texto de la edad es más pequeño en la imagen de la izquierda. El siguiente paso sería decidir si el nuevo resultado es o no correcto.
Parece una opción razonable para verificar de forma sencilla formularios, pero tampoco es perfecta. Si nuestro formulario se expandiese al iniciarse (WindowState = FormWindowState.Maximized), la captura que se genere va a depender de la resolución de pantalla del equipo donde se ejecute el test, o si se ejecuta en diferentes sistemas operativos, también puede cambiar la captura que se genere. Todas estos detalles no los podemos perder de vista.
Dentro de WinFormsApprovals podemos comprobar un par de cosas más. La primera es, en vez de formularios, podemos verificar el aspecto de controles:

1
2
3
4
5
6
7
8
9
10
11
    [UseReporter(typeof(DiffReporter))]
    public class ApprovalBasicTest
    {
        [Fact]
        public void SavedButtonControlTest()
        {
            Control savedButton = new SavedButton();
 
            WinFormsApprovals.Verify(savedButton);        }
    }

Funciona igual que los formularios, pero podemos comprobar en este caso unidades más pequeñas, como son los controles.
La última opción que nos proporciona WinFormsApprovals es a través del método VerifyEventsFor, que nos proporciona la opción de aprobar los eventos disponibles para un formulario:

1
2
3
4
5
6
7
8
9
10
11
12
13
    [UseReporter(typeof(DiffReporter))]
    public class ApprovalBasicTest
    {
        [Fact]
        public void EventsFromPersonFormTest()
        {
            PersonForm form = new PersonForm();
            var person = this.GivenAPerson();
            form.SetPerson(person);
 
            WinFormsApprovals.VerifyEventsFor(form);        }
    }

¿Qué hace este verificador? Pues se va a encargar de buscar todos los eventos que haya disponibles en el formulario y/o en sus controles y genera un string con los datos del evento. Por ejemplo, en el formulario anterior se utiliza únicamente el evento click del botón guardar y la primera vez que ejecuto el test anterior se mostraría el siguiente texto al reportar el error:

Como vemos en la captura anterior muestra datos informativos del evento.
¿Verificar los formularios de esta manera es útil? Quizá sea un poco excesivo que cada vez que cambio un evento o añado algo en el formulario rompa todos estos tests, o no.. lo que sí, me parece que su utilidad no es tanto en etapas iniciales del desarrollo del formulario, sino que son más útiles cuando el formulario ya está acabado y queremos poner una alerta para que cuando alguien lo cambie se dé cuenta de que tiene que validar el resultado generado.

WPF

Básicamente se van a verificar las pantallas/controles WPF de la misma forma que los Windows Forms que vimos anteriormente. Por ejemplo, para verificar una pantalla WPF lo haríamos de la siguiente forma:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    [UseReporter(typeof(DiffReporter),typeof(ClipboardReporter))]
    [TestFixture]
    public class ApprovalBasicTest
    {
        [Test]
        [RequiresThread(ApartmentState.STA)]
        public void PersonWPFFormTest()
        {
            PersonWPFForm form = new PersonWPFForm();
            var person = this.GivenAPerson();
            form.SetPerson(person);
 
            WpfApprovals.Verify(form);        }
    }

Lo único que cambiamos con respecto a Windows Forms es que utilizamos WpfApprovals. Si has prestado algo de atención al código anterior verás que ha cambiado algo más, ahora mismo estoy utilizando NUnit y en los ejemplos anteriores utilizaba XUnit, además decoro el test con [RequiresThread(ApartmentState.STA)]. Esto es porque XUnit v2 no tiene ningún atributo equivalente y es necesario para lanzar la ventana WPF. Es muy posible que haya soluciones parciales o de terceros para poder hacerlo con XUnit pero no me apetecía buscarlas 🙂
Para verificar controles, muy similar también a Windows Forms:

1
2
3
4
5
6
7
8
9
10
11
12
13
    [UseReporter(typeof(DiffReporter),typeof(ClipboardReporter))]
    [TestFixture]
    public class ApprovalBasicTest
    {
        [Test]
        [RequiresThread(ApartmentState.STA)]
        public void SavedButtonControlTest()
        {
            var savedButton = new SavedWPFButton();
 
            WpfApprovals.Verify(savedButton);        }
    }

La opción que veíamos anteriormente para comprobar los eventos de los formularios no está disponible, aunque con WPF ya sabemos que deberíamos utilizar MVVM y en pocas ocasiones vamos a utilizar directamente los eventos de los controles. A cambio de no poder verificar eventos, tenemos disponible una herramienta que, aunque no es exclusiva de WPF, está íntimamente relacionada y es que vamos a poder comprobar que los Bindings que creemos sean correctos. Nos imaginamos que tenemos la siguiente View Model:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    public class PersonViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged = delegate { };
 
        public const string PersonNamePropertyName = "PersonPersonName";
        private string personName;
        public string PersonName
        {
            get { return this.personName; }
            set
            {
                this.personName = value;
                RaisePropertyChanged();
            }
        }
 
        private void RaisePropertyChanged([CallerMemberName] string propertyName = null)
        {
            this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }

Y queremos comprobar que el Binding que hacemos de la propiedad PersonName de nuestra vm a nuestra vista es correcta. Podemos crear el siguiente test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    [TestFixture]
    public class ApprovalBasicTest
    {
        [Test]
        [RequiresThread(ApartmentState.STA)]
        public void PersonViewModelBindingsTest()
        {
            var personViewModel = new PersonViewModel();
            Binding bindingPersonName = new Binding(PersonViewModel.PersonNamePropertyName)
            {
                Source = personViewModel
            };
            WpfBindingsAssert.BindsWithoutError(personViewModel, () =>
            {
                PersonWPFForm form = new PersonWPFForm();
                form.SetBinding(PersonWPFForm.PersonNameProperty, bindingPersonName);
                return form;
            });
        }
    }

Si ejecutamos el test la primera vez nos va a mostrar un error que nos dirá algo como esto:

  Just Save to file: wpf.reg and run
  ------------------------------------------------------
  Windows Registry Editor Version 5.00

  [HKEY_CURRENT_USER\Software\Microsoft\Tracing\WPF]
  ""ManagedTracing""=dword:00000001"

Lo que ocurre es que el Assert va a iniciar un TraceListener antes de lanzar la ventana WPF y se va a encargar de escuchar los errores DataBindingSource generados (más bien los warnings), y para hacer esto es necesario añadir la clave que indica al regitro:

Una vez configurada la clave si ejecutamos de nuevo el test, pasará correctamente. Ahora vamos a ejecutar el test anterior pero con un Binding incorrecto:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    [TestFixture]
    public class ApprovalBasicTest
    {
        [Test]
        [RequiresThread(ApartmentState.STA)]
        public void PersonViewModelBindingsTest()
        {
            var personViewModel = new PersonViewModel();
            Binding bindingPersonName = new Binding("FakePropertyName")            {
                Source = personViewModel
            };
            WpfBindingsAssert.BindsWithoutError(personViewModel, () =>
            {
                PersonWPFForm form = new PersonWPFForm();
                form.SetBinding(PersonWPFForm.PersonNameProperty, bindingPersonName);
                return form;
            });
        }
    }

En la línea 9 vemos que estamos haciendo un Binding a una propiedad que no existe (FakeProperty), por lo que el Assert (WpfBindingsAssert.BindsWithoutError) nos va a generar una excepción como la siguiente:

System.Exception : System.Windows.Data Error: 40 : BindingExpression path error: 'FakePropertyName' property not found on 'object' ''PersonViewModel' (HashCode=63292936)'.
	BindingExpression:Path=FakeProperty
	 DataItem='PersonViewModel' (HashCode=63292936)
	 target element is 'PersonWPFForm' (Name='')
	 target property is 'PersonPersonName' (type 'String')

Nos indica que hay un error en el Binding que hemos testeado. De todas maneras esto realmente no es un test de aprobación como los que hemos visto, puesto que no estamos generando ningún reporte, sino que más bien es un Assert normal. Testear todos los Binding quizá me pueda volver a parecer bastante exagerado, además si utilizamos la característica de C# 6 nameof(), seguramente evitaremos errores en los Bindings. Por otro lado, tampoco es muy habitual, por lo menos en mi caso, generar los Bindings programáticamente, más bien los suelo crear directamente en XAML, aunque desde que trabajo a diario con Xamarin Forms me estoy empezando a plantear si no es mejor definir toda la vista programáticamente por aquello de los errores en tiempo de compilación y poder refactorizar muchísimo mejor, pero bueno, eso es otro tema. Por último, siendo sinceros, el test no es precisamente muy bonito y tiene pinta de que si hacemos cambios grandes en nuestras vm o controles/vistas nos va a tocar modificar un montón de test, aunque también hay que decir que por muy feo que nos pueda parecer el test, si realmente creemos que es útil, no debería ser un motivo que nos impida hacerlo.

ASP

Vamos a partir de una página Asp clásica que muestra los datos de una persona:

El Code-Behind de la página es el siguiente:

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
    public partial class PersonPage : System.Web.UI.Page
    {
        private Person person;
        public Person Person
        {
            get { return person; }
            set
            {
                person = value;
                this.textboxName.Text = person.Name;
                this.textboxSurname.Text = string.Concat(person.Surname1, " ", person.Surname2);
                this.textboxAge.Text = person.Age.ToString();
            }
        }
 
        protected void Page_Load(object sender, EventArgs e)
        {
            var personId = Page.ClientQueryString;
            LoadPersonFromDatabase(personId);
        }
 
        private void LoadPersonFromDatabase(string personId)
        {
            this.Person = new Person("Pepito database", "Pérez", "González", 23);
        }
 
    }

Lo que hacemos es, al cargar la página obtenemos la consulta (que sería un id de persona) y lo cargamos desde lo que sería típicamente la base de datos. En este ejemplo simplemente creamos una persona nueva sin ir a base de datos a por el dato.
¿Cuál sería la forma de poder testear esta página con ApprovalTests? La propuesta de ApprovalTests para poder testearla sería crear un método público en el Code-Behind para crear una persona sin necesidad de ir a base de datos a por él y utilizarlo desde el test.

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
    public partial class PersonPage : System.Web.UI.Page
    {
        private Person person;
        public Person Person
        {
            get { return person; }
            set
            {
                person = value;
                this.textboxName.Text = person.Name;
                this.textboxSurname.Text = string.Concat(person.Surname1, " ", person.Surname2);
                this.textboxAge.Text = person.Age.ToString();
            }
        }
 
        protected void Page_Load(object sender, EventArgs e)
        {
            if (AspTestingUtils.DivertTestCall(this))            {                return;            }            var personId = Page.ClientQueryString;
            LoadPersonFromDatabase(personId);
        }
 
        private void LoadPersonFromDatabase(string personId)
        {
            this.Person = new Person("Pepito database", "Pérez", "González", 23);
        }
 
        public void TestLoadDummyPerson()        {            this.Person = new Person("Juanito TEST", "Rodríguez", "López", 50);        }    }

Aquí hay un par de detalles que comentar. El primero, si nos fijamos el método que TestLoadDummyPerson creado (que será el que utilicemos a continuación en el test) empieza por la palabra Test, esto no es casualidad sino que es necesario porque, y aquí viene el segundo detalle, hemos añadido en la carga de la página (el evento Page_Load) una cláusula que hace que cuando se ejecute el test no se llegue a cargar el dato de base de datos. La línea 18 (AspTestingUtils.DivertTestCall(this)) es una utilidad de ApprovalTests que va a comprobar si la query de la url empieza por el texto Test y, en caso de que sea cierto, va a coger la query y mediante reflection buscará un método dentro de la página que tenga ese nombre y lo invocará. Con una prueba lo vamos a ver más claro, si a la query de la url le añadimos la cadena TestLoadDummyPerson

¡Premio! Como vemos, utiliza el método TestLoadDummyPerson… qué mala pinta.. Vamos a ver cómo crear el test.

1
2
3
4
5
6
7
8
9
10
    [UseReporter(typeof(FileLauncherReporter))]
    public class ApprovalBasicTest
    {
        [Fact]
        public void PersonPageTest()
        {
            ApprovalTests.Asp.PortFactory.AspPort = 64123;
            ApprovalTests.Asp.AspApprovals.VerifyAspPage(new PersonPage().TestLoadDummyPerson);
        }
    }

Asumiendo que tenemos el servidor levantado en localhost, establecemos el puerto (en mi caso el 64123) y utilizamos el método VerifyAspPage al que le pasaremos la página y el método con nuestros datos de test. Se va a encargar de crear la url, inicializar un WebClient y descargar el contenido. Esto me va a mostrar un archivo html que será el que tendremos que aprobar:

Si utilizásemos TortoiseDiffReporter lo veríamos así:

Como vemos, no tiene muy buen aspecto el testear páginas Asp con ApprovalTests. Por un lado tenemos que tener el servidor disponible, luego yo creo que no los podríamos considerar test unitarios, aunque esto no es ni bueno ni malo. Lo que sí es malo es añadir código a nuestra aplicación exclusivamente para los test, y esto sí lo estamos haciendo, estamos incluyendo métodos y otras artimañas en nuestra página con el único fin de que nuestro framework de test pueda testearlas. Ya no es que estemos añadiendo cosas para poder testearlas en general, sino que es para poder testearlas con una framework de test en concreto. Claro que podemos barrer un poco debajo de la alfombra y utilizar clases parciales para tener en un archivo diferente las cosas necesarias para el test, incluso añadir la directiva #if DEBUG para que ese código no esté en Release, pero estoy seguro casi seguro de que no nos debemos permitir hacer esto.
Testear páginas ASP MVC es muy parecido, dentro de las diferencias entre ASP.NET y MVC claro, el resumen de testear MVC es igual que en ASP.NET, ensuciar el código con cosas de los test.

MailMessage

Otra de las posibilidades que nos proporciona ApprovalTests es poder verificar emails de forma bastante sencilla. En el siguiente test vemos cómo poder testearlo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    [UseReporter(typeof(FileLauncherReporter))]
    public class ApprovalBasicTest
    {
        [Fact]
        public void MailMessageTest()
        {
            var message = new MailMessage()
            {
                To = {"paco@outlook.com"},
                From = new MailAddress("luis@outlook.com", "Luis"),
                Subject = "ApprovalTests mail",
                Body = "Esto es un ejemplo de test de mail con ApprovalTests. ¡Un saludo!"
            };
 
            EmailApprovals.Verify(message);        }
    }

Simplemente estamos generando un MailMessage (aunque no lo deberíamos crear en el test, claro) y utilizamos el verificador disponible en EmailApprovals. La clase la estamos decorando con el reporter FileLauncherReporter porque el archivo que se va a generar es de tipo .eml y, puesto que tengo instalado outlook, me va a abrir la aplicación para mostrar el resultado en caso de que el test falle:

También podemos verificar un MailMessage con un archivo adjunto o con AlternateViews por ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    [UseReporter(typeof(TortoiseDiffReporter))]
    public class ApprovalBasicTest
    {
        [Fact]
        public void MailMessageWithAttachmentTest()
        {
            var message = new MailMessage()
            {
                To = { "paco@outlook.com" },
                From = new MailAddress("luis@outlook.com", "Luis"),
                Subject = "ApprovalTests mail",
                Body = "Esto es un ejemplo de test de mail con ApprovalTests con un adjunto. ¡Un saludo!",
                Attachments = { new Attachment("Image.jpg")}            };
 
            EmailApprovals.Verify(message);
        }
    }

En esta ocasión estoy utilizando TortoiseDiffReporter como reporter:

Vemos que es bastante simple y efectivo verificar emails de esta forma, el único pero es que estamos muy acoplados a MailMessage, pero en este caso parece bastante razonable porque es lo que se suele utilizar para mandar mails (o eso creo).

PDF

En el caso de los pdf no he conseguido utilizarlos de la forma que quería. En principio utilizando iTextSharp intentaba crear un pdf de prueba y comparalo con el pdf aprobado anteriormente. Mi test era algo así:

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
    [UseReporter(typeof(FileLauncherReporter), typeof(ClipboardReporter))]
    public class ApprovalBasicTest
    {
        [Fact]
        public void PDFTest()
        {
            var pdfReportPath = CreatePdfReportFile();
            Approvals.VerifyPdfFile(pdfReportPath);        }
 
        private string CreatePdfReportFile()
        {
            System.IO.FileStream fs = new FileStream("Reporte.pdf", FileMode.Create);
            Document document = new Document(PageSize.A4, 25, 25, 30, 30);
            PdfWriter writer = PdfWriter.GetInstance(document, fs);
            document.AddAuthor("Luis M");
            document.AddTitle("ApprovalsTests");
            document.Open();
            document.Add(new Paragraph("Reporte de prueba para ApprovalsTests"));
            document.Close();
            writer.Close();
            fs.Close();
            return Path.Combine(Directory.GetCurrentDirectory(), "Reporte.pdf");
        }
    }

El problema viene una vez que he aprobado el pdf y vuelvo a pasar el test, sigue en rojo, es decir, encuentra diferencias entre el pdf que aprobé anteriormente y el nuevo. Echando un vistazo a lo que hace VerifyPdfFile, se supone que busca la fecha de creación del pdf y si la encuentra la modifica a una fija, de esta manera la fecha de creación se supone que no debería influir en la validación de los pdf. Estuve depurando un poco el código de la verificación y creo que no estaba logrando encontrar la fecha de creación del pdf, por lo que el pdf que genero en cada test siempre es distinto del anterior. Probando con un pdf generado con Word tampoco me la está detectando para poder modificarla.. Además, abriendo con el bloc de notas dos pdfs aparentemente iguales pero creados en dos momentos diferentes, tenían una línea diferente:

<<79f3ef76caff11e63e30d1bad2742925>]>>
<<9d0b1415300464d7e1ac6c701b4e8c2e>]>>

Cierto es que nunca había utilizado iTextSharp y poca experiencia tengo en la creación de pdfs, así que es posible que esto tenga solución 🙂

Y más

Hay bastantes más cosas que se pueden verificar con ApprovalsTest. Podríamos verificar archivos XML:

1
2
3
4
5
    [Fact]
    public void XMLTest()
    {
        Approvals.VerifyXml("file.xml");
    }

También hay cosas relacionadas con el testeo de bases de datos. Por ejemplo, poder testear sentencias SQL generadas por Entity Framework a través de un IQueryable:

1
2
3
4
5
6
7
8
    [Fact]
    public void EFIQueryableTest()
    {
        using (var db = new DummyContext())
        {
            EntityFrameworkApprovals.Verify(db, db.Persons.Where(e => e.Age >= 30));
        }
    }

Esto nos generaría un resultado así:

SELECT 
[Extent1].[Id] AS [Id], 
[Extent1].[Name] AS [Name], 
[Extent1].[Age] AS [Age], 
[Extent1].[Surname] AS [Surname]
FROM [dbo].[Persons] AS [Extent1]
WHERE [Extent1].[Age] >= 30

Se pueden verificar archivos html, reportes Rdlc, y más, pero eso ya os lo dejo a vosotros 🙂

¡Un saludo!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *