Supongamos que estamos construyendo un sistema para la gestión de clientes donde, entre otras cosas, vamos a registrar las compras que realizan. Empezamos con un modelo muy básico, creando una clase para nuestros clientes que nos ofrecerá la funcionalidad necesaria para cumplir nuestra primera regla de negocio:

  • Son clientes senior aquellos que tienen más de año de antiguedad.ß

Aquí tenemos algo de código:

public class Customer {

    private String code;

    private Date createdAt;

    Customer(String code, Date createdAt) {
        this.code = code;
        this.createdAt = createdAt;
    }

    public String code() {
        return code;
    }

    public Date createdAt() {
        return createdAt;
    }

    public boolean isSenior() {
        return false;
    }
}

public class CustomerTest {

    @Test
    public void testIsSeniorCustomer() {
        Date moreThanOneYearAgo = Date.from(
                LocalDateTime.now().minusDays(366).atZone(ZoneId.systemDefault()).toInstant()
        );
        Customer oldCustomer = new Customer("00001", moreThanOneYearAgo);

        assertThat(
                "Customer created more than one year ago should be senior", oldCustomer.isSenior(),
                is(true)
        );

        Customer newCustomer = new Customer("00002", new Date());

        assertThat(
                "Customer created right now should not be considered senior",
                newCustomer.isSenior(), is(false)
        );
    }

}

Hemos empezado con un test que falla y continuamos desarrollando la implementación que hace que el test pase:

    public boolean isSenior() {
        return LocalDate.now().isAfter(
                createdAt.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().plusYears(1)
        );
    }

Seguimos con nuestro modelo. Como dijimos, los clientes compran productos, para los que tenemos un código y la fecha de compra (dicha fecha se establecerá en el momento en que la compra se haga efectiva, no pudiendo modificarse posteriormente). Así que introducimos una nueva clase para productos que ofrecerá una funcionalidad, bastante básica también, con otra regla de negocio:

  • Un producto ha sido comprado recientemente si la compra se produjo en el último mes

Y creamos un test para dicha funcionalidad:

public class Product {

    private String code;

    private Date purchasedAt;

    Product(String code, Date purchaseDate) {
        this.code = code;
        this.purchasedAt = purchaseDate;
    }

    public String code() {
        return code;
    }

    public Date purchasedAt() {
        return purchasedAt;
    }

    public boolean isRecent() {
        return false;
    }
}

public class ProductTest {

    @Test
    public void testIsRecentPurchase() {
        Date moreThanOneMonthAgo = Date.from(
                LocalDateTime.now().minusDays(32).atZone(ZoneId.systemDefault()).toInstant()
        );
        Product oldPurchase = new Product("00001", moreThanOneMonthAgo);

        assertThat(
                "A purchase done more than one month ago should not be considered a recent purchase",
                oldPurchase.isRecent(), is(false)
        );

        Product newPurchase = new Product("00002", new Date());

        assertThat("A purchase done right now is a recent purchase", newPurchase.isRecent(), is(true));
    }

}

A continuación, añadimos la lógica que hace que nuestro test pase:

    public boolean isRecent() {
        return purchasedAt.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().isAfter(
                LocalDate.now().minusMonths(1)
        );
    }

Ahora que tenemos clientes y productos, vamos a añadir la lógica que permitirá a los clientes comprar productos. Empezamos, como siempre, por un test que falla:

public class Customer {

    private String code;

    private Date createdAt;

    private Set<Product> purchases;

    ...

    public Set<Product> boughtProducts() {
        return new HashSet<>(purchases);
    }

    ...

    public void buy(String productCode) {

    }

}


    @Test
    public void testBuy() {
        String aProductCode = "00001";
        String anotherProductCode = "00002";
        Customer customer = new Customer("00001", new Date());

        customer.buy(aProductCode);
        customer.buy(anotherProductCode);

        assertThat(
                aProductCode + " not found in products bought by customer",
                customer.boughtProducts().stream()
                        .anyMatch((product) -> aProductCode.equals(product.code())), is(true)
        );
        assertThat(
                anotherProductCode + " not found in products bought by customer",
                customer.boughtProducts().stream()
                        .anyMatch((product) -> anotherProductCode.equals(product.code())), is(true)
        );
    }

