Volver

Buenas prácticas en testing (Cap.1)

Introducción

Este post trata de exponer una serie de buenas prácticas o trucos a la hora de realizar testing de código, y forma parte de una serie de capítulos que pretenden seguir con el propósito.

En esta primera iteración la idea es hablemos sobre TDD, programación funcional, patrones de diseño y estabilidad, todo esto orientado a los tests.

Vamos al laboratorio! 🧪

TDD en nuestros tests

Test-Driven Development (TDD) es una práctica de programación que consiste en escribir primero las pruebas, después escribir el código fuente que pase la prueba satisfactoriamente y, por último, refactorizar el código escrito.

Con esta práctica se consigue entre otras cosas: un código más robusto, más seguro, más mantenible y una mayor rapidez en el desarrollo. Además, logramos realizar pruebas más sencillas, ya que escribimos código productivo para nuestros tests, y no al revés. Ganamos en código simple y fácil de testear.

Para ver como el TDD mejora nuestros tests veamos un ejemplo siguiendo la práctica:

Kata fizzbuzz

Enlace a la kata: https://www.hackerrank.com/challenges/fizzbuzz/problem

Recomiendo que el siguiente ejercicio se haga siguiendo la metodología TDD.

  1. Todo comienza por un test que falla porque aún no tenemos implementado que hará la función a testear.
  2. Le escribes el mínimo código para que cumplamos nuestro test.
  3. Refactorizamos con la tranquilidad de que sabremos si estamos cambiando comportamiento en el código gracias a la ejecución de nuestro test

Básicamente, el problema nos propone lo siguiente:

Pasamos un número, y puede ocurrir lo siguiente:
	- Que sea divisible por tres y devuelva "fizz"
	- Que sea dividible por cinco y devuelva "buzz"
	- Que no sea dividible por ninguno de los anteriores
	y devuelva el mismo número
	- Que sea dividible por tres y por cinco y devuelva "fizzbuzz"

Manos a la obra! 👷🏻

Caso: Que no sea dividible por ninguno de los anteriores y devuelva el mismo número

Test

@Test
public void return_the_same_number_as_passed(){
    assertThat(Fizzbuzz.fizzbuzz(1)).isEqualTo("1");
}

Code

class Fizzbuzz{
    public static String fizzbuzz(int number){
        return String.valueOf(number);
}

De momento solo tenemos un caso de uso, asi que nos ceñimos a desarrollar lo mínimo

Caso: Que sea dividible por tres y devuelva “fizz”

Test

@Test
    public void return_the_same_number_as_passed(){
        assertThat(Fizzbuzz.fizzbuzz(1)).isEqualTo("1");
    }

    @Test
    public void return_fizz_if_is_divisible_by_3(){
        assertThat(Fizzbuzz.fizzbuzz(3)).isEqualTo("fizz");
    }

Code

class Fizzbuzz{
    public static String fizzbuzz(int number){
        if(number % 3 == 0){
            return "fizz";
        }
        return String.valueOf(number);
    }
}

Progresivamente vamos añadiendo lógica para los nuevos casos de uso sin romper los anteriores

Caso: Que sea dividible por cinco y devuelva “buzz”

Test

@Test
    public void return_the_same_number_as_passed(){
        assertThat(Fizzbuzz.fizzbuzz(1)).isEqualTo("1");
    }

    @Test
    public void return_fizz_if_is_divisible_by_3(){
        assertThat(Fizzbuzz.fizzbuzz(3)).isEqualTo("fizz");
    }

    @Test
    public void return_buzz_if_is_divisible_by_5(){
        assertThat(Fizzbuzz.fizzbuzz(5)).isEqualTo("buzz");
    }

Code

class Fizzbuzz{
    public static String fizzbuzz(int number){
        if(number % 3 == 0){
            return "fizz";
        }
        if(number % 5 == 0){
            return "buzz";
        }
        return String.valueOf(number);
    }
}

Progresivamente vamos añadiendo lógica para los nuevos casos de uso sin romper los anteriores

Caso: Que sea dividible por tres y por cinco y devuelva “fizzbuzz”

Test


@Test
    public void return_the_same_number_as_passed(){
        assertThat(Fizzbuzz.fizzbuzz(1)).isEqualTo("1");
    }

