Testing: Dobles de acción y estado vs. comportamiento

Agregar tests a nuestros proyectos es muy rápido. Tan sólo hemos de añadir un proyecto de tipo Test a nuestra solución y Visual Studio nos deja ante un método Test1 listo para rellenar de Asserts😛
Pero para escribir buenos tests hay que tener algunos aspectos en cuenta, que influirán en su planteamiento y en el código que crearemos para satisfacerlos. En este post utilizaré un test sencillo como punto de partida y sobre él presentaré algunos de estos asuntos y las diferentes alternativas a nuestro alcance para solucionarlos.

En el ejemplo voy a testear un método que hace transferencias entre cuentas bancarias. Resta saldo a una cuenta, lo suma a la otra y envía un e-mail de aviso al receptor de la transferencia. No es muy original, pero nos da el suficiente juego:

        [TestMethod]
        public void TestTransfer()
        {
            var lRepo = new DatabaseAccountsRepository();
            var lMailer = new MailNotifier();
            var lServ = new TransferService(lRepo, lMailer);

            lRepo.Create(
                new Account()
                {
                    Id = 1,
                    Balance = 100
                });
            lRepo.Create(
                new Account()
                {
                    Id = 2,
                    Balance = 100
                });

            lServ.Transfer(1, 2, 100);

            var lAccount1 = lRepo.SelectById(1);
            var lAccount2 = lRepo.SelectById(2);

            Assert.AreEqual(0, lAccount1.Balance);
            Assert.AreEqual(200, lAccount2.Balance);
            Assert.AreEqual(1, lMailer.MailsSent);
        }

Y aquí tenemos el listado con el interfaz de la clase a testear (ITransferService) y su implementación (TransferService). También tenemos los interfaces IAccountsRepository e IMailNotifier de los que hace uso TransferService, así como un par de clases auxiliares:

    // Interfaz e implementación de la clase a testear
    public interface ITransferService
    {
        void Transfer(int aSourceAccountId, int aTargetAccountId, decimal aAmount);
    }
    public class TransferService : ITransferService
    {
        private IAccountsRepository _repo;
        private IMailNotifier _mailer;

        public TransferService(IAccountsRepository aAccRepository,IMailNotifier aMailNotifier)
        {
            _repo = aAccRepository;
            _mailer = aMailNotifier;
        }

        public void Transfer(int aSourceAccountId, int aTargetAccountId, decimal aAmount)
        {
            var lSourceAccount = _repo.SelectById(aSourceAccountId);
            if (lSourceAccount.Balance < aAmount)
                throw new Exception("No money!");
            var lTargetAccount = _repo.SelectById(aTargetAccountId);
            lSourceAccount.Balance -= aAmount;
            lTargetAccount.Balance += aAmount;
            _repo.Update(lSourceAccount);
            _repo.Update(lTargetAccount);
            _mailer.SendMail(lTargetAccount.Owner, "Got money!");
        }
    }

    // Interfaces de servicios auxiliares
    public interface IMailNotifier
    {
        int MailsSent { get; }
        void SendMail(AccountOwner aTo, string aText);
    }
    public interface IAccountsRepository
    {
        Account Create(Account aAccount);
        Account Update(Account aAccount);
        Account SelectById(int aAccountId);
    }

    // Clases auxiliares
    public class Account
    {
        public int Id { get; set; }
        public decimal Balance { get; set; }
        public AccountOwner Owner { get; set; }
    }
    public class AccountOwner
    {
        public string Name { get; set; }
        public string Email { get; set; }
    }

SUT/colaborador

Como vemos en el código, la clase TransferService se apoya en otras (DatabaseAccountsRepository y MailNotifier) para hacer el acceso a la base de datos y enviar notificaciones. Estamos testeando la clase TransferService, pero también necesitamos instancias de IAccountsRepository e IMailNotifier. En el primer caso, hablamos de Object-under-test o System-under-test (SUT) y, en los otros, de objetos colaboradores. Dicho de otra manera, una transferencia bancaria que realice nuestro SUT provocará cambios en los colaboradores: en los saldos de las cuentas que se almacenan en el repositorio de cuentas (IAccountsRepository) y que MailNotifier envíe mensajes.

