Un test tiene tres partes, que se identifican con las siglas AAA en inglés: Arrange (Preparar), Act (Actuar), Assert (Afirmar).
Una parte de la preparación puede estar contenida en el método SetUp
, si es común a todos los tests de la clase. Si la estapa de preparación es común a varios tests de la clase pero no a todos, entonces podemos definir otro método o función en la misma clase, que aúne tal código. No le pondremos la etiqueta de test sino que lo invocaremos desde cada punto en que lo necesitemos.
El acto consiste en hacer la llamada al código que queremos probar (SUT) y la afirmación o afirmaciones se hacen sobre el resultado de la ejecución, bien mediante validación del estado o bien mediante validación de la interacción. Se afirma que nuestras expectativas sobre el resultado se cumplen. Si no se cumplen el framework marcará en rojo cada falsa expectativa.
Veamos varios ejemplos en lenguaje C# con el framework NUnit. Tendremos que crear un nuevo proyecto de tipo librería con el IDE (VisualStudio, SharpDevelop, MonoDevelop...) e incorporar la DLL nunit.framework
a las referencias para disponer de su funcionalidad.
Lo ideal es tener el SUT en una DLL y sus tests en otra. Así en el proyecto que contiene los tests, también se incluye la referencia a la DLL o ejecutable (.exe) del SUT, además de a NUnit. Estos ejemplos realizan la validación a través del estado. El estado del que hablamos es el de algún tipo de variable, no hablamos del estado del sistema. Recordemos que un test unitario no puede modificar el estado del sistema y que todos los tests de este capítulo son unitarios.
```csharp using System; using NUnit.Framework;
namespace EjemplosNUnit { [TestFixture] public class NameNormalizerTests { [Test] public void FirstLetterUpperCase() { // Arrange string name = "pablorodriguez"; NameNormalizer normalizer = new NameNormalizer();
// Act string result = normalizer.FirstLetterUpperCase(name);
// Assert Assert.AreEqual("PabloRodriguez", result); } }
Hemos indicado a NUnit que la clase es un conjunto de tests (test case), utilizando para ello el atributo TestFixture
. El que la palabra fixture aparezca aquí, puede ser desconcertante, sería más claro si el atributo se llamase TestCase
.
El nombre que le hemos puesto a la clase describe el conjunto de los tests que va a contener. Debemos utilizar conjuntos de tests distintos para probar grupos de funcionalidad distinta o lo que es lo mismo: no se deben incluir todos los tests de toda la aplicación en un solo conjunto de tests (una sola clase).
En nuestro ejemplo la clase contiene un único test que está marcado con el atributo Test
. Los tests siempre son de tipo void
y sin parámetros de entrada. El nombre del test es largo porque es autoexplicativo. Es la mejor forma de documentarlo. Poner un comentario de cabecera al test, es un antipatrón porque vamos a terminar con un gran número de tests y el esfuerzo de mantener todos sus comentarios es muy elevado.
De hecho es un error que el comentario no coincida con lo que hace el código y eso pasa cuando modificamos el código después de haber escrito el comentario. No importa que el nombre del método tenga cincuenta letras, no le hace daño a nadie. Si no sabemos cómo resumir lo que hace el test en menos de setenta letras, entonces lo más probable es que tampoco sepamos qué test vamos a escribir, qué misión cumple. Es una forma de detectar un mal diseño, bien del testo bien del SUT. A veces cuando un código requiere documentación es porque no está lo suficientemente claro.
En el cuerpo del test aparecen sus tres partes delimitadas con comentarios. En la práctica nunca delimitamos con comentarios, aquí está escrito meramente con fines docentes. La finalidad del test del ejemplo es comprobar que el método FirstLetterUpperCase
de la clase NameNormalizer
es capaz de poner en mayúscula la primera letra de cada palabra en una frase.
Es un test de validación de estado porque hacemos la afirmación de que funciona basándonos en el estado de una variable. Assert
en inglés viene a significar afirmar. La última línea dice: Afirma que la variable result
es igual a "Pablo Rodriguez".
Cuando NUnit ejecute el método, dará positivo si la afirmación es cierta o negativo si no lo es. Al positivo le llamamos luz verde porque es el color que emplea la interfaz gráfica de NUnit o simplemente decimos que el test pasa. Al resultado negativo le llamamos luz roja o bien decimos que el test no pasa.
Imaginemos que el código del SUT ya está implementado y el test da luz verde. Pasemos al siguiente ejemplo recalcando que todavía no estamos practicando TDD, sino simplemente explicando el funcionamiento de un framework xUnit.
csharp
[Test]
public void SurnameFirst()
{
string name = "gonzaloaller";
NameNormalizer normalizer = new NameNormalizer();
string result = normalizer.SurnameFirst(name);
Assert.AreEqual("aller,gonzalo", result);
}
Es otro test de validación de estado que creamos dentro del mismo conjunto de tests porque el SUT es un método de la misma clase que antes. Lo que el test comprueba es que el método SurnameFirst
es capaz de recibir un nombre completo y devolver el apellido por delante, separado por una coma. Si nos fijamos bien vemos que la declaración de la variable normalizeres
idéntica en ambos tests. A fin de eliminar código duplicado la movemos hacia el SetUp
.
El conjunto queda de la siguiente manera:
```csharp namespace EjemplosNUnit { [TestFixture] public class NameNormalizerTests { NameNormalizer _normalizer;
[SetUp] public void SetUp() { _normalizer = newNameNormalizer(); }
[Test] public void FirstLetterUpperCase() { string name = "pablorodriguez"; string result = _normalizer.FirstLetterUpperCase(name); Assert.AreEqual("PabloRodriguez", result); }
[Test] public void SurnameFirst() { string name = "gonzaloaller"; string result = _normalizer.SurnameFirst(name); Assert.AreEqual("aller,gonzalo", result); } }
Antes de cada uno de los dos tests el framework invocará al método SetUp
recordándonos que cada prueba es independiente de las demás.
Hemos definido _normalizer
como un miembro privado del conjunto de tests. El guión bajo (underscore) que da comienzo al nombre de la variable, es una regla de estilo que nos ayuda a identificarla rápidamente como variable de la clase en cualquier parte del código.
El método SetUp
crea una nueva instancia de dicha variable asegurándonos que entre la ejecución de un test y la de otro, se destruye y se vuelve a crear, evitando efectos colaterales. Por tanto lo que un test haga con la variable_normalizerno afecta a ningún otro. Podríamos haber extraído también la variable name
de los tests pero como no se usa nada más que para alimentar al SUT y no interviene en la fase de afirmación, lo mejor es liquidarla:
```csharp namespace EjemplosNUnit { [TestFixture] public class NameNormalizerTests { NameNormalizer _normalizer;
[SetUp] public void SetUp() { _normalizer = newNameNormalizer(); }
[Test] public void FirstLetterUpperCase() { string result = _normalizer.FirstLetterUpperCase("pablorodriguez"); Assert.AreEqual("PabloRodriguez", result); }
[Test] public void SurnameFirst() { string result = _normalizer.SurnameFirst("gonzaloaller"); Assert.AreEqual("aller,gonzalo", result); } }
Nos está quedando un conjunto de tests tan bonito como los muebles que hacen en el programa de Bricomanía de la televisión. No hemos definido métodotearDownporque no hay nada que limpiar explícitamente. El recolector de basura es capaz de liberar la memoria que hemos reservado; no hemos dejado ninguna referencia muerta por el camino.
La validación de estado generalmente no tiene mayor complicación, salvo que la ejecución del SUT implique cambios en el sistema y tengamos que evitarlos para respetar las cláusulas que definen un test unitario. Veremos ejemplos en los próximos capítulos.
Continuemos con la validación de excepciones. Se considera validación de comportamiento, pues no se valida estado ni tampoco interacción entre colaboradores. Supongamos que a cualquiera de las funciones anteriores, pasamos como parámetro una cadena vacía. Para tal entrada queremos que el SUT lance una excepción de tipo EmptyNameException
definida por nuestra propia aplicación. ¿Cómo escribimos esta prueba con NUnit?
csharp
[Test]
public void ThrowOnEmptyName()
{
try
{
_normalizer.SurnameFirst("");
Assert.Fail("Exceptionshouldbethrown");
}
catch(EmptyNameException){}
}
Cuando un test se ejecuta sin que una excepción lo aborte, éste pasa, aunque no haya ninguna afirmación. Es decir, cuando no hay afirmaciones y ninguna excepción interrumpe el test, se considera que funciona.
En el ejemplo, esperamos que al invocar a SurnameFirst
, el SUT lance una excepción de tipo concreto. Por eso colocamos un bloque catch
, para que el test no se interrumpa. Dentro de ese bloque no hay nada, así que la ejecución del test termina. Entonces se considera que el SUT se comporta como deseamos.
Si por el contrario la ejecución del SUT termina y no se ha lanzado la excepción esperada, la llamada a Assert.Fail
abortaría el test explícitamente señalando luz roja. Se puede escribir el mismo test ayudándonos de atributos especiales que tanto NUnit como JUnit (en este caso son anotaciones) incluyen.
csharp
[Test]
[ExpectedException("EmptyNameException",
ExpectedMessage="Thenamecannotbeempty" )]
public void ThrowOnEmptyName()
{
_normalizer.SurnameFirst("");
}
El funcionamiento y significado de los dos últimos tests es exactamente el mismo. Cuando se quiere probar que se lanza una excepción, se debe expresar exactamente cuál es el tipo de la excepción esperada y no capturar la excepción genérica (System.Exception en .Net).
Si usamos la excepción genérica, estaremos escondiendo posibles fallos del SUT,
excepciones inesperadas. Además, en el caso de PyUnit la llamada a fail
no sólo detiene el test marcándolo en rojo sino que además lanza una excepción, con lo cual el bloque catch
la captura y obtenemos un resultado totalmente confuso. Creeríamos que el SUT está lanzando la excepción cuando en realidad no lo hace pero no lo advertiríamos.
Llega el turno de la validación de interacción. La validación de interacción es el recurso que usamos cuando no es posible hacer validación de estado. Es un tipo de validación de comportamiento. Es recomendable recurrir a esta técnica lo menos posible, porque los tests que validan interacción necesitan conocer cómo funciona por dentro el SUT y por tanto son más frágiles. La mayoría de las veces, se puede validar estado aunque no sea evidente a simple vista. Quizás tengamos que consultarlo a través de alguna propiedad del SUT en vez de limitarnos a un valor devuelto por una función. Si el método a prueba es de tipo void
, no se puede afirmar sobre el resultado pero es posible que algún otro miembro de la clase refleje un cambio de estado.
El caso de validación de interacción más común es el de una colaboración que implica alteraciones en el sistema. Elementos que modifican el estado del sistema son por ejemplo las clases que acceden a la base de datos o que envían mensajes a través de un servicio web (u otra comunicación que salga fuera del dominio de nuestra aplicación) o que crean datos en un sistema de ficheros.
Cuando el SUT debe colaborar con una clase que guarda en base de datos, tenemos que validar que la interacción entre ambas partes se produce y al mismo tiempo evitar que realmente se acceda a la base de datos. No queremos probar toda la cadena desde el SUT hacia abajo hasta el sistema de almacenamiento.
El test unitario pretende probar exclusivamente el SUT. Tratamos de aislarlo todo lo posible. Luego ya habrá un test de integración que se encargue de verificar el acceso a base de datos.
Para llevar a cabo este tipo de validaciones es fundamental la inyección de dependencias. Si los miembros de la colaboración no se han definido con la posibilidad de inyectar uno en el otro, difícilmente podremos conseguir respetar las reglas de los tests unitarios. Con lenguajes interpretados como Python, es posible pero el código del test termina siendo complejo de entender y mantener, es sólo una opción temporal.
Un ejemplo vale más que las palabras, así que imaginemos un sistema que gestiona el expediente académico de los alumnos de un centro de formación. Hay una función que dado el identificador de un alumno (su número de expediente) y la nota de un examen, actualiza su perfil en base de datos. Supongamos que existen los objetos relacionales StudentyScore
y una clase DataManager
capaz de recuperarlos y guardarlos en base de datos. El SUT se llama ScoreManager
y su colaborador será DataManager
, que implementa la interfaz IDataManager
.
Las fases de preparación y acción las sabemos escribir ya:
csharp
[Test]
public voidAddStudentScore()
{
ScoreManager smanager =new ScoreManager();
smanager.AddScore("23145", 8.5);
}
Pero el método AddScore
no devuelve nada y además estará actualizando la base de datos. ¿Cómo validamos? Lo primero es hacer que el colaborador de ScoreManager
se pueda inyectar:
```csharp [Test] public voidAddStudentScore() { IDataManager dataManager = newDataManager(); ScoreManager smanager = newScoreManager(dataManager);
smanager.AddScore("23145", 8.5);
La validación de la interacción se hace con frameworks de objetos mock, como muestra el siguiente capítulo pero para comprender parte de lo que hacen internamente los mocks y resolver este test sin ninguna otra herramienta externa, vamos a implementar la solución manualmente. Si el SUT va a invocar a su colaborador para leer de la base de datos, operar y guardar, podemos inyectar una instancia que se haga pasar por buena pero que en verdad no acceda a tal base de datos.
```csharp public classMockDataManager : IDataManager { publicIRelationalObject GetByKey(string key) { return newStudent(); }
public voidSave(IRelationalObject robject) {}
public voidCreate(IRelationalObject robject) {}
La interfaz IDataManagertiene
tres métodos; uno para obtener un objeto relacional dada su clave primaria, otro para guardarlo cuando se ha modificado y el último para crearlo en base de datos por primera vez. Actualizamos el test:
```csharp [Test] public voidAddStudentScore() { MockDataManager dataManager = newMockDataManager(); ScoreManager smanager = newScoreManager(dataManager);
smanager.AddScore("23145", 8.5);
Vale, ya no accederá al sistema de almacenamiento porque la clase no implementa nada ¿pero cómo validamos que el SUT intenta hacerlo? Al fin y al cabo la misión de nuestro SUT no es meramente operar sino también coordinar el registro de datos. Tendremos que añadir algunas variables de estado internas para controlarlo:
```csharp public class MockDataManager : IDataManager { private bool _getKeyCalled = false; private bool _saveCalled = false;
publicIRelationalObject GetByKey(string key) { _getKeyCalled =true; return newStudent(); }
public void Save(IRelationalObject robject) { _saveCalled = true; }
public void VerifyCalls() { if(!_saveCalled) throw Exception("Savemethodwasnotcalled"); if(!_getKeyCalled) throw Exception("GetByKeymethodwasnotcalled"); }
public void Create(IRelationalObject robject) {}
Ya podemos hacer afirmaciones (en este caso verificaciones) sobre el resultado de la ejecución:
```csharp [Test] public void AddStudentScore() { MockDataManager dataManager = new MockDataManager(); ScoreManager smanager = new ScoreManager(dataManager);
smanager.AddScore("23145", 8.5);
dataManager.VerifyCalls();
No hemos tocado la base de datos y estamos validando que el SUT hace esas dos llamadas a su colaborador. Sin embargo, la solución propuesta es costosa de implementar y no contiene toda la información que necesitamos (¿cómo sabemos que el dato que se salvó era el correcto?). En el siguiente capítulo veremos una solución alternativa, los mocks generador por frameworks, que nos permitirá definir afirmaciones certeras basadas en expectativas con todo lujo de detalles.
Como lectura adicional recomiendo el libro de J.B Rainsberg. Además el blog y los vídeos de las conferencias de este autor son una joya.