    @Test
    public void return_fizz_if_is_divisible_by_3(){
        assertThat(Fizzbuzz.fizzbuzz(3)).isEqualTo("fizz");
    }

    @Test
    public void return_buzz_if_is_divisible_by_5(){
        assertThat(Fizzbuzz.fizzbuzz(5)).isEqualTo("buzz");
    }

    @Test
    public void return_fizzbuzz_if_is_divisible_by_3_and_5(){
        assertThat(Fizzbuzz.fizzbuzz(15)).isEqualTo("fizzbuzz");
    }

Code

class Fizzbuzz{
    public static String fizzbuzz(int number){
        if (number % 3 == 0 && number % 5 == 0){
            return "fizzbuzz";
        }
        if(number % 3 == 0){
            return "fizz";
        }
        if(number % 5 == 0){
            return "buzz";
        }
        return String.valueOf(number);
    }
}

El ir implementando cada caso poco a poco con la lógica necesario para superar solo ese caso, ha hecho que lleguemos a una solución sencilla, y que escala a medida que necesitamos más casos de uso.

Test más legible, test más comestible 🍽

Programación declarativa frente a imperativa

Cuando hacemos uso de programación declarativa le estamos diciendo al código que haga algo, pero no el cómo lo tiene que ser, nosotros solo le pedimos una misión. Un ejemplo de este tipo de comportamiento es el lenguaje SQL, que por defecto funciona de tal manera. Si queremos buscar un cliente haríamos lo siguiente:

SELECT * FROM clients WHERE name = 'name';

Se ve muy fácil de leer, sobre todo porque no le indicamos como tiene que conseguir el propósito, eso son aspectos internos del propio de lenguaje.

Por el contrario, con programación imperativa no le decimos que queremos obtener, sino que nosotros mismo implementamos esa funcionalidad usando un lenguaje como pueda ser java. Siguiendo el ejemplo anterior para buscar un cliente dentro una lista tendríamos lo siguiente usando programación imperativa:

for (i = 0; i < clients.length(); i++) {
    Client client = client.get(i);
    if (client.name.equals(clientToBeFind)){
    return client;
    }
    }

O extrapolado a hacerte un café sería:

Programación declarativa

  • Prepara un café

Programación imperativa