Y, después de haber creado el test, implementamos dicha lógica y hacemos que el test pase:

    public void buy(String productCode) {
        purchases.add(new Product(productCode, new Date()));
    }

Con el código que tenemos a estas alturas, podemos empezar a hacer cosas más interesantes. Nos proponemos hacer ofertas especiales a algunos clientes y de ahí sale nuestra tercera regla de negocio:

  • Sólo los clientes senior pueden recibir ofertas especiales.

Así que añadimos una nueva funcionalidad en nuestra clase de clientes, empezando con tests que fallan:

public class Customer {

    ...

    public boolean isSpecialOffersEligible() {
        return false;
    }

}

    @Test
    public void testIsSpecialOffersEligible_SeniorsUsersAreEligible() {
        Date moreThanOneYearAgo = Date.from(
                LocalDateTime.now().minusDays(366).atZone(ZoneId.systemDefault()).toInstant()
        );
        Customer oldCustomer = new Customer("00001", moreThanOneYearAgo);
        String aProductCode = "00001";
        String anotherProductCode = "00002";
        oldCustomer.buy(aProductCode);
        oldCustomer.buy(anotherProductCode);

        assertThat(oldCustomer.isSpecialOffersEligible(), is(true));
    }

    @Test
    public void testIsSpecialOffersEligible_NewUsersNotEligible() {
        Customer newCustomer = new Customer("00001", new Date());
        String aProductCode = "00001";
        String anotherProductCode = "00002";
        newCustomer.buy(aProductCode);
        newCustomer.buy(anotherProductCode);

        assertThat(newCustomer.isSpecialOffersEligible(), is(false));
    }

Y, a continuación, implementamos esta funcionalidad de acuerdo con nuestra regla de negocio para hacer que los tests pasen:

    public boolean isSpecialOffersEligible() {
        return isSenior();
    }

Nuestro código pasa a producción, la gente empieza a comprar y nosotros hacemos ofertas a nuestros clientes para aumentar las ventas. Al cabo del tiempo, decidimos cambiar las condiciones para hacer ofertas a clientes y modificamos nuestra tercera regla de negocio con una nueva versión:

  • Sólo los clientes senior que hayan realizado alguna compra recientemente pueden recibir ofertas especiales.

Como siempre, empezamos por los tests, así que creamos un nuevo test que nos asegure que esta nueva versión de nuestra regla de negocio se cumple. Pero, con el código de test que hemos creado hasta ahora, nos encontramos con la primera dificultad. La clase Product fue diseñada para ser inmutable, por lo que ni el código del producto comprado ni obviamente la fecha de compra deberían poder ser cambiados. Con nuestro código actual, necesitamos establecer una fecha de compra con más de un mes de antelación para poder construir un test que compruebe que los clientes que

  • tienen más de un año de antigüedad pero
  • no han comprado recientemente
  • no reciben ofertas especiales.

Aquí empiezan las dificultades pues tenemos que, para poder probar nuestro sistema, hay que violar el diseño que nosotros mismos habíamos definido. No pasa nada, hacemos un pequeño ajuste en la clase Product y, a partir de ahí, resulta mucho más fácil probarlo todo:

public class Product {

    private String code;

    private Date purchasedAt;

    ...

    /**
     * Only for testing purposes.
     */
    void setPurchasedAt(Date newPurchaseDate) {
        this.purchasedAt = newPurchaseDate;
    }

}


    @Test
    public void testIsSpecialOffersEligible_SeniorsUsersWithNoRecentPurchaseAreNotEligible() {
        Date moreThanOneYearAgo = Date.from(
                LocalDateTime.now().minusDays(366).atZone(ZoneId.systemDefault()).toInstant()
        );
        Customer oldCustomer = new Customer("00001", moreThanOneYearAgo);
        String aProductCode = "00001";
        String anotherProductCode = "00002";
        oldCustomer.buy(aProductCode);
        oldCustomer.buy(anotherProductCode);
        Iterator<Product> boughtProducts = oldCustomer.boughtProducts().iterator();
        boughtProducts.next().setPurchasedAt(
                Date.from(
                        LocalDateTime.now().minusDays(40).atZone(ZoneId.systemDefault()).toInstant()
                )
        );
        boughtProducts.next().setPurchasedAt(
                Date.from(
                        LocalDateTime.now().minusDays(35).atZone(ZoneId.systemDefault()).toInstant()
                )
        );

        assertThat(oldCustomer.isSpecialOffersEligible(), is(false));
    }

