Exportar (0) Imprimir
Expandir todo
Expandir Minimizar

MockObjects y TDD en .Net Framework

25 de Julio de 2005

Publicado: Julio de 2005

Ricardo Mínguez

Este artículo aplica a

Resumen

El desarrollo guiado por pruebas es una práctica que está cambiando la forma en la que se desarrolla Software de calidad. Para poder aplicarla correctamente es necesario el dominio de los MockObjects para aislarse de las dependencias externas.

Nota

Descargue el ejemplo de código de este artículo

En esta página

Introducción Introducción
Desarrollo Guiado Por Pruebas  Desarrollo Guiado Por Pruebas
La Necesidad de Simuladores  La Necesidad de Simuladores
Pruebas Unitarias y Simulación de objetos  Pruebas Unitarias y Simulación de objetos
Inversión del Control  Inversión del Control
Ejemplo. Como probar el envío de correo electrónico  Ejemplo. Como probar el envío de correo electrónico
Diseño orientado a pruebas  Diseño orientado a pruebas
Simulación manual  Simulación manual
MockObjects  MockObjects
Estructura de las Pruebas  Estructura de las Pruebas
Pasar la primera prueba  Pasar la primera prueba
Refactoring  Refactoring
Las pruebas de integración  Las pruebas de integración
Conclusiones  Conclusiones
Referencias Referencias
Acerca del autor Acerca del autor

Introducción

La programación extrema (eXtremeProgramming) ha definido una serie de prácticas que están cambiando la forma habitual en la que se desarrolla software. Gracias a una buena estrategia de pruebas se puede incrementar la productividad a la vez que se mejora el control sobre los programas que se construyen.

Las prácticas más importantes de esta nueva metodología han dado lugar a una nueva técnica de diseño denominada Desarrollo Guiado por Pruebas TestDrivenDevelopment (a partir de ahora TDD). La aplicación de esta técnica en sistemas complejos requiere un diseño poco acoplado que permite aplicar el principio de responsabilidad a cada uno de los subsistemas para poder probarlos de forma unitaria. Como un subsistema siempre depende de otro es habitual recurrir al uso de simuladores que permitan suplantar los subsistemas de los que depende la funcionalidad a probar. .

Dentro del mundo de las pruebas unitarias, existe una forma estándar de simulación de objetos denominada Objetos Simulados MockObjects. Este artículo expone la importancia de los MockObjects para poder aplicar TDD, se analiza la necesidad de este tipo de objetos, las implicaciones que tiene en el diseño y se muestra con un ejemplo real como aplicar estas ideas.

El código de ejemplo comentado esta disponible para descargar. Depende de NUnit para la definición de las pruebas unitarias y de NMock para la definición de los objetos simulados.

Desarrollo Guiado Por Pruebas

El Desarrollo Guiado por Pruebas, es una técnica de programación (definida por KentBeck [1]); consiste en desarrollar primero el código que pruebe una característica o funcionalidad deseada antes del código que implementa dicha funcionalidad. El objetivo a lograr es por tanto, que no exista ninguna funcionalidad que no esté avalada por una prueba.

Para más información en TDD ver [2]

Lo primero que hay que aprender de TDD son las reglas básicas:

  • No añadir código sin escribir antes una prueba que falle

  • Eliminar el Código Duplicado empleando Refactoring

TDD invita a seguir una serie de tareas ordenadas, que a menudo se denomina ritmo TDD, y que se basa en los siguientes pasos:

  • Escribir una prueba que demuestre la necesidad de escribir código.

  • Escribir el mínimo código para que el código de pruebas compile

  • Implementar exclusivamente la funcionalidad demandada por las pruebas

  • Mejorar el código (Refactoring) sin añadir funcionalidad

  • Volver al primer paso

Este ritmo permite formalizar las tareas que se han de realizar para conseguir un código fácil de mantener, bien diseñado y que se puede probar automáticamente.

Estas reglas, son relativamente sencillas de conseguir cuando el código que tenemos que escribir no tiene muchas dependencias, sin embargo, en las aplicaciones empresariales se necesita integrar programas con diferentes componentes y subsistemas, complicando la forma de probar aisladamente las diferentes piezas que componen una solución real.

