Diseño ágil con TDD

6.1. Cuándo usar un objeto real, un stub o un mock

Vamos a por el primer ejemplo para sacar a relucir los pros y los con- tras de las distintas alternativas que tenemos para diseñar código que trata de colaboraciones entre objetos. Supongamos que hemos escrito un control gráfico que muestra un calendario en pantalla para permitirnos seleccionar una fecha.

Ahora nos piden que dibujemos los días festivos de determinados municipios en un color distinto, para lo cual tenemos que consultar un servicio remoto que lee, de una base de datos, los días festivos. El servicio necesita conocer qué año, qué mes y qué municipio nos ocupa, para devolver un vector con los días festivos del mes. La interfaz del servicio remoto es la siguiente:

public interface ICalendarService
{
    int[] GetHolidays(int year, int month, string townCode);
}

El método es de lectura, de consulta. Lo que queremos diseñar es el trozo de código, de nuestra aplicación cliente, que obtiene los días festivos del servidor remoto. El SUT es el calendario cliente y su colaborador el servicio remoto. Estamos hablando de una aplicación cliente/servidor.

Para diseñar la colaboración, no vamos a utilizar el servicio real porque el test no sería unitario, no se ejecutaría de manera veloz ni con independencia del entorno. Está claro que necesitamos un doble. ¿Qué tipo de doble de prueba utilizaremos?

Generalmente, para los métodos de consulta se usan stubs pero el factor determinante para decantarse por un mock o un stub es el nivel de especificidad que se requiere en la colaboración. Vamos a estudiar las dos posibilidades resaltando las diferencias de cada una. Utilizaremos Rhino.Mocks para los ejemplos.

Lo primero es añadir al proyecto las referencias a las DLL Rhino.Mocks, Castle.Core y Castle.DynamicProxy. Nuestro calendario cliente tiene tres propiedades que son CurrentYear, CurrentMonth, CurrentTown que sirven para configurarlo.

El test, usando un mock, sería el siguiente:

using System;
using System.Collections.Generic;
using NUnit.Framework;
using Rhino.Mocks;

[TestFixture]
public class CalendarTests
{
    [Test]
    public void ClientAsksCalendarService()
    {
        int year = 2009;
        int month = 2;
        string townCode = "b002";
        ICalendarService serviceMock =
            MockRepository.GenerateStrictMock<ICalendarService>();
        serviceMock.Expect(
            x => x.GetHolidays(
                year, month, townCode)).Return(new int[] { 1, 5 });

        Calendar calendar = new Calendar(serviceMock);
        calendar.CurrentTown = townCode;
        calendar.CurrentYear = year;
        calendar.CurrentMonth = month;
        calendar.DrawMonth(); // the SUT

        serviceMock.VerifyAllExpectations();
    }
}

El código asusta un poco al principio pero, si lo estudiamos, veremos que no es tan complejo. En él, se distinguen tres bloques separados por líneas en blanco (AAA). El primer bloque consiste en la generación del mock y la definición de expectativas. Con la llamada a GenerateStrictMock, el framework genera una instancia de una clase que implementa la interfaz ICalendarService. Nos ahorramos crear una clase impostora a mano como hicimos en el capítulo anterior.

La siguiente línea define la primera expectativa (Expect) y dice que, sobre el propio objeto mock, en algún momento, se invocará al método GetHolidays con sus tres parámetros. Y además, dice que, cuando esa invocación se haga, el mock devolverá un array de dos elementos, 1 y 5. O sea, estamos diciéndole al mock que le van a invocar de esa manera y que, cuando ocurra, queremos que se comporte tal cual.

El siguiente bloque ya es el de acto (Act), donde se invoca al SUT. La última línea es la que verifica que todo fue según lo esperado (Assert), la que le dice al mock que compruebe que la expectativa se cumplió. Si no se cumplió, entonces el test no pasa porque el framework lanza una excepción.

