lo que hacemos es simular el comportamiento de los objetos "reales" pero de manera "controlada".
Muchas veces cuando queremos crear pruebas para algún objeto nos topamos con el problema de que tiene dependencias, esto nos obliga a buscar la forma para proveerlas, esta situación se puede complicar más cuando estas dependencias aún no han sido programadas, inclusive , tendiéndolas, nos va a llevar mucho trabajo configurar el contexto y conocer que exactamente se les está suministrando y que es lo que nos están proveyendo. Poco a poco nos vamos metiendo en un desorden de miedo, por ejemplo al fallar una prueba nos va costar determinar si fue el objeto "
target" el que falló o alguna de sus dependencias y de ser este el caso, es algo que realmente
no nos interesa mucho, ya que
NO son las dependencias las que estamos probando.
Para aislar las pruebas, al punto que las podamos hacer realmente unitarias, utilizamos "
mocks", que permitan grabar el comportamiento del objeto con aquellos con los que tiene relación. Con los "
mocks" proveemos datos y comportamiento totalmente controlados de manera tal que sabemos exactamente que es lo que le estamos proveyendo al objeto, que nos está mandando y que deberíamos esperar.
En la teoría siempre citan el ejemplo de las pruebas de carros, en donde ponen maniquíes especiales que simulan ("
mock") personas reales para ver como reaccionan al choque, esto, dado que es imposible probar con personas reales.
Cuando vamos a implementar mocks, salta la pregunta de cuál framework deberíamos usar. Como es de esperar existen varias opciones, no hay un consenso ya que cada quien tiene su sabor favorito. Entre los principales frameworks actualmente se encuentran
NMock2,
Moq,
Rhino,
Isolator. Existen varias comparaciones
esta particularmente es corta y concisa. Lo que puedo decir rápidamente basado en la comparativa: NMock2 es
"Type unsafe" lo que implica que espera
strings lo cual es algo que particularmente no me gusta ya se vuelve muy desordenado y delicado, además no hay apoyo del
Intelicense, Moq es el que usamos actualmente y es simple y fácil de aprender ya que es muy intuitivo, recientemente me topé con el problema de que no soporta
output parameters y los
callbacks tienen un límite de cuatro parámetros, actualmente hay una versión en beta. Rhino si bien no lo he usado mucho he visto que tiene buena aceptación en los foros aunque desde la aparición de Moq ha ido perdiendo terreno. Isolator no es open source y no es gratuito lo que le hace perder muchos puntos cuando el presupuesto es importante, además, según la comparativa es el más lento.
Ejemplo con Moq
Moq es relativamente nuevo, pero ha tenido buena aceptación. Para el ejemplo estoy usando la versión 3.1.416.3 junto con
NUnit 2.5.9222 y el .Net Framework 3.5
Supongamos que tenemos un pequeño web que registra usuarios tomando alguna información mínima. Existen dos servicios uno de datos (
IUserDataService) y otro de formato (
IUserFormatingService) los cuales son utilizados por un tercer servicio (
IUserService) que funciona como un
facade para juntarlos y dar acceso a las operaciones.
1: public interface IUserFormatingService
2: {
3: User FormatUserRaw(DataRow row);
4: }
5:
6: public interface IUserService
7: {
8: int Add(User user);
9: }
10:
11: public interface IUserDataService
12: {
13: int AddToDB(User user);
14: bool IsLoginNameAvailable(string LoginName);
15: }
16:
IUserService se encarga de validar el
DTO User e insertarlo en la Base de Datos, para ello utiliza el método
IsLoginNameAvailable para verificar que el Login está disponible y
AddToDB que se encarga de la inserción propiamente dicha.
La entidad
User tiene unas pocas propiedades
1: public class User
2: {
3: public int ID { get; set; }
4: public string Name{ get; set; }
5: public string Login { get; set; }
6: public string Password { get; set; }
7: public string Email { get; set; }
8: public string Country { get; set; }
9: public double Balance { get; set; }
10: public DateTime BirthDate { get; set; }
11: public DateTime SingUpDate { get; set; }
12: }
La implementación
UserService, como curiosidad, podríamos pensar que los dos servicios que está esperando en el constructor son provistos por una entidad aparte que se encarga de inyectarlos y por tanto estamos usando
IoC.
1: public class UserService : IUserService
2: {
3: private IUserDataService _userDataService;
4: private IUserFormatingService _userFormatingService;
5:
6: public UserService(IUserDataService userDataService,
7: IUserFormatingService userFormatingService)
8: {
9: this._userDataService = userDataService;
10: this._userFormatingService = userFormatingService;
11: }
12:
13: #region IUserService Members
14:
15: /// <summary>
16: /// Adds the specified user.
17: /// </summary>
18: /// <param name="user">The user.</param>
19: /// <returns>The user id</returns>
20: public int Add(User user)
21: {
22: if (user == null)
23: {
24: throw new ArgumentException("No se ha provisto un usuario");
25: }
26:
27: //NOTE: Si propiedad Login es nula, habrá una
28: // excepción no esperada.
29: if (user.Login.Length < 5)
30: {
31: throw
32: new ArgumentException("Login deber ser mayor a 5 caracteres.");
33: }
34:
35: if (string.IsNullOrEmpty(user.Name))
36: {
37: throw new ArgumentException("Debe Proveer un Nombre.");
38: }
39:
40: if (CalculateAge(user.BirthDate) < 18)
41: {
42: throw
43: new ArgumentException("Debe ser mayor de 18 años " +
44: " para poder registrarse.");
45: }
46:
47: if (!_userDataService.IsLoginNameAvailable(user.Login))
48: {
49: throw new ArgumentException("Login no disponible");
50: }
51:
52: if (!IsEmail(user.Email))
53: {
54: throw new ArgumentException("Email Inválido");
55: }
56:
57: return _userDataService.AddToDB(user);
58: }
59:
60: #endregion
61: }
Queremos asegurar que
UserService funciona correctamente, pero para ello ocuparíamos las dependencias
IUserDataService y
IUserFormatingService, que por cierto aún no han sido implementadas y ni siquiera existe una base de datos. Es, en este punto donde entra
Moq. Para ello creamos un proyecto y agregamos una clase
UserServiceTest que tendrá los tests relacionados con
UserService, para las dependencias creamos dos "
mocks" con los cuales controlamos que le pasaremos a la clase y que nos está mandando ella.
El método
GetUserForTest lo que hace es regresar un usuario válido el cual modificaremos según sea requerido en cada uno de los test. En el constructor inicializamos el servicio y proveemos los "
mocks".
1: [TestFixture]
2: public class UserServiceTest
3: {
4: private Mock<IUserDataService> _userDataService =
5: new Mock<IUserDataService>();
6:
7: private Mock<IUserFormatingService> _userFormatingService =
8: new Mock<IUserFormatingService>();
9:
10: private IUserService _userService;
11:
12: public UserServiceTest()
13: {
14: _userService = new UserService
15: (_userDataService.Object, _userFormatingService.Object);
16: }
17:
18:
19: private User GetUserForTest()
20: {
21: User user = new User();
22: user.Login = "elvis";
23: user.Name = "David";
24: user.Password = "un_password_compliCAdo";
25: user.Email = "test@test.com";
26: user.Country = "Costa Rica";
27: user.SingUpDate = DateTime.Now;
28: user.BirthDate = new DateTime(1966, 06, 06);
29:
30: return user;
31:
32: }
33:
34: [Test]
35: public void Add_User_Test()
36: {
37: User user = GetUserForTest();
38:
39: _userDataService.Setup(x => x.IsLoginNameAvailable(user.Login)).
40: Returns(true);
41: _userDataService.Setup(x => x.AddToDB(user)).Returns(1);
42:
43: try
44: {
45: int userId = _userService.Add(user);
46: Assert.AreEqual(1, userId);
47: }
48: catch (Exception)
49: {
50: Assert.Fail("No se esperaba ninguna excepción");
51: }
52: }
53:
54: [Test]
55: [ExpectedException(typeof(ArgumentException))]
56: public void Add_User_Login_Short_Lenght()
57: {
58: User user = GetUserForTest();
59:
60: user.Login = "zord";
61:
62: _userDataService.Setup(x =>
63: x.IsLoginNameAvailable(It.IsAny<string>()))
64: .Returns(true);
65: _userDataService.Setup(x => x.AddToDB(user)).Returns(1);
66:
67: try
68: {
69: int userId = _userService.Add(user);
70: Assert.Fail("Se esperaba una excepción");
71: }
72: catch (Exception)
73: {
74: throw;
75: }
76: }
77:
78: [Test]
79: [ExpectedException(typeof(ArgumentException))]
80: public void Add_User_Login_Short_Lenght_BUG()
81: {
82: User user = GetUserForTest();
83: user.Login = null;
84: _userDataService.Setup(x =>
85: x.IsLoginNameAvailable(user.Login))
86: .Returns(true);
87:
88: _userDataService.Setup(x => x.AddToDB(user)).Returns(1);
89:
90: try
91: {
92: int userId = _userService.Add(user);
93: Assert.Fail("Se esperaba una excepción");
94: }
95: catch (Exception)
96: {
97: throw;
98: }
99: }
100:
101:
102: }
En
Add_User_Test() y para cada test configuramos los "
mocks" para establecer los comportamientos requeridos, así por ejemplo
UserService.Add(User) utiliza de
IUserDataService los métodos
IsLoginNameAvailable que recibe un
string y regresa un
booleano y
AddToDB que recibe un
User y regresa un entero, el cual es el ID asignado. Para la configuración llamamos el método
Setup del moq. Note que en la expresión lambda decimos que de "x" vamos a configurar el método
IsLoginNameAvailable he indicamos que esperamos en sus parámetros, cuando reciba
user.login, se refiere al valor exacto que contenga al momento de la configuración, si el valor
no interesa como en
Add_User_Login_Short_Lenght usamos un "
wildcard" que indica que cualquier valor
string es válido =>
It.IsAny(), y finalmente con Returns indicamos que siempre que se cumplan las condiciones retornaremos True.
1: _userDataService.Setup(x => x.IsLoginNameAvailable(user.Login)).Returns(true);
Lo mismo sucede con
AddToDB que retornará el id 1 siempre que sea invocada con la instancia "user"
2: _userDataService.Setup(x => x.AddToDB(user)).Returns(1);
Finalmente implementamos los
Asserts de
NUnit para asegurar los valores.
Un BUG
El método
Add_User_Login_Short_Lenght_BUG intencionalmente contiene un bug, ya que está diseñado para fallar cuando cuando la longitud de la propiedad LoginName es vacía o menor a 5, en cuyo caso se producirá una excepción tipo
ArgumentException. Sin embargo la propiedad está nula y al intentar accederla se produce
System.NullReferenceException lo cual no es un comportamiento esperado y por tanto un BUG del sistema.
Lo primero que debemos hacer al encontrar un bug, antes de arreglarlo es crearle un test, luego corregirlo y de esta manera queda asegurado que si el bug vuelve a aparecer existirá un prueba que nos va decir con anterioridad de la existencia del problema.
Gran parte de la idea de tener pruebas es que nos estén asegurando que el sistema está funcionando correctamente y que los cambios que vamos introduciendo no lo están quebrando. Si logramos tomar nuestro sistema por unidades y atomizar cada una de las pruebas aseguramos que si cada una funciona como debe en forma individual deben funcionar de manera conjunta y de no ser así, implica que alguna de las partes está fallando y las pruebas no son correctas o no son las suficientes.
Más Moq
Algunas otras cosas que podemos hacer con moq.
Parámetros
Accesando los parámetros con los cuales se hizo el llamado para el retorno.
1: _userDataService.Setup(x => x.AddID(It.IsAny()))
2: .Returns((string login) => string.Format("{0}ID", login));
CallBacks
En la línea 2 se llama el callback antes de la invocación del return y se accesa el argumento miertras en la línea 3 se invoca despues del return
1: _userDataService.Setup(x => x.AddToDB(It.IsAny()))
2: .Callback((User inserUser) =>
3: Console.WriteLine(string.Format("Adding user {0}", inserUser.Login)))
4: .Returns(1)
5: .Callback(()=> Console.WriteLine("User Inserted"));
Excepciones
Moq puede configurar excepciones según la condición
1: _userDataService.Setup(x => x.IsLoginNameAvailable(null))
2: .Throws(new ArgumentException("Valor no puede ser nulo"))
Matching
Como en el ejemplo se pueden usar "
Matchings" que indiquen los valores esperados.
Indicar que espera cualquier objeto de tipo User
1: _userDataService.Setup(x => x.AddToDB(It.IsAny<User>())).Returns(1);
Valida los valores que cumplan con el predicado dado, ósea el valor esperado deber ser string y deber ser igual a "test"
1: _userDataService.Setup(x => x.IsLoginNameAvailable(It.Is<string>(l => l.Equals("Test"))))
2: .Returns(true);
Expresiones Regulares
1: _userDataService.Setup(x =>
2: x.IsLoginNameAvailable(It.IsRegex(@"^([a-zA-Z0-9_\-\.]+)", RegexOptions.IgnoreCase)))
3: .Returns(true);
Rangos, ejemplo si queremos usar un número entero que no sea ni el mínimo, ni el máximo
1: moq.Setup(x =>
2: x.Ejemplo(It.IsInRange<int>(int.MinValue, int.MaxValue, Range.Exclusive)))
3: .Return(true);
Son algunas pocas opciones del framework aun quedan eventos, propiedades, verificaciones, comportamientos, avanzados, etc. Ejemplos conceptuales los podemos encontrar en
QuickStart de la pagina.