A continuación se explorarán algunas técnicas que permitan probar el código de forma unitaria, dando como resultado un diseño poco acoplado, y fácilmente extensible.

La Necesidad de Simuladores

Cada vez es más frecuente la necesidad de integrar funcionalidad expuesta por componentes y servicios que están fuera del alcance del desarrollo que queremos probar.

Las dependencias con sistemas externos afectan a la complejidad de la estrategia de pruebas, ya que es necesario contar con sustitutos de estos servicios externos durante el desarrollo. Ejemplos típicos de estas dependencias son Servicios Web, Sistemas de envío de correo, Fuentes de Datos o simplemente dispositivos hardware.

Estos sustitutos, muchas veces son exactamente iguales que el servicio original, pero en un entorno diferente al entorno de producción, es el caso de los entornos de desarrollo o de pruebas. En otros casos es frecuente encontrarse con simuladores que exponen el mismo interfaz pero realmente no realizan las mismas tareas que el sistema real, o las realizan contra un entorno controlado.

Para poder probar los escenarios más importantes, es necesario sincronizar el código de pruebas y el simulador de forma que desde la prueba se pueda saber a priori como será el comportamiento y así poder asegurar que el código que a probar se ha ejecutado en las condiciones esperadas.

Este modelo de pruebas es muy conveniente para las pruebas de integración o aceptación, pero no es válido para aplicar TDD. El ritmo TDD requiere apoyarse en unas pruebas unitarias que sean rápidas y fáciles de ejecutar en cualquier puesto de desarrollo. Los principales inconvenientes son:

  • Si las pruebas son lentas, dejaremos de ejecutarlas tan frecuentemente como necesitamos para poder concentrarnos en el Refactoring.

  • Si las pruebas dependen de un entorno completo, servicios, infraestructura de red, entorno de seguridad, va a ser muy costoso configurar todo lo necesario para que todos los miembros del equipo puedan ejecutar las pruebas.

  • Si el código de las pruebas depende del código del simulador, se va a complicar la comprensión del código de las pruebas, restando valor a la inversión que supone desarrollar las pruebas.

Pruebas Unitarias y Simulación de objetos

Otra alternativa para realizar la simulación, consiste en conseguir probar el código unitariamente, esto significa aislarse de todos los recursos externos, es decir no depender de la infraestructura de red, o de un determinado entorno, o incluso del proceso que ejecutará nuestro código cuando esté en producción. Cargaremos las pruebas y el código a probar en un proceso encargado de gestionar la ejecución de las pruebas.

Al conseguir aislarnos de todos estos elementos conseguimos el control necesario para poder aplicar TDD, y estar seguros de que no añadimos funcionalidad sin que exista una prueba previa que la demande.

Desde el punto de vista de una prueba unitaria, para saber que el código funciona bien, no necesitamos llamar al componente real, basta con crear un objeto que lleve la cuenta de las llamadas recibidas y más tarde comprobar que ha recibido las llamadas esperadas. Este objeto será el simulador, pero en vez de simular el comportamiento tan sólo simula el interfaz del componente externo.

Con esta aproximación se consiguen resolver los problemas comentados para aplicar TDD con simuladores tradicionales ya que el control de estos objetos se realiza desde la propia prueba eliminando las dependencias con un entorno real.

Sin embargo esta técnica no asegura que el sistema completo funciona correctamente, tan sólo asegura que el código que interactúa con el servicio externo no contiene errores, pero es necesario apoyarse en las pruebas de integración para asegurar que el sistema funciona.

Inversión del Control

Para poder emplear la técnica de simulación de objetos debemos diseñar el código a probar de forma que sea posible trabajar con los objetos que acceden al servicio real o con los objetos simulados.

El código a probar será invocado desde el programa final, en cuyo caso se utilizarán los objetos que nos dan acceso al servicio externo; desde las pruebas se ejecutarán los objetos que realizan la simulación.

Gracias a la orientación a objetos se puede definir un interfaz que cumplan tanto el objeto real como el objeto simulado, y hacer que el código a probar dependa tan sólo del interfaz que ambos implementan. Además se debe proveer de un mecanismo que permita elegir entre los dos objetos. Este patrón se denomina InversionOfControl (IoC) o DependencyInjectionPattern. [3]