Que no se cumplió significa que la llamada nunca se hizo o que se hizo con otros parámetros distintos a los que explícitamente se pusieron en la expectativa o bien que se hizo más de una vez. Además, si en el acto se hacen llamadas al mock que no estaban contempladas en las expectativas (puesto que solo hay una expectativa, cualquier otra llamada al servicio sería no-contemplada), el framework hace fallar el test.

La ausencia de expectativa supone fallo, si se produce alguna interacción entre SUT y mock, al margen de la descrita explícitamente. Esta es una restricción o una validación importante, según cómo se mire. Si el colaborador fuese un stub, la verificación (y por tanto sus restricciones), no se aplicaría, como veremos a continuación.

Desde luego, el framework está haciendo una gran cantidad de trabajo por nosotros, ahorrándonos una buena suma de líneas de código y evitándonos código específico de validación dentro del SUT. Si por motivos de rendimiento, por ejemplo, queremos obligar a que el SUT se comunique una única vez con el colaborador, siendo además de la forma que dicta el test, entonces un mock está bien como colaborador.

Cualquier cosa que se salga de lo que pone el test, se traducirá en luz roja. Digo rendimiento porque quizás queremos cuidarnos del caso en que el calendario hiciese varias llamadas al servicio por despiste del programador o por cualquier otro motivo.

El código del SUT que hace pasar el test sería algo como:

public void DrawMonth()
{
    // ... some business code here ...
    int[] holidays = _calendarService.GetHolidays(
        _currentYear,
        _currentMonth,
        _currentTown
    );

    // ... rest of business logic here ...
}

¿Cómo lo haríamos con un stub? ¿qué implicaciones tiene?. El stub no dispone de verificación de expectativas sino que hay que usar el Assert de NUnit para validar el estado. En el presente ejemplo, podemos validar el estado, definiendo en el calendario cliente alguna propiedad Holidays de tipo array de enteros que almacenase la respuesta del servidor para poder afirmar sobre él. Al recurrir al stub, nos aseguramos que el SUT es capaz de funcionar puesto que, cuando invoque a su colaborador, obtendrá respuesta. El stub, al igual que el mock, simulará al servicio devolviendo unos valores:

[Test]
public void DrawHolidaysWithStub()
{
    int year = 2009;
    int month = 2;
    string townCode = "b002";
    ICalendarService serviceStub =
        MockRepository.GenerateStub<ICalendarService>();
    serviceStub.Stub(
        x => x.GetHolidays(year, month, townCode)
    ).Return(new int[] { 1, 5 });

    Calendar calendar = new Calendar(serviceStub);
    calendar.CurrentTown = townCode;
    calendar.CurrentYear = year;
    calendar.CurrentMonth = month;
    calendar.DrawMonth();

    Assert.AreEqual(1, calendar.Holidays[0]);
    Assert.AreEqual(5, calendar.Holidays[1]);
}

La diferencia es que este test mantendría la luz verde incluso aunque no se llamase a GetHolidays, siempre que la propiedad Holidays de calendar tuviese los valores indicados en las afirmaciones del final. También pasaría aunque la llamada se hiciese cien veces y aunque se hicieran llamadas a otros métodos del servicio.

Al ser menos restrictivo, el test es menos frágil que su versión con un mock. Sin embargo, nos queda sensación de que no sabemos si la llamada al colaborador se está haciendo o no. Para salir de dudas, hay que plantearse cuál es el verdadero objetivo del test.

Si se trata de describir la comunicación entre calendario y servicio con total precisión, usaría un mock. Si me basta con que el calendario obtenga los días festivos y trabaje con ellos, usaría un stub.

Cuando no estamos interesados en controlar con total exactitud la forma y el número de llamadas que se van a hacer al colaborador, también podemos utilizar un stub. Es decir, para todos aquellos casos en los que le pedimos al framework... "si se produce esta llamada, entonces devuelve X", independientemente de que la llamada se produzca una o mil veces. Digamos que son atajos para simular el entorno y que se den las condiciones oportunas. Al fin y al cabo, siempre podemos cubrir el código con un test de integración posterior que nos asegure que todas las partes trabajan bien juntas.

