Test builders en TypeScript

Tests más legibles y sólidos. Un tema recurrente en este blog. Ya he escrito sobre por qué es importante evitar depender de APIs no testeadas para conseguir obtener más rendimiento de nuestra estrategia de testing, y hemos visto varias técnicas para conseguirlo utilizando el patrón Builder, ObjectMother o Factory Methods.

¿Por qué volver a escribir sobre builders, ahora en TypeScript? Porque después de los últimos posts en los que veíamos las implicaciones de los sistemas de tipos nominales y estructurales y las dificultades para mantener invariantes en TypeScript, éste es un buen ejemplo de cómo esas características del lenguaje acaban influyendo en la forma de diseñar las cosas. Al menos en mi caso.

El enfoque en lenguajes basados en clases

No voy a entrar en detalles sobre el problema que queremos resolver porque ya lo analicé en profundidad en los posts que enlazaba en la cabecera, pero la idea básica es poder construir objetos que necesitamos en nuestros tests sin acoplar el código de test a constructores y APIs que no son relevantes para el propio test.

Para ello, en lenguajes basados en clases como C# o Java se suelen usar técnicas como ObjectMother, Builders, o Factory Methods que tienen un aspecto parecido a éste:

// Usando un ObjectMother
var customer = ObjectMother.Customer;

// Usando un Builder
Customer customer = Build.Customer('Paco')
   .Preferred()
   .From('Madrid');

// Usando un Factory Method
var customer = Customer(name: 'Paco', city: 'Madrid');

En todos los casos el motivo real de recurrir a estas construcciones es que nuestro modelo de objetos debería garantizar que se cumplen sus invariantes y para ello necesitamos construirlos de una forma determinada o invocar métodos que requieren más parámetros que los relevantes para el test. Lo que es una ventaja para el código de producción (modelo de objetos rico, bien encapsulado, que evita acabar en estados inválidos), se convierte en un inconveniente a la hora de escribir tests.

Qué ocurre en TypeScript

Como ya he mencionado anteriormente, en TypeScript trato de huir del uso de clases, en parte por evitar la complejidad que introducen, y en parte porque tampoco ayudan demasiado a mantener invariantes. Eso hace que tienda a utilizar estructuras de datos más sencillas, sin lógica asociada, y mantener la lógica en funciones aparte.

Nuestro Customer de los ejemplos anteriores podría ser algo así:

type Customer = {
    id: number;
    name: string;
    phone: string;
    email: string;
    isPreferred: boolean;
    address: {
        street: string;
        city: string;
        region: string;
        zipCode: string;
        country: {
            isoCode: string;
            name: string;
        }
    };
    productCategories: Array<{ id: number; name: string; }>
}

En esta estructura de datos recogemos la información que nos interesa de un cliente, incluyendo sus datos básicos, su dirección y las categorías de productos en las que pueda estar interesado. Probablemente podríamos definir el tipo como inmutable (y de hecho nosotros lo vamos a tratar como tal), pero no es necesario. En general, es poco probable que tengamos que construir objetos de tipo Customer en nuestra aplicación porque la mayoría de las veces procederán de una llamada a un servidor (que usará el modelo que considere oportuno para generarlos) y los construiremos deserializando el JSON de turno.

Sin embargo, cuando llega el momento de escribir tests sobre código que opera con Customer, no nos queda más remedio que construir objetos válidos (bueno, al menos que tengan la estructura adecuada), y tenemos los mismos problemas que en C#: tests demasiado acoplados a la estructura del tipo Customer y con un montón de detalles irrelevantes para el test.

Aunque se podrían aplicar exactamente las mismas técnicas que en C#, en TypeScript prefiero un enfoque basado en funciones. Así, para crear un Customer del que no nos importa ningún dato concreto, podríamos tener esta función:

function aCustomer(): Customer {
    return {
        id: 12,
        name: 'Marcelino',
        phone: '91 555 55 66',
        email: 'marcelino@gmail.com',
        isPreferred: false,
        address: {
            street: 'Calle del Tomate, 12',
            city: 'Parla',
            region: 'Madrid',
            zipCode: '28106',
            country: {
                isoCode: 'ES',
                name: 'España'
            }
        },
        productCategories: [
            { id: 5, name: 'punto de cruz' },
            { id: 9, name: 'macramé' }
        ]
    };
}

Esto es equivalente al uso de ObjectMother y nos permite obtener cómodamente objetos Customer y que éstos tengan información «correcta», aunque no nos deja configurarlos mucho. Podríamos tener más funciones, aCustomerFromMadrid, aPreferredCustomer, etc., pero es fácil ver que, al igual que el ObjectMother, esto no escala cuando necesitamos cubrir muchos casos diferentes.