Para poder definir con que objeto trabajar se suelen emplear dos técnicas, la primera consiste en definir dos constructores en la clase que depende del interfaz, el primero sin parámetros creará una instancia del objeto real, y será el que se use desde el programa principal, el segundo constructor aceptará como parámetro una instancia que cumpla con interfaz definido.

Un ejemplo usando el constructor para decidir que dependencia usar

public class ClassUnderTest 
{ 
IExternalService service; 
 
public ClassUnderTest(): this(new ExternalServiceAgent()) 
{ 
} 
 
public ClassUnderTest(IExternalService externalService) 
{ 
service = externalService; 
} 
}

La otra técnica requiere definir un método que realice la misma tarea que el constructor parametrizado. Este patrón es utilizado en diversos marcos de trabajo que definen lo que se denomina “Contenedores de Dependencias”, pero este tema se queda fuera del ámbito de este artículo. Para más información ver [3].

Ejemplo. Como probar el envío de correo electrónico

El ejemplo de envío de correos es un clásico para explicar esta idea. Supongamos que se quiere desarrollar un componente que realiza la gestión de usuarios en el sistema, y se be enviar un correo electrónico al usuario cada vez que este actualiza sus datos.

Para probar esta funcionalidad es necesario invocar al método UpdateUser de la clase UserManager, esta clase depende de una estructura de datos que representa al usuario UserInfo y el componente encargado de enviar correo electrónico. El diagrama de clases que representa este modelo:

Una vez que se ha usado el componente MailSender, se sabe que para asegurar el correcto funcionamiento del sistema, se ha de establecer la propiedad SmtpServer antes de llamar al método SendMail, y que este ha sido llamado con los valores esperados. Un ejemplo de uso del componente que vamos a utilizar es:

MailSender sender = new MailSender(); 
sender.SmtpServer = "smtp.mail.ya.com"; 
sender.SendMail("user@mail.com","test@test.com","Test", "Mail Test");

La primera vez se hace funcionar a estas tres líneas de código, y después de configurar adecuadamente el servidor SMTP que se va a emplear, obtenemos un mensaje en nuestro buzón. En este caso la comprobación de que el correo ha llegado correctamente se hace de forma manual, es decir, es necesario que una persona acceda al buzón, lea el contenido del mensaje, y asegure que es correcto.

Sin embargo, si dependemos de esta forma de comprobar si el código que envía el correo es correcto, cada vez que ejecutemos el plan de pruebas vamos encontrarnos con cientos de mensajes en el buzón, cuya validación será muy costosa de realizar. Para automatizar la tarea se puede escribir el código necesario para acceder a los buzones para validar los mensajes, pero esta técnica, además de ser muy compleja para aplicar TDD implica que no sólo estamos probando nuestro código sino el sistema en su totalidad incluyendo la red y los servidores POP3 y SMTP.

Sin embargo, después de la primera ejecución, se puede asegurar que el código que envía el correo es correcto si se ha establecido correctamente la propiedad SmtpServer y se ha llamado al método SendMail con los parámetros apropiados.

Diseño orientado a pruebas

Según las reglas de TDD se debe escribir la prueba que invoque al método UserManager.UpdateUser(user) y comprobar que se ha enviado un correo con el resultado de la actualización.

Para poder comprobar mediante las llamadas que ha recibido la instancia de IMailSender necesitamos aplicar el patrón IoC al modelo que tenemos, el primer paso consiste en extraer el interfaz:

interface IMailManager 
{ 
string SmtpServer {set;} 
void SendMail(string to, string from, string subject, string body); 
}

Y modificar el constructor de UserManager para que permita asignar la instancia de IMailSender con la que trabajará.

Simulación manual

Antes de analizar una implementación real de MockObjects, vamos a crear un simulador implementando el interfaz y controlando si se reciben o no las llamadas esperadas

La primera aproximación al simulador es capaz de memorizar las llamadas recibidas en un par de variables privadas.

public class MailSenderSimulator : IMailSender 
{ 
bool smtpServerCalled = false; 
bool sendMailCalled = false; 
 
public string SmtpServer 
{ 
set 
{ 
smtpServerCalled = true; 
} 
} 
 
public void SendMail(string to, string from, string subject, string body) 
{ 
sendMailCalled = true; 
 
} 
}