A continuación, vamos a por un ejemplo que nos hará dudar sobre el tipo de doble a usar o, incluso, si conviene un doble o no. Se trata de un software de facturación. Tenemos los objetos, Invoice (factura), Line y TaxManager (gestor de impuestos).

El objeto factura necesita colaborar con el gestor de impuestos para calcular el importe a sumar al total, ya que el porcentaje de los mismos puede variar dependiendo de los artículos y dependiendo de la región.

Una factura se compone de una o más líneas y cada línea contiene el artículo y la cantidad. Nos interesa inyectar el gestor de impuestos en el constructor de la factura para que podamos tener distintos gestores correspondientes a distintos impuestos. Así, si estoy en Madrid inyectaré el IvaManager y si estoy en Canarias el IgicManager.

¿Cómo vamos a probar esta colaboración? ¿Utilizaremos un objeto real? ¿un stub? ¿un mock tal vez?. Partimos de la base de que el gestor de impuestos ya ha sido implementado. Puesto que no altera el sistema y produce una respuesta rápida, yo utilizaría el objeto real:

[TestFixture]
public class InvoiceTests
{
    [Test]
    public void CalculateTaxes()
    {
        Stock stock = new Stock();
        Product product = stock.GetProductWithCode("x1abc3t3c");
        Line line = new Line();
        int quantity = 10;
        line.AddProducts(product, quantity);
        Invoice invoice = new Invoice(newTaxManager());
        invoice.AddLine(line);

        float total = invoice.GetTotal();

        Assert.Greater(quantity * product.Price, total);
    }
}

Las tres partes del test están separadas por líneas en blanco. En la afirmación, nos limitamos a decir que el total debe ser mayor que la simple multiplicación del precio del producto por la cantidad de productos.

Usar el colaborador real (TaxManager) tiene la ventaja de que el código del test es sencillo y de que, los posibles defectos que tenga, probablemente sean detectados en este test. Sin embargo, el objetivo del test no es probar TaxManager (el colaborador) sino probar Invoice (el SUT). Visto así, resulta que si usamos un doble para TaxManager, entonces el SUT queda perfectamente aislado y este test no se rompe aunque se introduzcan defectos en el colaborador.

La elección no es fácil. Personalmente, prefiero usar el colaborador real en estos casos en que no altera el sistema y se ejecuta rápido. A pesar de que el test se puede romper por causas ajenas al SUT, ir haciendo TDD me garantiza que, en el instante siguiente a la generación del defecto, la alerta roja se va a activar, con lo que detectar y corregir el error será cuestión de segundos.

Los mocks no se inventaron para aislar dependencias sino para diseñar colaboraciones, aunque el aislamiento es un aspecto secundario que suele resultar beneficioso.

Se puede argumentar que, al no haber usado un mock, no tenemos garantías de que el cálculo del impuesto fuese realizado por el gestor de impuestos. Podría haberse calculado dentro del mismo objeto factura sin hacer uso de su colaborador. Pero eso supondría que, forzosamente, hemos producido código duplicado; replicado del gestor de impuestos.

El tercer paso del algoritmo TDD consiste en eliminar la duplicidad, por lo que, si lo estamos aplicando, no es obligatorio que un mock nos garantice que se hizo la llamada al colaborador.

La validación de estado que hemos hecho, junto con la ausencia de duplicidad, son suficientes para afirmar que el código va por buen camino. Así pues, la técnica no es la misma siestamos haciendo TDD que si estamos escribiendo pruebas de software a secas.

Las consideraciones difieren. Si el gestor de impuestos accediese a la base de datos, escribiese en un fichero en disco o enviase un email, entonces seguro que hubiese utilizado un doble. Si no estuviese implementado todavía y me viese obligado a diseñar primero esta funcionalidad de la factura, entonces seguro usaría un stub para simular que la funcionalidad del gestor de impuestos está hecha y produce el resultado que quiero.