Mi opción preferida para solucionar esto se basa en usar tipos mapeados para poder personalizar las partes del objeto Customer que nos interesan. Es un patrón muy habitual en Javascript y que podemos aplicar sin problemas en TypeScript. Vamos a modificar nuestra función para construir clientes:

function aCustomer(...options: Partial<Customer>[]) {
  let defaults: Customer = { 
    /* valores por defecto del cliente */ 
  };
  return Object.assign({}, defaults, ...options);
}

De esta forma, si queremos construir clientes en los que modificamos algún dato, basta con sobreescribirlo:

const paco = aCustomer({name: 'Paco'}, {email: 'paco@gmail.com'});

Un par de puntualizaciones sobre esto.

En realidad, sería más adecuado usar un tipo Pick en lugar de un Partial, pero eso complicaría la signatura de la función aCustomer y de las que vamos a definir dentro de un momento, por lo que no sé si compensa.

Además, en lugar de pasar un array de opciones, podríamos pasar un único objeto que contuviera todos los cambios, pero usar una función variádica tiene la ventaja de que nos permite invocar la función aCustomer sin pasar ningún parámetro cuando nos sirven los valores por defecto, y nos facilita la composición de distintas personalizaciones como veremos más adelante.

Jugando con esta idea tan simple, podemos empezar a añadir funciones y constantes para personalizar el cliente según vayamos necesitando. Por ejemplo, si necesitamos construir clientes preferentes, tendríamos:

const preferred = { isPreferred: true };


// En algún test...
const vip = aCustomer(preferred);

Con esto, en nuestro test quedaría bastante claro que sólo nos importa si el cliente es preferente o no. Si necesitamos mayor configuración, es sencillo, recurrimos a funciones:

const named = (name: string) => ({name});

const isFrom = (city: string) => ({
  address: {
    street: 'Calle del Tomate, 12',
    city,
    region: 'Madrid',
    zipCode: '28106',
    country: {
      isoCode: 'ES',
      name: 'España'
    }
  }
});

const likes = (...categories: string[]) => ({
  productCategories: categories.map((name, id) => ({id, name}))
});


// En algún test...
const paco = aCustomer(named('Paco'), isFrom('Madrid'));
const juan = aCustomer(likes('Punto de Cruz', 'Macramé'));

De esta forma quedaría claro que de paco lo que nos importa en el test es el nombre y la ciudad en que vive, y de juan las categorías de productos en que está interesado, facilitando la lectura del código de test y evitando que quede acopado a la estructura interna del tipo Customer.

Lo bueno de esta forma de trabajar es que nos permite incrementar de forma progresiva la complejidad de nuestros builders sin necesidad de modificar el código existente (os suena el Open Closed Principle, ¿verdad?).

Empezamos utilizando sólo la función aCustomer y objetos literales para hacer la personalización. Si queremos mejorar la legibilidad o escribir un poco menos, podemos utilizar una constante, como hacemos con preferred, y si más adelante tenemos que dar soporte a más opciones para ese valor, podemos convertirlo en una función con los parámetros adecuados.

Podemos partir de funciones que hacen todo inline, como isFrom, pero si empezamos a necesitar construir muchos tipos de direcciones podemos añadir una función anAddress para construir direcciones personalizadas usando la misma técnica que estamos empleando con Customer.

La ventaja es que cada función es completamente independiente de las demás (no están acopladas a través del estado de una mismas clase, como ocurre en un builder), y eso nos da mucha flexibilidad para hacer con ellas lo que queramos y añadir soporte para nuevos escenarios. Lo único que necesitamos es definir nuevas funciones que devuelvan Partial<Customer>.

Conclusión

Está claro que tener test legibles y sólidos es importante y es algo que he repetido en muchas ocasiones. En realidad, ese no es el objetivo de este post, aunque nunca está de más recordarlo. Lo interesante es comparar la forma de trabajar en diferentes lenguajes y cómo, aunque el objetivo sea el mismo, los diseños que empleamos pueden (y deben) adaptarse a las características que ofrece cada uno.

En el caso de TypeScript, podemos aprovechar su tipado estructural y los tipos mapeados para diseñar un sistema que nos permita construir objetos de forma cómoda, legible y que haga explícito lo que nos importa en cada momento. Por el camino, conseguimos además desacoplar los test de los detalles estructurales del tipo del que dependen.

No se trata de que esta sea una técnica mejor o peor que las típicas que encontramos en lenguajes basados en clases. Tan sólo es diferente. A día de hoy a mi me resulta cómoda y atractiva, pero nunca hay que olvidar que en esto influyen también preferencias personales.