Los simuladores de objetos deben ser capaces de verificar si se han recibido las llamadas esperadas. Como la definición de las llamadas esperadas se debe hacer desde la prueba, es necesario exponer esta funcionalidad de forma que podamos controlar el comportamiento del simulador en cada prueba. La verificación del simulador consistirá en comparar los valores esperados con los valores reales y ver si coinciden.

public class MailSenderSimulator : IMailSender 
{ 
public bool ExpectedSmtpServer = false; 
public bool ExpectedSendMail = false; 
 
... 
 
public void Verify() 
{ 
Assert.AreEqual(ExpectedSmtpServer, smtpServerCalled); 
Assert.AreEqual(ExpectedSendMail,  sendMailCalled); 
} 
}

Con este simulador se puede definir la primera prueba.

[Test] 
public void  UpdateUser_MailOK() 
{ 
MailSenderSimulator simulator = new MailSenderSimulator(); 
simulator.ExpectedSmtpServer = true; 
simulator.ExpectedSendMail = true; 
 
 UserManager manager = new UserManager(simulator); 
 
manager.UpdateUser(new UserInfo("rido", "rido@mail.com")); 
 
simulator.Verify(); 
}

Para completar este ejemplo se ha de verificar no sólo si se han recibido las llamadas a los métodos y propiedades esperados, si no que además los valores son los esperados.

MockObjects

La forma de establecer los valores esperados y “memorizar” el valor con el que se ha llamado al simulador para posteriormente verificarlo se ha generalizado dando lugar a un marco de trabajo que permite definir objetos simulados sin necesidad de crear explícitamente el código que verifica cada uno de los valores.

Los MockObjects son objetos que siempre realizan las mismas tareas

  • Implementan un interfaz dado

  • Permiten establecer los valores esperados (tanto de entrada como de salida)

  • Permiten establecer el comportamiento (para lanzar excepciones en casos concretos)

  • Memorizan los valores con los que se llama a cada uno de sus miembros

  • Permiten verificar si los valores esperados coinciden con los recibidos

Existen varios marcos de trabajo para facilitar la implementación de MockObjects , uno de los más conocidos para la plataforma .Net es NMock [7].

Para crear un simulador dinámico DynamicMock del interfaz IMailSender

IMock mockSender = new DynamicMock(typeof(IMailSender));

El interfaz IMock nos permite configurar el comportamiento del simulador a través de los siguientes métodos y propiedades.

Métodos

Descripción

NMock.IMock.Expect

Configura un método para que espere unos determinados valores

NMock.IMock.ExpectAndReturn

Configura un método para que espere unos determinados valores y permite definir el valor de retorno del método

NMock.IMock.ExpectAndThrow

Configura un método para que reciba unos determinados valores y lanzar la excepción que se desee

NMock.IMock.ExpectNoCall

Configura el método de forma que cualquier llamada que reciba se considera un error

NMock.IMock.SetupResult

Configura el valor de retorno de un método independientemente de los parámetros de entrada

Propiedades

Descripción

NMock.IMock.Strict

Indica al simulador que falle si cualquier método recibe una llamada no configurada

NMock.IMock.MockInstance

Devuelve la instancia del simulador

Para configurar los valores esperados NMock cuenta con una serie de Constraints que permiten personalizar la comparación entre los valores esperados y los reales. Las más comunes son

Constraints

Descripción

IsEqual

Compara si dos objetos son iguales

IsNull

Compara si un objeto es nulo

IsAnything

Acepta cualquier objeto

En el caso del ejemplo, para comprobar que se ha llamado a la propiedad SmtpServer del simulador:

mockSender.Expect("SmtpServer", new IsEqual("smtp.test.com"));

Este método configura el simulador indicando: “Se espera una llamada a la propiedad SmtpServer con el valor smtp.test.com”. Si no se ha recibido la llamada con los parámetros adecuados, se lanzará una excepción al llamar al método Verify:

SendMailDemo.test.v1.UserManagerTest.UpdateUser : NMock.VerifyException : MockIMailSender.SmtpServer) called with incorrect parameter (1) 
expected:<smtp.test.com> 
but was:<incorrectServer>