Ahora ya podemos implementar nuestra nueva regla en la clase Customer:

    public boolean isSpecialOffersEligible() {
        return isSenior() && purchasedSomethingRecently();
    }

    private boolean purchasedSomethingRecently() {
        return purchases.stream().anyMatch( p -> p.isRecent() );
    }

Y esto es lo que llaman Design for testability, ¿verdad? Hemos modificado nuestro diseño para poder probar el código correctamente. ¡Falso! Esto no es más que un diseño pobre que hemos enmendado de mala manera para poder crear unas mínimas pruebas unitarias, pero dista mucho de ser una buena solución y el problema no hará más que agravarse con el tiempo. El diseño para la prueba no tiene nada que ver con romper el principio de encapsulación y exponer el estado interno de un objeto permitiendo que actores externos puedan alterarlo sin ningún control.

Por supuesto, podríamos intentar usar algún tipo de objeto mock para los productos que compran los clientes, lo cual requeriría configurar dichos mocks para devolver las fechas de compra que necesitamos en nuestra prueba y modificar la propia clase Customer para poder inyectar dichos mocks como productos comprados por el cliente. Sin embargo, esto sería un engorro que nos va a hacer perder mucho tiempo cada vez que queramos probar algo y ya sabemos que hacer que las pruebas sean complicadas de programar supone abandonar la práctica de programar pruebas automáticas.

El problema no está en si la clase Product es inmutable o no, en cómo modificar la fecha de compra de los productos o en cómo inyectar instancias mocks de productos en la clase Customer. El auténtico fallo de diseño en nuestro código está en que hemos dejado en manos de la clase Customer la creación de la fecha de compra de los productos. Es decir, cuando un cliente compra un producto, establecemos la fecha de compra del product (que debe ser el instance actual, es decir, ahora) y dejamos que sea la propia clase Customer la que decida cuándo es ahora.

El verdadero problema está en que la clase Customer es responsable de determinar cuándo es ahora, es decir, de instanciar Date.

Un verdadero diseño que facilite las pruebas consiste en delegar la lógica que determina cuándo es ahora, es decir, crear una nueva instancia de la clase Date en el instante actual, a una clase externa que sirva como proveedor de tiempo. Con esta idea, la implementación no podría ser más sencilla:

public final class TimeProvider {

    static long offset = 0;

    private TimeProvider() {
    }

    public static long currentTimeMillis() {
        return System.currentTimeMillis() + offset;
    }

    public static Date now() {
        return new Date(currentTimeMillis());
    }

    public static Calendar calendar() {
        Calendar calendar = Calendar.getInstance();
        calendar.setTimeInMillis(currentTimeMillis());
        return calendar;
    }

}

public class Customer {

    ...

    public void buy(String productCode) {
        purchases.add(new Product(productCode, TimeProvider.now()));
    }

    ...

}

Ahora ya no es Customer la clase responsable de crear objetos Date, sino que los solicita a una clase externa, TimeProvider. De hecho, a partir de ahora ninguna clase en nuestro sistema debería jamás crear sus propias instancias de Date sino pedirlas siempre a TimeProvider.

Habréis notado algo raro en esta implementación de TimeProvider. ¿Qué es ese campo offset y para qué lo queremos? Offset no es ni más ni menos que nuestra implementación del concepto de design for testability. Veamos cómo funciona.