En palabras de Steve Freeman: *"utilizamos mocks cuando el servicio cambia el mundo exterior; stubs cuando no lo hace - stubs para consultas y mocks para acciones". "Usa un stub para métodos de consulta y un mock para suplantar acciones". Es una regla que nos orientará en muchos casos. Aplicada al ejemplo anterior, funciona; usaríamos un stub si el TaxManager no estuviese implementado, ya que le hacemos una consulta.

Hay veces en las que un mismo test requiere de mocks y stubs a la vez, ya que es común que un SUT tenga varios colaboradores, siendo algunos stubs y otros mocks, dependiendo de la intención del test. Antes de pasar a estudiar las peculiaridades de EasyMock veamos un ejemplo más complejo. Se trata del ejemplo de los estudiantes del capítulo anterior. Escribimos el mismo test con la ayuda de Rhino.Mocks:

using System;
using System.Collections.Generic;
using System.Text;
using NUnit.Framework;
using Rhino.Mocks;

[Test]
public void AddStudentScore()
{
    string studentId = "23145";
    float score = 8.5f;
    Student dummyStudent = newStudent();

    IDataManager dataManagerMock =
        MockRepository.GenerateStrictMock<IDataManager>();
    dataManagerMock.Expect(
        x => x.GetByKey(studentId)).Return(dummyStudent);
    dataManagerMock.Expect(
        x => x.Save(dummyStudent));

    ScoreManager smanager = new ScoreManager(dataManagerMock);
    smanager.AddScore(studentId, score);

    dataManagerMock.VerifyAllExpectations();
}

En este caso, el colaborador es un mock con dos expectativas. El orden de las expectativas también es decisivo en la fase de verificación: si la que aparece segunda se diese antes que la primera, el framework marcaría el test como fallido.

A pesar del esfuerzo que hemos hecho por escribir este test, tiene un problema importante y es su fragilidad, ya que conoce cómo funciona el SUT más de lo que debería. No sólo sabe que hace dos llamadas a su colaborador sino que, además,conoce el orden y ni si quiera estamos comprobando que los puntos han subido al marcador del alumno.

¿Será que el SUT tiene más de una responsabilidad? Un código que se hace muy difícil de probar expresa que necesita ser reescrito. Reflexionemos sobre las responsabilidades. El ScoreManager está encargado de coordinar la acción de actualizar el marcador y guardar los datos. Podemos identificar entonces la responsabilidad de actualizar el marcador y separarla. La delegamos en una clase que se encarga exclusivamente de ello. Vamos a diseñarla utilizando un test unitario:

[Test]
public void ScoreUpdaterWorks()
{
    ScoreUpdater updater = new ScoreUpdater();
    Student student = updater.UpdateScore(newStudent(), 5f);
    Assert.AreEqual(student.Score, 5f);
}

El código del SUT:

public class ScoreUpdater : IScoreUpdater
{
    public Student UpdateScore(Student student, floatscore)
    {
        student.Score = student.Score + score;
        return student;
    }
}

Ahora, que ya podemos probarlo todo, reescribamos el test que nos preocupaba:

[Test]
public void AddStudentScore()
{
    string studentId = "23145";
    float score = 8.5f;
    Student dummyStudent = new Student();

    IDataManager dataManagerMock =
        MockRepository.GenerateStrictMock<IDataManager>();
    dataManagerMock.Expect(
        x => x.GetByKey(studentId)).Return(dummyStudent);
    dataManagerMock.Expect(
        x => x.Save(dummyStudent));
    IScoreUpdater scoreUpdaterMock =
        MockRepository.GenerateStrictMock<IScoreUpdater>();
    scoreUpdaterMock.Expect(
        y => y.UpdateScore(dummyStudent, score)).Return(dummyStudent);

    ScoreManager smanager = new ScoreManager(dataManagerMock, scoreUpdaterMock);
    smanager.AddScore(studentId, score);

    dataManagerMock.VerifyAllExpectations();
    scoreUpdaterMock.VerifyAllExpectations();
}