Si sólo queremos comprobar que ha establecido la propiedad sin importarnos su valor:

mockSender.Expect("SmtpServer", new IsAnything());

Para obtener una instancia de IMailSender con la que inicializar UserManager es necesario realizar un "casting" al interfaz.

manager = new UserManager((IMailSender)mockSender.MockInstance);

Estructura de las Pruebas

Una vez que podemos definir más fácilmente el simulador con el que vamos a probar, podemos definir la estructura de las pruebas del método UpdateUser.

La estructura de una prueba unitaria siempre sigue una secuencia de tres pasos:

  1. Configurar los objetos que intervienen en la prueba

  2. Ejecutar el método a probar

  3. Verificar el correcto comportamiento

1) Para evitar tener que configurar en todas las pruebas los objetos que participan en la prueba se puede usar el atributo [SetUp], este código se ejecutará antes de cada prueba.

[SetUp] 
public void SetUp() 
{ 
mockSender = new DynamicMock(typeof(IMailSender)); 
manager = new UserManager((IMailSender)mockSender.MockInstance); 
}

La configuración del simulador depende de cada prueba por lo que su configuración variará en cada prueba

mockSender.Expect("SmtpServer", new IsEqual("smtp.test.com")); 
 
mockSender.Expect("SendMail",  
new IsEqual("user@mail.com"),  
new IsEqual("admin@users.com"),  
new IsEqual("Bienvenido a Users.com"),  
new IsEqual("Bienvenido user1 a Users.com"));

2) Para ejecutar el método en cuestión basta con crear una instancia de UserInfo con datos de prueba y pasársela como parámetro de entrada

manager.UpdateUser(new UserInfo("user1", "user@mail.com"));

3) Las pruebas unitarias suelen verificarse gracias a la clase Assert que provee NUnit, como en este caso no tenemos ningún valor de retorno que comprobar, la única validación que debemos realizar consiste en comprobar que se han recibido las llamadas en el simulador, para lo cual empleamos el método Verify

mockSender.Verify();

Con todo esto, la estructura de la primera prueba:

UserManager manager; 
IMock mockSender; 
 
[SetUp] 
public void SetUp() 
{ 
mockSender = new DynamicMock(typeof(IMailSender)); 
manager = new UserManager((IMailSender)mockSender.MockInstance); 
} 
 
[Test] 
public void  UpdateUser () 
{ 
mockSender.Expect("SmtpServer", new IsEqual("smtp.test.com")); 
 
mockSender.Expect("SendMail",  
new IsEqual("user@mail.com"),  
new IsEqual("admin@users.com"),  
new IsEqual("Bienvenido a Users.com"),  
new IsEqual("Bienvenido user1 a Users.com")); 
 
manager.UpdateUser(new UserInfo("user1", "user@mail.com")); 
 
mockSender.Verify(); 
}

Pasar la primera prueba

Una vez que se ha escrito la primera prueba ejecutándose, y fallando (todavía no hemos implementado el método UpdateUser ) es el momento de empezar a escribir el código de producción.

public class UserManager 
{ 
IMailSender sender; 
 
 public UserManager() : this (new MailSender()) 
 {  
 } 
 
 public UserManager(IMailSender mailSender) 
 { 
   sender = mailSender; 
 } 
 
public void UpdateUser(UserInfo user) 
{ 
string subject = "Bienvenido a Users.com"; 
string body = "Bienvenido " + user.Name + " a Users.com"; 
sender.SmtpServer = SMTP_SERVER; 
sender.SendMail(user.Email, ADMIN_EMAIL, subject, body); 
} 
 
 const string ADMIN_EMAIL = "admin@users.com"; 
const string SMTP_SERVER = "smtp.server.com"; 
}

En este punto el diseño de la clase UserManager y su correspondiente conjunto de pruebas UserManagerTest, permiten aplicar TDD para ir añadiendo funcionalidad a la vez que vamos añadiendo pruebas.

En una situación real, el método UpdateUser debería encargarse de actualizar los datos del usuario, quizás realizar alguna lógica de negocio, y por último enviar el correo.