Supongamos, como caso más sencillo, que offset toma un valor de 0. A continuación, solicitamos a TimeProvider la fecha y hora actual llamando al método now(). Pero supongamos que offset tuviera como valor el incremento de tiempo correspondiente a tres días (en milisegundos) y le volvemos a pedir a TimeProvider la fecha y ahora actual, es decir, volvemos a llamar a now(). ¿Qué recibimos? La fecha y hora de dentro de tres días. ¿Y si todo nuestro sistema estuviera conectado a TimeProvider y cada vez que requiriese la hora actual llamara al método now()? Entonces nuestro sistema estaría viviendo en el futuro, tres días en el futuro, para ser exactos.

Para conseguir esto, vamos a construir una clase que solamente pueda ser utilizada por nuestro código de pruebas. Dicha clase nos permitirá ajustar el valor de offset dentro de TimeProvider de forma que siempre sume (o reste) una constante de tiempo al instante actual. Así es como acabamos de crear la máquina del tiempo:

public class TimeMachine {

    public static void reset() {
        TimeProvider.offset = 0;
    }

    public static void fastForward(long offset) {
        TimeProvider.offset += offset;
    }

    public static void rewind(long offset) {
        TimeProvider.offset -= offset;
    }

    public static void goTo(LocalDateTime localDateTime) {
        goTo(Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()));
    }

    public static void goTo(Date date) {

        goTo(date.getTime());

    }

    public static void goTo(long timestamp) {

        long currentTimestamp = TimeProvider.currentTimeMillis();

        if (currentTimestamp > timestamp) {

            TimeProvider.offset -= currentTimestamp - timestamp;

        } else {

            TimeProvider.offset += timestamp - currentTimestamp;

        }

    }

}

Básicamente esta clase sirve para establecer el valor de offset en TimeProvider. Además le he añadido algunos métodos adicionales para permitir especificar el instante de tiempo al que queremos viajar como Date, LocalDateTime, etc. pero la idea básica es esa: cambiar el offset de TimeProvider. Ahora podemos reescribir nuestro código de pruebas para la nueva versión de nuestra regla de negocio de una forma más clara y sencilla:

    @Test
    public void testIsSpecialOffersEligible_SeniorsUsersWithNoRecentPurchaseAreNotEligible() {
        LocalDateTime moreThanOneYearAgo = LocalDateTime.now().minusDays(366);
        LocalDateTime fortyDaysAgo = LocalDateTime.now().minusDays(40);
        LocalDateTime thirtyFiveDaysAgo = LocalDateTime.now().minusDays(35);
        TimeMachine.goTo(moreThanOneYearAgo);
        Customer oldCustomer = new Customer("00001", TimeProvider.now());
        TimeMachine.goTo(fortyDaysAgo);
        String aProductCode = "00001";
        oldCustomer.buy(aProductCode);
        TimeMachine.goTo(thirtyFiveDaysAgo);
        String anotherProductCode = "00002";
        oldCustomer.buy(anotherProductCode);

        assertThat(oldCustomer.isSpecialOffersEligible(), is(false));
    }

