In questo articolo vedremo come applicare il Test-Driven Development (TDD) ad un progetto reale in Node.js.

TDD con Jest

L'approccio TDD si basa su un principio molto semplice: i test vengono scritti prima del codice necessario a superarli. In questo modo si è certi che l'implementazione finale sarà conforme alle specifiche delineate nei test.

Un framework molto usato per i test in Node e JavaScript è Jest. Jest va installato con NPM tra le dipendenze di sviluppo. Quindi il comando da digitare dalla shell è il seguente:

npm install jest --save-dev

Una volta installato, Jest va configurato nel file package.json per poter operare con Node.

"scripts": {
   "start": "node index.js",
   "test": "jest --watchAll"
}

watchAll fa in modo che Jest monitori tutti i file con la doppia estensione .test.js (ossia i file di Jest usati per i test) una volta lanciato il comando npm run test. Se i file vengono modificati, Jest esegue nuovamente i test. Questi file sono normali file JavaScript che Jest riconosce come file delegati per i test. È buona pratica salvare questi file in un'unica directory chiamata tests anche se Jest è in grado di individuarli in tutta la nostra applicazione.

Jest organizza i test in modo gerarchico. All'interno di un file di test, la funzione describe() di Jest dichiara una suite o macrosezione di test identificati dalla stringa passata come primo parametro e definiti nella funzione di callback passata come secondo parametro. Tutto quello che viene definito nei file di test viene mandato in output da Jest sulla shell.

'use strict';

describe('Tests on APIs', () => {

});

All'interno di una macrosezione possono essere definite delle sottosezioni logiche:

'use strict';

describe('Tests on APIs', () => {
    describe('GET endpoints', () => {
    
    });
    describe('POST endpoints', () => {
    
    });
});

All'interno di ciascuna sezione possiamo usare la funzione it() per creare un test. Questa funzione accetta una stringa di descrizione ed una funzione di callback. La descrizione indica di solito cosa ci si aspetta come esito dall'esecuzione del test. L'aspettativa viene inserita come espressione logica nella funzione expect() e quindi verificata con uno dei metodi delle API di Jest.

'use strict';

const axios = require('axios');

describe('Tests on APIs', () => {
    describe('GET endpoints', () => {
            it('/posts should return an array of posts', async () => {
                const response = await axios.get('https://api.site.tld/posts');
                
                expect(Array.isArray(response.data)).toBe(true);
            });
    });
});

La condizione in questo caso deve essere true nel caso in cui la richiesta GET restituisca un array di oggetti. Per conoscere meglio i metodi disponibili per la funzione expect(), consigliamo vivamente di consultare la documentazione.

Creare un carrello di un e-commerce

Creeremo una classe Cart per gestire la logica del nostro carrello ed una classe Product per avere un modello dei prodotti da aggiungere, rimuovere o aggiornare.

Il primo test verifica che la nostra classe abbia tre proprietà fondamentali, ossia un array di elementi, un totale ed un eventuale tassa globale.

'use strict';

const Cart = require('../classes/Cart');
const Product = require('../classes/Product');

describe('Cart Properties', () => {
        let instance;
        beforeEach(() => {
            instance = new Cart();
        });
        it('should have an `items` property which is an array', () => {
            
            expect(Array.isArray(instance.items)).toBe(true);
        });
        it('should have a `total` property which is a number', () => {

            expect(typeof instance.total === 'number').toBe(true);
        });
        it('should have a `fee` property which is a number', () => {

            expect(typeof instance.fee === 'number').toBe(true);
        });
});

All'inizio i test falliranno, perché non abbiamo definito alcuna classe con quelle specifiche. Definiamo quindi la classe Cart.

'use strict';

const Product = require('./Product');

class Cart {
    constructor() {
        this.items = [];
        this.total = 0.00;
        this.fee = 0.00;
    }
}

module.exports = Cart;    

Abbiamo anche bisogno della classe Product ovviamente.

'use strict';

class Product {
    constructor( { name, price, id } ) {
        this.name = name;
        this.price = price;
        this.id = id;
    }
}

module.exports = Product;

A questo punto il primo test avrà successo. Passiamo quindi all'aggiunta di prodotti al carrello. Vogliamo che il metodo addToCart() aggiunga al carrello sono le istanze della classe Product e che non aggiunga più volte lo stesso prodotto. Definiamo quindi i nostri test.

describe('Cart Methods', () => {
        let instance;
        beforeEach(() => {
            instance = new Cart();
        });
        it('`addToCart()` should add a valid Product instance to the `items` array', () => {
            instance.addToCart({ product: new Product({ name: 'Test', price: 50, id: 1 }), quantity: 1});
            expect(instance.items.length === 1).toBe(true);
        });
        it('`addToCart()` should not add duplicate products', () => {
            instance.addToCart({ product: new Product({ name: 'Test', price: 50, id: 1 }), quantity: 1});
            instance.addToCart({ product: new Product({ name: 'Test', price: 50, id: 1 }), quantity: 1});
            expect(instance.items.length === 1).toBe(true);
        });
});