Hubo que modificar el constructor deScoreManagerpara que aceptase otro colaborador. Ahora estamos seguros que se está probando todo. ¡Pero el test es idéntico a la implementación del SUT!

public void AddScore(string studentId, float score)
{
    IRelationalObject student = _dataManager.GetByKey(studentId);
    Student studentUpdated = _updater.UpdateScore((Student)student, score);
    _dataManager.Save(studentUpdated);
}

Desde luego este test parece más un SUT en sí mismo que un ejemplo de cómo debe funcionar el SUT. Imita demasiado lo que hace el SUT.

¿Cómo lo enmendamos? Lo primero que hay que preguntarse es si realmente estamos obligados a obtener el objeto Student a partir de su clave primaria o si tiene más sentido recibirlo de alguna otra función.

Si toda la aplicación tiene un buen diseño orientado a objetos, quizás el método AddScore puede recibir ya un objeto Student en lugar de su clave primaria. En ese caso, nos quitaríamos una expectativa del test. Vamos a suponer que podemos modificar el SUT para aceptar este cambio:

[Test]
public void AddStudentScore()
{
    float score = 8.5f;
    Student dummyStudent = new Student();
    IScoreUpdater scoreUpdaterMock =
        MockRepository.GenerateStrictMock<IScoreUpdater>();
    scoreUpdaterMock.Expect(
        y => y.UpdateScore(dummyStudent, score)).Return(dummyStudent);
    IDataManager dataManagerMock =
        MockRepository.GenerateStrictMock<IDataManager>();
    dataManagerMock.Expect(
        x => x.Save(dummyStudent));

    ScoreManager smanager = newScoreManager(dataManagerMock, scoreUpdaterMock);
    smanager.AddScore(dummyStudent, score);

    dataManagerMock.VerifyAllExpectations();
    scoreUpdaterMock.VerifyAllExpectations();
}
public void AddScore(Student student,float score)
{
    Student studentUpdated = _updater.UpdateScore(student, score);
    _dataManager.Save(studentUpdated);
}

Ahora el test nos ha quedado con dos expectativas pero pertenecientes a distintos colaboradores. El orden de las llamadas no importa cuando son sobre colaboradores distintos, es decir, que aunque en el test hayamos definido la expectativa UpdateScore antes que Save, no se rompería si el SUT los invocase en orden inverso. Entonces el test no queda tan frágil.

En caso de que no podamos cambiar la API para recibir el objeto Student, sólo nos queda partir el test en varios para eliminar las restricciones impuestas por el framework con respecto al orden en las llamadas a los mocks. La idea es probar un solo aspecto de la colaboración en cada test mediante mocks, e ignorar lo demás con stubs.

Veamos un ejemplo simplificado. Pensemos en la orquestación de unos servicios web. El SUT se encarga de orquestar (coordinar) la forma en que se realizan llamadas a distintos servicios. De forma abreviada el SUT sería:

public class Orchestrator
{
    private IServices _services;

    public Orchestrator(IServices services)
    {
        _services = services;
    }

    public void Orchestrate()
    {
        _services.MethodA();
        _services.MethodB();
    }
}

La orquestación consiste en invocar al servicio A y, a continuación, al servicio B. Si escribimos un test con tales expectativas, queda un test idéntico al SUT como nos estaba pasando. Vamos a partirlo en dos para probar cada colaboración por separado:

[TestFixture]
public class ServicesTests
{
    [Test]
    public void OrchestratorCallsA()
    {
        IServices servicesMock =
            MockRepository.GenerateStrictMock<IServices>();
        servicesMock.Expect(a => a.MethodA());
        servicesMock.Stub(b => b.MethodB());

        Orchestrator orchestrator = new Orchestrator(servicesMock);
        orchestrator.Orchestrate();

        servicesMock.VerifyAllExpectations();
    }