Ya no necesitamos cambiar la fecha de compra de los productos ya que ésta se va a establecer en el instante correcto cuando llamamos al método buy(). Nuestra clase Product vuelve a ser inmutable tal y como queríamos. De hecho, todos los tests que hemos creado hasta ahora pueden ser reescritos usando TimeMachine, lo que nos va a ayudar a hacerlos más legibles:

    @Test
    public void testIsSeniorCustomer() {
        LocalDateTime moreThanOneYearAgo = LocalDateTime.now().minusDays(366);
        TimeMachine.goTo(moreThanOneYearAgo);
        Customer oldCustomer = new Customer("00001", TimeProvider.now());

        assertThat(
                "Customer created more than one year ago should be senior", oldCustomer.isSenior(),
                is(true)
        );

        TimeMachine.goTo(LocalDateTime.now());
        Customer newCustomer = new Customer("00002", TimeProvider.now());

        assertThat(
                "Customer created right now should not be considered senior",
                newCustomer.isSenior(), is(false)
        );
    }

    @Test
    public void testBuy() {
        String aProductCode = "00001";
        String anotherProductCode = "00002";
        Customer customer = new Customer("00001", TimeProvider.now());

        customer.buy(aProductCode);
        customer.buy(anotherProductCode);

        assertThat(
                aProductCode + " not found in products bought by customer",
                customer.boughtProducts().stream()
                        .anyMatch((product) -> aProductCode.equals(product.code())), is(true)
        );
        assertThat(
                anotherProductCode + " not found in products bought by customer",
                customer.boughtProducts().stream()
                        .anyMatch((product) -> anotherProductCode.equals(product.code())), is(true)
        );
    }

    @Test
    public void testIsSpecialOffersEligible_SeniorsUsersWithRecentPurchasesAreEligible() {
        LocalDateTime moreThanOneYearAgo = LocalDateTime.now().minusDays(366);
        TimeMachine.goTo(moreThanOneYearAgo);
        Customer oldCustomer = new Customer("00001", TimeProvider.now());
        LocalDateTime oneWeekAgo = LocalDateTime.now().minusWeeks(1);
        TimeMachine.goTo(oneWeekAgo);
        String aProductCode = "00001";
        String anotherProductCode = "00002";
        oldCustomer.buy(aProductCode);
        oldCustomer.buy(anotherProductCode);

        assertThat(oldCustomer.isSpecialOffersEligible(), is(true));
    }

    @Test
    public void testIsSpecialOffersEligible_NewUsersNotEligible() {
        Customer newCustomer = new Customer("00001", TimeProvider.now());
        String aProductCode = "00001";
        String anotherProductCode = "00002";
        newCustomer.buy(aProductCode);
        newCustomer.buy(anotherProductCode);

        assertThat(newCustomer.isSpecialOffersEligible(), is(false));
    }

    ...

    @Test
    public void testIsRecentPurchase() {
        LocalDateTime moreThanOneMonthAgo = LocalDateTime.now().minusDays(32);
        TimeMachine.goTo(moreThanOneMonthAgo);

        Product oldPurchase = new Product("00001", TimeProvider.now());

        assertThat(
                "A purchase done more than one month ago should not be considered a recent purchase",
                oldPurchase.isRecent(), is(false)
        );

        LocalDateTime oneWeekAgo = LocalDateTime.now().minusWeeks(1);
        TimeMachine.goTo(oneWeekAgo);

        Product newPurchase = new Product("00002", TimeProvider.now());

        assertThat("A purchase done right now is a recent purchase", newPurchase.isRecent(), is(true));
    }

Sólo hay un detalle final que tenemos que tener en cuenta. Como debemos mantener las ejecuciones de los distintos tests independientes unas de otras, es preciso que offset vuelva a cero cada vez que terminamos con una prueba (y también asegurarnos de que es cero cuando una prueba comienza). Para eso, TimeMachine nos proporciona un método reset() que se encarga de ello.

    @Before
    public void setUp() {
        TimeMachine.reset();
    }

    @After
    public void tearDown() {
        TimeMachine.reset();
    }

En resumen, hemos llegado a una solución con un mejor reparto de responsabilidades entre las distintas clases del sistema. Esta solución nos ha ayudado a mantener el diseño inmutable que queríamos para nuestra clase Product y ha conseguido que los tests sean más fáciles de escribir y de leer.

Incluso en el caso de que no estemos demasiado interesados en la inmutabilidad de objetos, seguir principios de Design Driven Domain o sencillamente no nos importe llenar nuestras clases de métodos de acceso getters y setters (bueno, hay gente para todo), el tener un proveedor centralizado de fecha y hora para todo el sistema nos puede resultar muy útil cuando estamos construyendo lógica fuertemente ligada a una cronología de eventos y acontecimientos (por ejemplo, cuando estamos implementando un flujo de trabajo y queremos probarlo de forma integral).

Aquí termina la presentación de mi máquina del tiempo particular, una ligera variación del arcano conocimiento que me fue transmitido por Dmytro Iaroslavskyi, un auténtico fenómeno. Espero que os haya resultado interesante esta solución. Por supuesto, cualquier comentario sobre cómo podéis aplicarla, qué modificaciones haríais, qué puntos débiles le veis o cualquier otra cosa que os venga a la mente será más que bienvenida.

Tenéis el código fuente utilizado como ejemplo para esta entrada en https://github.com/joragupra/customer-process.