  • Ve a la cocina
  • Échale agua a la cafetera, cafe y ponla al fuego
  • Espera a que salga el café y tráelo

El gran pro de usar la programación declarativa es la transparencia que te da de lo que está ocurriendo, te estás abstrayendo de toda complejidad existente. Además, no solo sabes que está pasando en todo momento de una forma más clara, sino que ganas en código impoluto, ya que mandar instrucciones a la aplicación será como leer un cuento.

El paradigma de la programación funcional con la declarativa

Como mencioné anteriormente, SQL funciona de tal forma que es declarativo, pero también tenemos otros lenguajes como Javascript que aunque no es declarativo, tiene muchas funciones predefinidas que llaman a utilizar la programación funcional. En Javascript el modo de iterar listas o arrays puede ser un proceso que sin terminar de ser declarativo del todo, es mucho más amigable que en otros lenguajes. Por ejemplo:

const ages = [12,32,32,53]
const selectedAges = ages.map(age => age += 10).filter(age => age > 40);

Como vemos tiene mucho parecido con la programación declarativa, ya que seguimos pidiendo cosas sin importar como las haga, como pueda ser el uso de map o filter, pero en el caso de map le tenemos que indicar que realizar con cada elemento de la lista, y en el caso de filter que elementos debe escoger y cuáles no. No termina de ser declarativo, pero nos permite tener esa limpieza abstrayéndonos de bucles complicados.

Este comportamiento de las funciones de javascript responde a que son funciones inmutables, es decir, que no alteran ningún aspecto de la aplicación como pueda ser el estado de un atributo de un objeto, una variable global, o lo que sea. Son funciones puras, por lo que siempre que pongamos la misma entrada saldrá la misma salida. Siempre devuelven el resultado de llamarlas por eso es por lo que funcionan como la programación funcional.

Patrones de diseño aplicados a testing

El uso de patrones de diseño en código productivo se hace con la finalidad de que nuestro código sea más mantenible, escalable, siga unas reglas de diseño que busquen organización y otros fines similares. Pero cuando se trata de test, estos no son una excepción. Nuestras pruebas automatizadas también pueden seguir unos patrones de diseño con el fin de mejorar la mantenibilidad y legibilidad.

Ahora veremos algunos ejemplos escritos en Kotlin sobre como mejorar la legibilidad y mantenibilidad de nuestros test mediante el uso de build pattern, object mother o named arguments. Vamos a poner en contexto una clase llamada User, en la que tenemos una regla de negocio que para apostar se debe tener una edad mínima.

Tradicional

private val minimumAgeToBet = 18
fun canBet(): Boolean {
    return userAge >= minimumAgeToBet
}

fun `user can bet with the minimum age`() {
    val userAge = 22
    val user = User("some name", userAge)

    assertTrue(user.canBet())
}

En este primer caso hemos creado directamente una instancia de la clase en nuestros test, por lo que cada uno tendrá la responsabilidad de mantenerse actualizado si la firma de la clase cambia, como por ejemplo que se añada un parámetro.

Builder Pattern

fun withAge(age: Int): UserBuilder {
    userAge = age
    return this
}

fun build(): User {
    return User(userName, userAge)
}

fun `user can bet with the minimum age`() {
    val userAge = 22
    val user = UserBuilder().withAge(20).build()

    assertTrue(user.canBet())
}

En este caso hemos hecho uso del patrón builder, que implicar tener un Builder encargado de construir nuestra clase e inicializarla a través de un build().

En el constructor habrá definidos unos valores por defecto para todos los atributos, y proporcionarnos unos setters que sirven para aportar semántica al test, ya que a la hora de establecer valores solo lo haremos en aquellos atributos que tiene importancia en el test.

Usando el patrón builder hemos sacado fuera de los test la responsabilidad de crear instancias de las clases, hemos ganado semántica y hemos hecho más declarativos nuestros tests, ahora muestran de una forma más clara que les interesa.

El patrón builder gana al método tradicional, pero tiene un problema, cuando tengamos muchos atributos a los que definirles un valor vamos a pasarlo un poco mal, veamos otro patrón que nos será aún más útil.

Object mother

companion object {
    fun userWithMinimumAgeToBet(): User {
        return User("name", 22)
    }
}

fun `user can bet with the minimum age`() {
    val user = UserFixtures.userWithMinimumAgeToBet()

    assertTrue(user.canBet())
}

En el ámbito de test hacemos uso del ObjectMother, que no es más que una clase de factoría que contiene una serie de métodos estáticos que nos permiten crear una instancia de la clase con mayor semántica. Hemos indicado el tipo de caso que queremos, sin entrar en detalles de cuál tiene que ser el valor exacto, por lo que hemos ganado en semántica y además tenemos un factor a nuestro favor y es que si mañana cambia la edad mínima o la forma en la que y en varios de nuestros test llamamos al método userWithMinimumAgeToBet, solo tendremos que cambiar o añadir variables o lo que haga falta en el UserFixtures, nuestro test no necesita ser modificado.

Programación funcional en nuestros test

Ahora voy a exponer un ejemplo real escrito en Kotlin de como el uso de programación declarativa junto con el patrón builder han sido una gran ventaja a la hora de desarrollar test que necesitaban realizar muchas aserciones para verificar el estado de registros persistidos en la base de datos.

Pongamos en contexto un contrato que tiene como propiedades:

  • Estado
  • Fecha de alta
  • Fecha de baja
  • Servicio contratado

Teniendo lo anterior presente, vamos a crear una clase que nos permita construir paso a paso lo que queremos comprobar. Lo que queremos verificar son las columnas de ciertas filas en base de datos, o lo que es lo mismo, queremos revisar la información almacenada para un pedido, por lo que cuando produzcamos una instancia de la clase que nos hará las aserciones le pasaremos el ID en cuestión. Por otro lado, iniciaremos un mapa en el que la clave del mapa será la columna en la tabla de base de datos y el valor del mapa será el valor en la tabla de base de datos para dicha columna.

Tendremos múltiples métodos para añadir columnas a comprobar, y cada uno de estos métodos devuelve la misma instancia de la clase para que podamos ir concatenando llamadas y agregar más columna que comprobar.

Finalmente, la última llamada que realizaremos será al método que hará las aserciones.

class OrderAssertion(private val orderId: Long, private val connection: DBConnection) {
    private val fieldsValuesMap: MutableMap<String, String> = mutableMapOf()

    fun hasStatus(status: String): OrderAssertion {
        fieldsValuesMap["status"] = status
        return this
    }

    fun hasOrderDate(date: String): OrderAssertion {
        fieldsValuesMap["order_date"] = date
        return this
    }

    fun hasProducts(products: String): OrderAssertion {
        fieldsValuesMap["products"] = products
        return this
    }

    fun doAssert() {
        try {
            assertOrder()
        } catch (assertionError: AssertionError) {
            throw assertionError
        } catch (unexpectedError: Exception) {
            throw AssertionError("Unexpected Error inside order assert", unexpectedError)
        }
    }

    @Throws(Exception::class)
    private fun assertOrder() {
        val sql = StringBuilder(500)
        sql.append("SELECT id")
        for (field in fieldsValuesMap.keys) {
            sql.append(", ").append(field)
        }
        sql.append(" FROM orders").append(" WHERE id = ").append(orderId)
        connection.executeSQL(sql.toString()).use { results ->
            if (results.moveNext()) {
                for ((field, expected) in fieldsValuesMap) {
                    val value: String = results.getString(field)
                    assertThat(value)
                        .withFailMessage(
                            "Wrong value for field '%s'. Expected is '%s' but actual was '%s'",
                            field,
                            expected,
                            value
                        ).isEqualTo(expected)
                }
            } else {
                fail("Order with ID '%s' not found", orderId)
            }
        }
    }
}

Nuestro test quedará muy claro y limpio, gracias al trabajo que hemos hecho en esta clase de aserciones que quita toda complejidad innecesaria para nuestro test.

fun `create a order`() {
    val orderId = "1234"
    val status = "in arrival"
    val orderDate = "2021-05-31 00:00:00"
    val products = "some products"

    orderService.makeOrder(orderId, status, products)

    assertThatOrderWithId(orderId)
        .hasStatus(status)
        .hasOrderDate(orderDate)
        .hasProducts(products)
        .doAssert()
}

Como si de un framework se tratara, hemos simplificado y abstraído del test la complejidad de hacer aserciones a la base de datos, y ha quedado un test muy fácil de leer. Así es, test legible, test más comestible.

Estabilidad en nuestros test

Como solucionar test que fallan aleatoriamente

Un problema que nos podemos encontrar a la hora de hacer test, es que tenemos algunos tests que algunas veces pasan y otras no. Esto se puede deber a que tenemos una batería de test en la que el orden de ejecución influye en el resultado de los mismos. El ejemplo más claro sería un test que persiste un registro y otro test que intenta recuperar ese mismo registro. Un test no debería depender de otros para poder funcionar correctamente, por lo que cada test debería comenzar desde cero y levantar o preparar todo lo que necesite para el caso de uso que esté probando, sin esperar que sea otro test el que prepare el escenario.

Por lo que tengo entendido, en JUnit (framework de unit test para JVM) la primera ejecución de los test en una máquina (tu pc) se hace forma aleatoria, pero después ejecutará los mismos test siempre en el mismo orden. Eso quiere decir que si otra máquina ejecuta esos mismos test por primera vez también será de manera aleatoria, pero solo una vez, el resto de veces se ejecutarán en el mismo orden. Tenemos un modo de indicar usando JUnit que queremos que la ejecución de nuestros test sea de modo aleatorio, y así asegurarnos que ninguno de nuestros test depende de otro. Simplemente, lograremos esta aleatoriedad estableciendo una etiqueta encima del nombre de la clase de test:

@TestMethodOrder(MethodOrderer.Random::class)
internal class SomeTest {
    @Test
    fun aTest() {
    }