    [Test]
    public void OrchestratorCallsB()
    {
        IServices servicesMock =
            MockRepository.GenerateStrictMock<IServices>();
        servicesMock.Expect(b => b.MethodB());
        servicesMock.Stub(a => a.MethodA());

        Orchestrator orchestrator = new Orchestrator(servicesMock);
        orchestrator.Orchestrate();

        servicesMock.VerifyAllExpectations();
    }
}

El primer test tan sólo se encarga de probar que el servicio A se llama, mientras que se le dice al framework que da igual lo que se haga con el servicio B. El segundo test trabaja a la inversa. De esta forma, estamos diseñando qué elementos toman parte en la orquestación y no tanto el orden mismo. Así el test no es tan frágil y la posibilidad de romper los tests por cambios en el SUT disminuye

No obstante, si resulta que el orden de las llamadas es algo tan crítico que se decide escribir todas las expectativas en un solo test, se puede hacer siempre que tengamos conciencia de lo que ello significa: lanzar una alerta cada vez que alguien modifique algo del SUT. Es programar una alerta, no un test. Si de verdad es lo que necesitamos, entonces está bien.

Rhino.Mocks permite crear híbridos entre mocks y stubs mediante la llamada GenerateMock en lugar de GenerateStrictMock. Así, los tests anteriores se podrían reescribir con un resultado similar y menos líneas de código:

[TestFixture]
public class ServicesHybridMocksTests
{
    [Test]
    public void OrchestratorCallsA()
    {
        IServices servicesMock =
            MockRepository.GenerateMock<IServices>();
        servicesMock.Expect(a => a.MethodA());

        Orchestrator orchestrator = new Orchestrator(servicesMock);
        orchestrator.Orchestrate();

        servicesMock.VerifyAllExpectations();
    }

    [Test]
    public void OrchestratorCallsB()
    {
        IServices servicesMock = MockRepository.GenerateMock<IServices>();
        servicesMock.Expect(b => b.MethodB());

        Orchestrator orchestrator = new Orchestrator(servicesMock);
        orchestrator.Orchestrate();

        servicesMock.VerifyAllExpectations();
    }
}

La diferencia es que el framework sólo falla si la llamada no se produce pero admite que se hagan otras llamadas sobre el SUT, incluso que se repita la llamada que definimos en la expectativa. Nos ahorra declarar la llamada stub en el test pero, por contra, se calla la posible repetición de la expectativa, lo cual seguramente no nos conviene.

Visto el ejemplo de los servicios, queda como ejercicio propuesto escribir tests para el ejemplo de los estudiantes que no hemos terminado de cerrar.

Quizás haya observado que en todo el capítulo no hemos creado ningún mock basado en una implementación concreta de una base de datos ni de ninguna librería del sistema (.Net en este caso). No es recomendable crear mocks basados en dependencias externas sino sólo en nuestras propias interfaces. De ahí el uso de interfaces como IDataManager.

Aunque es posible hacer mocks de clases, siempre usamos interfaces, puesto que la inyección de dependencias es la mejor forma en que podemos gestionar tales dependencias entre SUT y colaboradores. Una vez escritos los tests unitarios, añadiremos tests de integración que se encargan de probar que aquellas de nuestras clases que hablan con el sistema externo, lo hacen bien.

Escribiríamos tests de integración para la clase DataManager. En el caso de Python, si la API de la clase externa nos resulta suficientemente genérica, podemos hacer mock de la misma directamente, dado que en este lenguaje no necesitamos definir interfaces al ser débilmente tipado. Por eso, en Python, sólo crearía una clase tipo DataManager si viese que las distintas entidades externas con las que hablará son muy heterogéneas. Es un claro ejemplo en el que Python ahorra unas cuantas líneas de código.


Copyright (c) 2010-2013 Carlos Ble. La copia y redistribución de esta página se permite bajo los términos de la licencia Creative Commons Atribución SinDerivadas 3.0 Unported siempre que se conserve esta nota de copyright.