Para simplificar el ejemplo, se supone que la única lógica que vamos a implementar depende del la propiedad UserInfo.Email.

  • Si contiene una arroba y un punto se enviará el mail de bienvenida al usuario, y otro al administrador.

  • Si el mail del usuario está en blanco no se enviará ningún mail, y se lanzará una excepción.

  • Si el mail no es válido se enviará un mail al administrador informando del error

Para reflejar esta funcionalidad se debe modificar la prueba para que compruebe que no sólo se envía el correo al usuario, sino también al administrador:

[Test] 
public void  UserOk() 
{ 
 
mockSender.Expect("SmtpServer", new IsEqual("smtp.test.com")); 
 
mockSender.Expect("SendMail",  
new IsEqual("user@mail.com"),  
new IsEqual("admin@users.com"),  
new IsEqual("Bienvenido a Users.com"),  
new IsEqual("Bienvenido user1 a Users.com")); 
 
mockSender.Expect("SendMail",  
new IsEqual("admin@users.com"),  
new IsEqual("admin@users.com"),  
new IsEqual("Nuevo usuario"),  
new IsEqual("El usuario user1[user@mail.com] se ha dado de alta")); 
 
 
manager.UpdateUser(new UserInfo("user1", "user@mail.com")); 
 
mockSender.Verify(); 
}

Para implementar el código necesario para satisfacer a esta prueba, lo primero que debemos crear son los textos que vamos a emplear en los correos.

const string USER_SUBJECT_TEMPL = "Bienvenido a Users.com"; 
const string USER_BODY_TEMPL = "Bienvenido {0} a Users.com"; 
 
const string ADMIN_SUBJECT_TEMPL = "Nuevo usuario"; 
const string ADMIN_BODY_TEMPL = "El usuario {0}[{1}] se ha dado de alta";

Y modificar el método UpdateUser para enviar un correo al usuario y otro al administrador.

public void UpdateUser(UserInfo user) 
{ 
  sender.SmtpServer = SMTP_SERVER; 
 
  string userBody = string.Format(USER_BODY_TEMPL, user.Name); 
  sender.SendMail(user.Email, ADMIN_EMAIL, USER_SUBJECT_TEMPL, userBody); 
 
  string adminBody = string.Format(ADMIN_BODY_TEMPL, user.Name, user.Email); 
  sender.SendMail(ADMIN_EMAIL, ADMIN_EMAIL, ADMIN_SUBJECT_TEMPL, adminBody); 
}

La siguiente prueba a implementar debe comprobar que el correo no esté vacío.