    @Test
    fun aTest() {
    }

    @Test
    fun aTest() {
    }
}

Como forzar tests deterministas

Otro punto de error que puede surgir a la hora que realizamos test, es tener aserciones que dependen de variables y que además tienen mucha tendencia a cambiar. Es decir, si en un test estamos comprobando el retorno de una función contra una variable el test pasará en ocasiones si y en otras no. Esto se debe a que comparar el resultado contra dicha variable no es la mejor idea, ya que dicha variable estará cambiando su valor constantemente y, por tanto, no tendremos un test consistente y estará lanzándonos un error cuando puede ser que en realidad la función que estamos testeando está perfecta. Vamos a ver un ejemplo en Kotlin muy sencillo con una función que tiene que comprobar si en este momento actual es de día:

Por un lado, el método check() de la clase MorningChecker que devuelve true o false dependiendo de si es por la mañana o no.

class MorningChecker {
    fun isMorning(): Boolean {
        val hour: Int = now().hour
        val startOfMorning = 6
        val endOfMorning = 12
        return hour in startOfMorning..endOfMorning
    }
}

Por otro lado, el test que comprueba que cuando es por la mañana debería devolver true

class MorningCheckerShould {
    @Test
    fun `check if is morning`() {
        val morningChecker = MorningChecker()
        assertTrue(morningChecker.isMorning())
    }
}

Si estamos lanzando el test entre las 6 y las 12 nos dará un resultado correcto, pero en cualquier otro horario este test no pasaría. Nuestro método isMorning() está sacando la variable que determina que hora del día es usando una librería que te la proporciona en tiempo real, por lo que cuando vamos al test también se usa la fecha real. Vamos a cambiar un poco el panorama para que en vez de usar una variable calculada basándose en el tiempo real, usemos un parámetro:

Extraemos a un parámetro la hora del día

class MorningChecker {
    fun isMorning(hour: Int): Boolean {
        val startOfMorning = 6
        val endOfMorning = 12
        return hour in startOfMorning..endOfMorning
    }
}

Y finalmente en el test le pasamos la hora que queremos comprobar para ese test

class MorningCheckerShould {
    @Test
    fun `check if is morning`() {
        val morningChecker = MorningChecker()
        val actualHour = 8

        assertTrue(morningChecker.isMorning(actualHour))
    }
}

Ahora que el test tiene siempre la misma entrada, por ende tendrá siempre la misma salida. Sin sorpresas, solo lo que esperamos. Puede parecer un caso simple, pero la idea es plasmar que nuestro test no debería depender nunca de factores variables, que hacen que perdamos el control de lo que pasa y que en ocasiones provoquen que nuestro test no tenga siempre el mismo resultado para la misma entrada.

Estructura de los test

Cuando hablamos de entender un test no solo está sobre la mesa su contenido, sino que su contexto también no es de utilidad y muy importante que esté en su lugar correcto. Una buena organización y estructura de los archivos del proyecto es fundamental, pero si aplicamos arquitectura hexagonal vamos un paso más allá, ya que estaremos separando responsabilidades por capas y estaremos mejorando la mantenibilidad y escalabilidad de nuestro código. Pero la arquitectura hexagonal no exclusivamente la podemos aplicar a nuestro source code (código productivo), deberíamos seguir la misma idea para nuestro test, separándolos entre test que comprueban aspectos de la aplicación, el dominio o la infraestructura. Para aprender maś sobre arquitectura hexagonal puede ver un post realizado por mí:

https://raulpadilladelgado.github.io/blog/p/arquitectura-hexagonal/

Creado con Hugo
Tema Stack diseñado por Jimmy