Stack TDD
Getting your Trinity Audio player ready...

Por poner el broche de oro a esta mini-serie de artículos sobre TDD, vamos a ver un caso práctico con algo muy sencillo que todos conocéis: una pila. Por si queda algún despistado o despistada, una pila es una lista ordenada que permite almacenar y recuperar valores. El acceso a estos datos sigue el patrón LIFO (del inglés Last In, First Out, "el último en entrar es el primero en salir"). Básicamente, podéis pensar en la típica pila de platos, en la que el último plato que se pone encima, será el primero que quitemos.

Para este caso, se usará JavaScript estándar, haciendo uso de clases. De todas formas, si alguien tiene dudas sobre algo del lenguaje, podéis preguntarme en comentarios que responderé con todo gusto.

Primer test. Crear una instancia.

Como estamos usando TDD, lo primero de todo será definir el test a probar:

describe("Stack", () => {
    test("should create a stack instance", () => {
        const stack = new Stack();
        expect(typeof stack).toBe("object");
     });
});

Si ahora ejecutamos el test, fallará como cabe espera:

ReferenceError: Stack is not defined

      1 | describe("Stack", () => {
      2 |       test("should create a stack instance", () => {
    > 3 |               const stack = new Stack();
        |                             ^
      4 |
      5 |               expect(typeof stack).toBe("object");
      6 |       });

      at Object.<anonymous> (stack.test.js:3:17)

Tenemos que escribir el código mínimo para que el test pase, así que, crearemos la clase:

class Stack {
}

Y tendremos nuestro test pasado:

 PASS  ./stack.test.js
  Stack
should create a stack instance (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.412 s, estimated 1 s

De aquí en adelante, ya no os pondré la salida de error y de passed por consola 😉.

Segundo test. Null al quitar un elemento de la pila vacía.

En este segundo test, vamos a verificar que si ejecutamos la operación de quitar un elemento de la pila, y esta se encuentra vacía, se devuelve el valor null.

test("should return null when pop an empty stack", () => {
    const stack = new Stack();

    expect(stack.pop()).toBeNull();
});

Para hacer que el test pase, como tenemos que escribir el código mínimo, simplemente crearemos un método pop() que devolverá null al ejecutarlo.

class Stack {
    pop() {
        return null;
    }
}

Tercer test. Añadir un valor y eliminarlo.

Ahora tenemos que probar que si añadimos un valor y lo eliminamos se obtiene ese mismo valor.

test("should add value in the top of the stack and return this value when do a pop", () => {
    const stack = new Stack();

    stack.push("hello");

    expect(stack.pop()).toBe("hello");
});

Ahora el código de la clase debería seguir haciendo lo mínimo posible, pero vamos a ir un paso más allá (para no eternizar el ejemplo), y nos vamos a la fase de refactor, tras la que tendríamos:

class Stack {
    #lastValue = null;

    push(value) {
        this.#lastValue = value;
    }

    pop() {
        return this.#lastValue;
    }
}

Cuarto test. Devolver null si no quedan elementos por quitar

test("should return null after pop the last element", () => {
    const stack = new Stack();

    stack.push("value");
    stack.pop();

    expect(stack.pop()).toBeNull();
});

class Stack {
    #lastValue = null;

    push(value) {
        this.#lastValue = value;
    }

    pop() {
        const lastValue = this.#lastValue;
        this.#lastValue = null;
        return lastValue;
    }
}

Obsérvese que como aún no hemos gestionado más de un objeto en la cola, no estamos controlando que haya varios, es por ello que en este paso, asignaremos a null el valor, en cuanto llamemos a pop(). Y esto es por la regla de escribir el mínimo código posible.

Quinto test. Añadir y eliminar varios valores a la pila

test("should add several values to the top and remove then until arrive to null", () => {
    const stack = new Stack();

    stack.push("value1");
    stack.push("value2");
    stack.push("value3");

    expect(stack.pop()).toBe("value3");
    expect(stack.pop()).toBe("value2");
    expect(stack.pop()).toBe("value1");
    expect(stack.pop()).toBeNull();
});

Es necesario hacer una aclaración en esta parte, ya que si en el test, sólo hubiésemos añadido la última comprobación, omitiendo el resto de expects, no se hubiera podido verificar la pila nos devuelve cada valor eliminado, y según el código que ya teníamos, el test pasaría. Es por ello, que tenemos que dar mucha importancia a qué se quiere probar, porque un test que no esté bien escrito, es como si no existiera. En estos casos también es importante tener en cuenta, que si escribimos un test, y pasa sin tocar nada de código, puede que no estemos probando lo que queremos.

Tras un refactor, en este caso tendremos lo siguiente:

class StackElement {
    #value = null;
    #previousElement = null;

    constructor(value, previousElement) {
        this.#value = value;
        this.#previousElement = previousElement;
    }

    get Value() {
        return this.#value;
    }

    get PreviousElement() {
        return this.#previousElement;
    }
}

class Stack {
    #lastElement = null;

    push(value) {
        this.#lastElement = new StackElement(value, this.#lastElement);
    }

    pop() {
        if (!this.#lastElement) {
            return null;
        }

        const lastValue = this.#lastElement.Value;
        this.#lastElement = this.#lastElement.PreviousElement;
        return lastValue;
    }
}

Se ha incluido una nueva clase, y en este caso, no hemos añadido tests para dicha clase, ya que es un getter/setter. Añadir tests en estos casos no tendría mucho sentido. Sin embargo, si hubiésemos necesitado una clase más compleja. Si que en este momento pausaríamos el test actual, para comenzar el desarrollo de la nueva clase con TDD. Una vez que hiciera todo lo que necesitamos, volveríamos a este test.

Sexto test. Devolver longitud 0 cuando la pila está vacía.

test("should return zero when stack is empty", () => {
    const stack = new Stack();

    expect(stack.Length).toBe(0);
});

class Stack {
    #lastElement = null;

    get Length() {
        return 0;
    }
    
    // El resto del código no tiene cambios
}

Séptimo test. Obtener el número de elementos añadidos.

test("should return the number of elements in stack after add some values", () => {
    const stack = new Stack();

    stack.push(1);
    stack.push(2);
    stack.push(3);

    expect(stack.Length).toBe(3);
});

class Stack {
    #lastElement = null;
    #length = 0;

    get Length() {
        return this.#length;
    }

    push(value) {
        this.#lastElement = new StackElement(value, this.#lastElement);
        ++this.#length;
    }

    // El resto del código no tiene cambios
}

En este punto, como el test sólo ha probado el añadir elementos, no tenemos que tocar el resto del código, sólo la parte de añadir. Aunque sea cansino, recordad que vamos a mínimos en cada paso.

Último test. Obtener el número de elementos tras añadir y eliminar varios elementos.

test("should return the number of elements in stack after add and remove some values", () => {
    const stack = new Stack();

    stack.push(1);
    stack.push(2);
    stack.pop();
    stack.push(3);
    stack.pop();

    expect(stack.Length).toBe(1);
});

class Stack {
    #lastElement = null;
    #length = 0;

    get Length() {
        return this.#length;
    }

    push(value) {
        this.#lastElement = new StackElement(value, this.#lastElement);
        ++this.#length;
    }

    pop() {
        if (!this.#lastElement) {
            return null;
        }

        const lastValue = this.#lastElement.Value;
        this.#lastElement = this.#lastElement.PreviousElement;
        --this.#length;
        return lastValue;
    }
}

Conclusiones.

Después de haber visto un caso de uso de TDD, creo que podemos concluir que esta técnica es una herramienta valiosa para garantizar la calidad del software y mejorar la eficiencia del proceso de desarrollo. Al escribir las pruebas antes de escribir el código, se obliga a pensar cuidadosamente en los requisitos del software y en cómo se comportará en diferentes situaciones. El enfoque incremental de TDD también significa que el software se desarrolla en pequeños pasos, lo que facilita la detección de errores y la resolución de problemas antes de que se conviertan en problemas mayores.

¿Os ha parecido interesante? ¿tenéis dudas o sugerencias? Pues ya sabéis, podéis dejar un mensajito en la caja de comentarios.

Comparte este artículo con quien quieras
Testing y TDD. Algunos consejos
Hablemos de acoplamiento y cohesión. Parte 1

Leave a Comment

Your email address will not be published. Required fields are marked *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.