[Test, ExpectedException(typeof(Exception), "Debe indicar el mail")] 
public void  NoMail() 
{ 
mockSender.Strict = true; 
mockSender.ExpectNoCall("SmtpServer", new Type[]{}); 
 
mockSender.ExpectNoCall("SendMail", new Type[]{ 
typeof(string),typeof(string),typeof(string),typeof(string)});  
 
 
manager.UpdateUser(new UserInfo("user1", ")); 
 
mockSender.Verify(); 
}

Esta prueba indica que si no se recibe la propiedad que indica la dirección de correo del usuario no se debe invocar a ninguno de los miembros del simulador.

Una vez que se tiene una prueba fallando es el momento de implementar esta funcionalidad.

public void UpdateUser(UserInfo user) 
{ 
if (user.Email.Length>0) 
{ 
sender.SmtpServer = SMTP_SERVER; 
 
string userBody = string.Format(USER_BODY_TEMPL, user.Name); 
sender.SendMail(user.Email, ADMIN_EMAIL, USER_SUBJECT_TEMPL, userBody); 
 
string adminBody = string.Format(ADMIN_BODY_TEMPL, user.Name, user.Email); 
sender.SendMail(ADMIN_EMAIL, ADMIN_EMAIL, ADMIN_SUBJECT_TEMPL, adminBody); 
} 
else 
{ 
throw new Exception("Debe indicar el mail"); 
} 
}

Por último, añadimos la prueba que comprueba que el correo contenga el carácter "@".

[Test] 
public void  BadMail() 
{ 
 
mockSender.Expect("SmtpServer", new IsEqual("smtp.test.com")); 
 
mockSender.Expect("SendMail",  
new IsEqual("admin@users.com"),  
new IsEqual("admin@users.com"),  
new IsEqual("Error en el mail de un Usuario "),  
new IsEqual("El usuario user1 tiene un mail incorrecto: badmail")); 
 
 
manager.UpdateUser(new UserInfo("user1", "badmail")); 
 
mockSender.Verify(); 
}

Para completar esta prueba basta con comprobar si el mail contiene una arroba.

public void UpdateUser(UserInfo user) 
{ 
if (user.Email.Length>0) 
{ 
sender.SmtpServer = SMTP_SERVER; 
 
if (user.Email.IndexOf("@")>0) 
{ 
 
string userBody = string.Format(USER_BODY_TEMPL, user.Name); 
sender.SendMail(user.Email, ADMIN_EMAIL, USER_SUBJECT_TEMPL, userBody); 
 
string adminBody = string.Format(ADMIN_BODY_TEMPL, user.Name, user.Email); 
sender.SendMail(ADMIN_EMAIL, ADMIN_EMAIL, ADMIN_SUBJECT_TEMPL, adminBody); 
} 
else 
{ 
string adminBody = string.Format(ADMIN_BADMAIL_TEMPL, user.Name, user.Email); 
sender.SendMail(ADMIN_EMAIL, ADMIN_EMAIL, ADMIN_BADMAIL_SUBJECT, adminBody); 
} 
} 
else 
{ 
throw new Exception("Debe indicar el mail"); 
} 
 
}

Refactoring

Según las reglas de TDD, antes de implementar más funcionalidad debemos asegurar la calidad del código. Las principales características de un código de calidad son que además de funcionar (ser entendido por las máquinas) pueda ser entendido por los humanos.

Cuando se escribe código es importante tener en cuenta que alguien más también lo va a leer, incluso nosotros mismos pasado un tiempo.

Las estadísticas demuestran que en cualquier proyecto de software el equipo pasa más tiempo leyendo código que escribiendo, por este motivo cualquier cambio que permita mejorar la legibilidad se puede considerar como una inversión a largo plazo.

En los últimos pasos del ejemplo, hemos añadido cierta funcionalidad sin fijarnos en la estructura del código, ahora que tenemos la funcionalidad completada (las pruebas nos lo demuestran) podemos concentrarnos en mejorar la legibilidad sin fijarnos en la funcionalidad, ya que si realizamos un cambio que modifique la funcionalidad las pruebas lo delatarán.

Al leer de nuevo el método UpdateUser , vemos que existen cuatro ramas de código en función de la validación del correo. Esto nos sugiere escribir mover las reglas de validación a una nueva clase: EmailValidator .

Como siempre, antes de escribir la clase necesitaremos unas pruebas.

[TestFixture] 
public class EmailValidatorTest 
{ 
[Test] 
public void  Validate() 
{ 
Assert.IsTrue(EmailValidator.Validate("u@mail.com")); 
Assert.IsFalse(EmailValidator.Validate("badmail")); 
} 
 
[Test, ExpectedException(typeof(Exception), "Debe indicar el mail")] 
public void  ValidateEmptyMail() 
{ 
EmailValidator.Validate(string.Empty); 
 
} 
}

Y la implementación

public class EmailValidator 
{ 
public static bool Validate(string mail) 
{ 
if (mail.Length<3) 
{ 
throw new Exception("Debe indicar el mail"); 
} 
return (mail.IndexOf("@")>0); 
} 
}

Por último modificamos el método UpdateUser para que se apoye en esta clase.

public void UpdateUser(UserInfo user) 
{ 
if (EmailValidator.Validate(user.Email)) 
{ 
sender.SmtpServer = SMTP_SERVER; 
 
string userBody = string.Format(USER_BODY_TEMPL, user.Name); 
sender.SendMail(user.Email, ADMIN_EMAIL, USER_SUBJECT_TEMPL, userBody); 
 
string adminBody = string.Format(ADMIN_BODY_TEMPL, user.Name, user.Email); 
sender.SendMail(ADMIN_EMAIL, ADMIN_EMAIL, ADMIN_SUBJECT_TEMPL, adminBody); 
} 
else 
{ 
string adminBody = string.Format(ADMIN_BADMAIL_TEMPL, user.Name, user.Email); 
sender.SendMail(ADMIN_EMAIL, ADMIN_EMAIL, ADMIN_BADMAIL_SUBJECT, adminBody); 
} 
 
}

Al realizar esta modificación hemos introducido un defecto en el sistema, dependiendo de la habilidad y concentración del lector lo puede encontrar revisando el código, o en el peor de los casos cuando esté en producción.

Sin embargo, gracias a las pruebas, podemos detectar el error automáticamente. Se trata de la propiedad SmtpServer , en la versión anterior de la función, esta propiedad se establecida sólo si la dirección tenia algún contenido. Al introducir la clase EmailValidator no se ha establecido la propiedad al enviar un mail al administrador, con lo cual, en el caso de tratar una dirección errónea e intentar enviar un mail al administrador, el código fallará ya que no se ha establecido el servidor.

Para resolverlo podríamos copiar la línea a la segunda rama de la condición, pero como queremos evitar el código duplicado, modificaremos la prueba NoMail para que no de por incorrecta la situación de asignar el servidor SMTP si la dirección está vacía. La solución final se encuentra en los archivos adjuntos al artículo.

Las pruebas de integración

Una vez que se da el código por terminado es necesario realizar las pruebas de integración para asegurarse que el sistema funciona correctamente en su conjunto.

Estas pruebas de integración deben incluir dos aspectos

  • Pruebas del componente del que nos hemos aislado ( MailSender )

  • Pruebas del código desarrollado cuando se usa este componente ( UserManager )

En el primer caso la prueba MailSenderTest debe comprobar que el código de UserManager realmente envía correos sin importarnos el contenido.

[TestFixture] 
public class MailSenderTest 
{ 
[Test, Ignore("Necesita de un entorno de integración")] 
public void  SendMail() 
{ 
MailSender sender = new MailSender(); 
sender.SmtpServer = "smtp.mail.ya.com"; 
sender.SendMail("rmpablos@hotmail.com", "test@testing.com", "Test", "Mail Test"); 
} 
}

Las pruebas de integración UserManagerIntegrationTest debe ejecutar toda la lógica implementada contra el sistema real. La verificación de estas pruebas es manual, pero debido a que sólo tenemos que ejecutarlas en contadas ocasiones no es necesario automatizar la verificación.

Conclusiones

El desarrollo guiado por pruebas no debe clasificarse como un método para hacer pruebas, sino para diseñar sistemas poco acoplados que permiten mejorar su calidad gracias al refactoring apoyándose en pruebas unitarias. Para poder probar unitariamente sistemas complejos es necesario recurrir al uso de simuladores, y el patrón de los MockObjects nos ofrece una forma sencilla de aplicar estas ideas.

El balance entre el tiempo invertido en las pruebas y la calidad del código obtenido establece una nueva forma de desarrollar al ahorrar el tiempo necesario para verificar el correcto comportamiento del código.

En el ejemplo de envío de correo electrónico es fácil intuir la cantidad de tiempo ahorrado al poder verificar el contenido de los correos sin necesidad de la infraestructura SMTP necesaria.

Referencias

[1] TestDrivenDevelopment By Example. KentBeck ( http://www.amazon.com/exec/obidos/tg/detail/-/0321146530/qid=/sr=/ref=cm_lm_asin/103-6807181-1003021?v=glance)

[2] TestDriven.com ( http://www.testdriven.com)

[3] MartinFowler. Dependency Injection Pattern ( http://martinfowler.com/bliki/InversionOfControl.html)

[4] http://www.mockobjects.com/FrontPage.html

[5] Endo-Testing: Unit Testing with Mock Objects [ http://www.connextra.com/aboutUs/mockobjects.pdf]

[6] http://www.nunit.org

[7] http://nmock.truemesh.com

[8] Programación Extrema en Castellano [http://www.programacionextrema.org]

Acerca del autor

Ricardo Mínguez (más conocido como Rido), es un consultor de la división de servicios de Microsoft España. Está especializado en desarrollo con .Net, Metodologías ágiles, y gestión de calidad de proyectos de software. Se puede contactar con él a través de su blog http://blogs.msdn.com/rido o escribiendo a Ricardo.Minguez@microsoft.com

Mostrar:
© 2014 Microsoft