Algunos aspectos interesantes del rol de colaborador son:

  • Si la implementación de IAccountsRepository (DatabaseAccountsRepository) tiene un bug, también va a romper el test de TransferService. Ídem para MailNotifier. No hay un buen aislamiento de los tests y en implementaciones reales con miles de líneas de código no tener este tipo de aislamiento es un problema. Como en este ejemplo, un bug en la capa de acceso a datos o en un servicio secundario como el envío de mails podría romper todos los tests de nuestra aplicación.
  • Necesitaremos una base de datos para ejecutar el test. Poner en marcha una BBDD no es muy costoso pero, ¿y en el caso del envío de e-mails? ¿O si consume algún recurso que nos facturan por uso? En esos casos ejecutar nuestros tests contra las implementaciones reales de los colaboradores tendría consecuencias no deseadas.
  • ¿Y si no tenemos las implementaciones reales de los colaboradores? Imagina que somos los encargados de implementar el servicio de transferencias y tan sólo tenemos los interfaces de los otros servicios. No podríamos testear nuestra parte hasta tenerlo todo.
  • Finalmente, es muy común que observando el estado en que queden nuestros objetos colaboradores al final del test sea como podamos asegurar que éste se supera. En el ejemplo, comprobamos que la transferencia se ha hecho y la notificación se ha enviado consultando el estado de los colaboradores. De hecho, podemos observar aún más cosas, como veremos más adelante.

Estos son algunos de los motivos que pueden hacer recomendable que en nuestros tests no utilicemos las implementaciones reales de los colaboradores. En su lugar, podemos utilizar dobles (como en el cine) para las escenas de acción😛

Fakes y Stubs

Es primer tipo de doble que vamos a ver son los objetos Fake (no se me ocurre una buena traducción al castellano). Un objeto fake tiene una implementación funcional, pero no es apto para entornos de producción. Un ejemplo clásico es una BBDD en memoria y eso es lo que vamos a implementar para nuestro repositorio de cuentas:

    public class FakeInMemoryAccountsRepository : IAccountsRepository
    {
        private Dictionary _accounts = new Dictionary();

        public Account Create(Account aAccount)
        {
            _accounts.Add(aAccount.Id, aAccount);
            return aAccount;
        }

        public Account Update(Account aAccount)
        {
            _accounts[aAccount.Id] = aAccount;
            return aAccount;
        }

        public Account SelectById(int aAccountId)
        {
            return _accounts[aAccountId];
        }
    }

Como vemos, para este test podemos sustituir una complicada implementación que acceda a BBDD por un código sencillo que utiliza un simple diccionario.

Otro tipo de doble que podemos utilizar son los Stubs (¿resguardo? ¿esbozo? las traducciones no son lo mío). Un stub no proporciona una implementación completa, sino que se limita a responder a lo que se demanda en el test. Los stubs pueden guardar información sobre las llamadas que se hacen en el test, de forma que podamos hacer Asserts sobre ella. Vamos  implementar un IMailNotifier a modo de stub:

    public class StubMailNotifier : IMailNotifier
    {
        public int MailsSent
        {
            get;
            private set;
        }

        public void SendMail(AccountOwner aTo, string aText)
        {
            MailsSent++;
        }
    }