Anche in questo caso i test falliranno la prima volta perché non abbiamo ancora implementato la logica di quel metodo. Riprendiamo quindi la classe Cart ed aggiungiamo il metodo richiesto.

addToCart({product, quantity}) {
   if(product instanceof Product && !this.isInCart(product)) {
        this.items.push({
           product,
           quantity
        });

          
   }
}

isInCart(product) {
        const foundProduct = this.items.find(prod => { return prod.product.id === product.id } );
        return foundProduct ? true : false;
}

La verifica della presenza di un prodotto nel carrello è stata delegata ad un metodo accessorio. A questo punto vogliamo un metodo per calcolare il totale. Questo metodo deve essere invocato dopo l'aggiunta di un prodotto al carrello e il suo calcolo dovrà tenere conto dell'eventuale tassa globale aggiunta al carrello. Con questi criteri, definiamo i nostri test.

it('`calculateTotal()` should calculate the cart total', () => {
            instance.addToCart({ product: new Product({ name: 'Test', price: 50, id: 1 }), quantity: 1});
            instance.addToCart({ product: new Product({ name: 'Test 2', price: 50, id: 2 }), quantity: 2});
            instance.calculateTotal();

            expect(instance.total).toEqual(150);
        });
        it('`calculateTotal()` should be called after adding a product', () => {
            instance.addToCart({ product: new Product({ name: 'Test', price: 50, id: 1 }), quantity: 1});
            instance.addToCart({ product: new Product({ name: 'Test 2', price: 50, id: 2 }), quantity: 1});

            expect(instance.total).toEqual(100);
        });
        it('`calculateTotal()` should take the cart fee into account', () => {
            instance.fee = 10;
            instance.addToCart({ product: new Product({ name: 'Test', price: 50, id: 1 }), quantity: 1});
            instance.addToCart({ product: new Product({ name: 'Test 2', price: 50, id: 2 }), quantity: 1});
            
            expect(instance.total).toEqual(110);
        });

Per superare questi test dobbiamo definire un nuovo metodo e modificare il metodo addToCart().

addToCart({product, quantity}) {
   if(product instanceof Product && !this.isInCart(product)) {
        this.items.push({
           product,
           quantity
        });

        this.calculateTotal();  
   }
}

calculateTotal() {
        let total = 0.00;
        for(let item of this.items) {
            let partial = item.product.price * item.quantity;
            total += partial;
        }
        this.total = total + this.fee;
}

Dobbiamo poter essere anche in grado di rimuovere un elemento dal carrello ed avere il totale aggiornato. Definiamo un nuovo test che richiede un altro metodo specifico.

it('`removeItem()` removes a product from the cart and recalculates total', () => {
            instance.addToCart({ product: new Product({ name: 'Test', price: 50, id: 1 }), quantity: 1});
            instance.addToCart({ product: new Product({ name: 'Test 2', price: 50, id: 2 }), quantity: 1});
            instance.removeItem(2);
            expect(instance.items.length).toEqual(1);
            expect(instance.total).toEqual(50);
});

Quindi implementiamo il metodo come da specifiche.

removeItem(productId) {
        for(let i = 0; i < this.items.length; i++) {
            if(this.items[i].product.id === productId) {
                this.items.splice(i, 1);
            }
        }
        this.calculateTotal();
}

Infine, dobbiamo poter aggiornare un articolo con una nuova quantità da cui ne consegue che anche il totale dovrà essere ricalcolato. Inoltre dobbiamo evitare che venga inviato 0 come quantità. I test richiedono un nuovo metodo.

it('`updateCart()` updates the quantity of a given product and recalculates total', () => {
            instance.addToCart({ product: new Product({ name: 'Test', price: 50, id: 1 }), quantity: 1});
            instance.addToCart({ product: new Product({ name: 'Test 2', price: 50, id: 2 }), quantity: 1});
            instance.updateCart({ id: 2, quantity: 3});
            expect(instance.items[1].quantity).toEqual(3);
            expect(instance.total).toEqual(200);
        });

        it('`updateCart()` should not update the quantity if it is 0', () => {
            instance.addToCart({ product: new Product({ name: 'Test', price: 50, id: 1 }), quantity: 1});
            instance.addToCart({ product: new Product({ name: 'Test 2', price: 50, id: 2 }), quantity: 1});
            instance.updateCart({ id: 2, quantity: 0});
            expect(instance.items[1].quantity).toEqual(1);
            expect(instance.total).toEqual(100);
        });

Quindi il nostro metodo verrà implementato come segue per poter soddisfare i test.

updateCart({id, quantity}) {
        if(quantity === 0) {
            return;
        }

        let index = 0;
        for(let i = 0; i < this.items.length; i++) {
            if(this.items[i].product.id === id) {
                index = i;
                break;
            }
        }
        this.items[index].quantity = quantity;
        this.calculateTotal();
}

Conclusione

L'approccio TDD semplifica notevolmente lo sviluppo con Node.js in quanto elimina fin dall'inizio i bug che possono presentarsi nell'implementazione poiché ora l'implementazione avviene in base ai requisiti enunciati nei test.

Codice sorgente e demo

GitHub

Heroku