Approval Test Driven Development

En los últimos meses, he estado trabajando en un proyecto nuevo que a grandes rasgos genera un manifiesto de Kubernetes a partir de un fichero de entrada en formato yaml muy sencillo. En cierto momento nos dimos cuenta de que nuestros tests eran muy mejorables, ya que validaban muy pocas cosas de un conjunto enorme de posibilidades, lo que apenas nos daba ninguna seguridad durante el desarrollo.

¿Qué son los Approval Tests?

Los Approval Tests son una técnica de desarrollo de software que permite validar estructuras de datos complejas que requieren más de una simple aserción.

La librería Approval Tests está disponible en multitud de lenguajes de programación, lo que hace que sea muy fácil de integrar en prácticamente cualquier proyecto. En nuestro caso concreto, la librería nos ha permitido validar el manifiesto de Kubernetes generado por nuestra aplicación de una manera muy sencilla, efectiva y sin apenas esfuerzo.

El comportamiento de Approval Tests es muy sencillo, cuando se ejecuta un test, la librería te genera un par de ficheros, uno con el resultado esperado y otro con el resultado obtenido. Cuando ambos ficheros son iguales, el test pasa, en caso contrario, el test falla.

En nuestro caso ya sabíamos cuál debería ser el resultado final, por tanto, nuestro objetivo se redujo a ir implementando la lógica de negocio hasta que el test estuvo en verde.

Lo mejor es que el test, como veremos después, es supersencillo y validamos el 100% del manifiesto.

¿Qué es Test Driven Development?

TDD es una técnica de desarrollo de software que consiste en escribir los tests antes de comenzar con el desarrollo de una funcionalidad. De esta manera, se garantiza que el código que se escribe cumple con los requisitos especificados en los tests.

Personalmente, soy un gran defensor de esta técnica, ya que me ha permitido escribir código de forma mucho más segura a lo largo de los ultimos años. Además, me ha ayudado a mejorar mis habilidades de diseño y a escribir el código justo y necesario para pasar los tests, sin irme por las ramas, ni reinventando la rueda.

¿Y por qué no combinarlos?

Habitualmente los Approval Tests se basan en iterar sobre un resultado, teniendo que aceptar el nuevo fichero con el resultado recibido en cada iteración, hasta llegar al resultado esperado. En nuestro caso sabíamos, incluso antes de empezar el desarrollo, como tenía que ser el manifiesto de Kubernetes que ibamos a generar, por tanto, una pregunta que nos hicimos fue: ¿Por qué no hacemos TDD pero con Approval Tests en lugar de usar tests?

Escenario real

Siempre me gusta buscar un ejemplo lo más real posible, que me permita ilustrar de la forma más clara posible el concepto que quiero transmitir.

Appernetes

En este caso, vamos a imaginar que estamos desarrollando una aplicación que lee un fichero de entrada en formato yaml en el que puedes definir de forma muy sencilla tus aplicaciones con su infra asociada (Buckets, Postgres, Redis, etc.).

application:
  name: my-app
  repo: https://github.com/my-repository

services:
  - name: my-app
    image: my-image
    cpu: 250m
    memory: 256Mi
    replicas: 2
    port: 8501

  - name: other-app
    ...

databases:
  - name: my-db
    engine: postgresql
  - name: my-redis
    engine: redis

buckets:
  - name: my-bucket

Este fichero es leído por nuestra aplicación generando un manifiesto de Kubernetes bastante complejo a su salida que puede contener una gran cantidad de elementos como Deployments, Services, Ingress, etc:

...
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-deployment-name
spec:
  replicas: 1
  template:
    spec:
      containers:
        image: my-image
...
---
apiVersion: v1
kind: Service
metadata:
  name: my-service-name
spec:
  ...
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: my-service-account-name
spec:
  ...
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-ingress-name
spec:
  ...
---
apiVersion: v1
kind: Secret
metadata:
  name: my-secret-name
spec:
  ...
---
apiVersion: iam.aws.upbound.io/v1beta1
kind: Role
metadata:
  name: my-role-name
spec:
  ...
---
apiVersion: s3.aws.upbound.io/v1beta1
kind: Bucket
metadata:
  name: my-bucket-name
spec:
  ...

Como ya estaréis pensando, validar un manifiesto tan complejo como esté con aserciones es un dolor.

Inicialmente, nuestros tests eran algo así:

class TestManifest:
    def test_generates_a_complete_manifest(self) -> None:
        ...
        appernetes = Appernetes(...)

        manifest = appernetes.synth()
        components = manifest.split("---")

        deployment = yaml.safe_load(components[0])
        expect(deployment).not_to(be_empty)

Básicamente, el test generaba un manifiesto válido, pero solo validaba que el manifiesto no estaba vacío.

Esto se podría mejorar un poco simplemente añadiendo algunas aserciones mas concretas, pero siendo realistas este cambio no aporta prácticamente nada:

class TestManifest:
    def test_generates_a_complete_manifest(self) -> None:
        ...
        appernetes = Appernetes(...)

        manifest = appernetes.synth()
        components = manifest.split("---")

        deployment = yaml.safe_load(components[0])
        expect(deployment["kind"]).to(equal("Deployment"))
        expect(deployment["metadata"]["name"]).to(equal("my-deployment-name"))
        expect(deployment["spec"]["container"]["image"]).to(equal("my-image"))

A simple vista ya se pueden ver algunos problemas a esta aproximación:

  • El test es poco legible.
  • El test no válida todos los componentes del manifiesto.
  • El test no válida el contenido de los componentes.
  • El test no aporta seguridad, muchos cambios en el manifiesto no romperán el test.

¿Cómo lo haríamos con Approval Tests?

Si usamos Approval Tests, con un esfuerzo mínimo el test se simplifica mucho pasando a validar todo el manifiesto.

En nuestro caso dado que conocemos el manifiesto de salida que queremos crear a partir de un fichero de entrada dado, podemos crear del fichero final que genera la librería Approval Tests llamado TestManifiest.test_generates_a_complete_manifest.approved.txt y simplemente ir implementando la lógica de nuestra aplicación hasta que el test esté en verde.

El nuevo test sería algo así:

class TestManifest:
    def test_generates_a_complete_manifest(self) -> None:
        ...
        appernetes = Appernetes(...)

        manifest = appernetes.synth()

        verify(manifest)

Básicamente, tenemos un método verify que es el encargado de leer el manifiesto y compararlo con un valor esperado. Esta aproximación tiene varias ventajas:

  • El test es legible.
  • El test valida todos los componentes del manifiesto.
  • El test valida el contenido de los componentes.
  • El test aporta seguridad, cualquier cambio en el manifiesto romperá el test.

Conclusiones

Una vez que empezamos a usar Approval Tests en nuestro proyecto, fue para nosotros trivial crear otros tests que nos permitieran validar otros tipos de manifiestos.

Usar esta librería nos ha permitido validar el 100% del contenido del manifiesto, lo que nos ha dado mucha seguridad durante el desarrollo.