Como vimos antes en el código del test, lo único que nos interesaba es que se enviara un e-mail. Pues bien, nuestro stub contendrá el código mínimo que nos permita comprobar eso. El test reescrito utilizando nuestros dobles queda así:

        [TestMethod]
        public void TestTransferFakeStub()
        {
            var lRepo = new FakeInMemoryAccountsRepository();
            var lMailer = new StubMailNotifier();
            var lServ = new TransferService(lRepo, lMailer);

            lRepo.Create(
                new Account()
                {
                    Id = 1,
                    Balance = 100
                });
            lRepo.Create(
                new Account()
                {
                    Id = 2,
                    Balance = 100
                });

            lServ.Transfer(1, 2, 100);

            var lAccount1 = lRepo.SelectById(1);
            var lAccount2 = lRepo.SelectById(2);

            Assert.AreEqual(0, lAccount1.Balance);
            Assert.AreEqual(200, lAccount2.Balance);
            Assert.AreEqual(1, lMailer.MailsSent);
        }

Behavior testing y Mock objects

Hasta ahora, nuestros tests se limitan a realizar una acción y comprobar que ha sido correcta comprobando el estado de nuestro SUT y/o los colaboradores al acabar. Es un estilo de test que se basa en el estado de los objetos que intervienen, y recibe el nombre de State testing.
¿Existe otra manera? ¿Y si comprobáramos su comportamiento? Es decir, podríamos definir que, al acabar el test, se tiene que haber llamado a IMailNotifier.SendMail una sola vez y a IAccountsRepository.Update dos veces, con determinados valores en los parámetros. Este tipo de tests reciben el nombre de Behavior testing.

Para poder implementar este último tipo de tests basados en comportamiento vamos a utilizar otro tipo de dobles de objetos que llamamos Mock objects. Los mocks son un tipo de objetos que podemos programar con las expectativas de llamadas que esperamos que reciban.
Existen varias librerías de Mock objects, todas ellas muy similares en cuanto a su uso. En este post voy a utilizar Moq. Una gran ventaja de Moq es que también nos permite crear stubs de una forma muy simple.

Así queda un test similar al que ya habíamos escrito pero al estilo Behavior testing:

        [TestMethod]
        public void TestTransferBehavior()
        {
            var lRepoMock = new Mock();
            // Creamos un metodo stub para SelectById en el repositorio
            lRepoMock
                .Setup(r => r.SelectById(It.IsAny()))
                .Returns((int id) => new Account() { Id = id, Balance = 100 });
            var lRepo = lRepoMock.Object;
            var lMailerMock = new Mock();
            var lMailer = lMailerMock.Object;

            var lServ = new TransferService(lRepo, lMailer);

            lServ.Transfer(1, 2, 100);

            // Llamada solo una vez a sendmail
            lMailerMock.Verify(m => m.SendMail(It.IsAny(), It.IsAny()), Times.Once());
            // Llamada dos veces a update
            lRepoMock.Verify(r => r.Update(It.IsAny()), Times.Exactly(2));
        }

Como dije antes, Moq también nos permite crear stubs de una forma muy sencilla, y eso es lo que hacemos para el método SelectById del repositorio. Para cualquier valor entero (It.Any<int>()) con el que nos llamen a SelectById devolveremos una cuenta con saldo 100 e Id el que nos hayan pasado por parámetro. Es una implementación suficiente para el objetivo de este test. Trabajar poco y bien, ¡es perfecto!😛

La otra parte interesante del test son las dos últimas líneas, donde comprobamos que se llama al método IMailNotifier.SendMail una vez y a IAccountsRepository.Update dos veces.

Estado o comportamiento. Pros y contras

¿Entonces, cómo he de orientar mis tests, a estado o a comportamiento? Pues depende. Seguramente lo mejor será combinar las dos maneras. Siendo pragmático, la principal diferencia entre ambas aproximaciones es el nivel de acoplamiento del test respecto a las implementaciones concretas: basta con que cambien las llamadas a los colaboradores para que los tests se rompan. Este nivel de acoplamiento también afectará a la forma en que nos aproximamos al diseño, ya que podremos incluir en las especificaciones (un test no deja de ser eso cuando hacemos TDD) requisitos funcionales y también de implementación. Para más información, Martin Fowler.

Hasta la próxima, happy coding… and testing!🙂

Esta entrada fue publicada en Dev y etiquetada , , , , . Guarda el enlace permanente.

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s