Come implementare Integration Tests (end-to-end)

Unit Test vs Integration Tests

Un po’ controcorrente rispetto alle ultime mode, devo ammettere che non sono un grande fan degli Unit Tests, anche se li trovo utili e in alcuni casi insostituibili. Nei progetti senza budget (e tempo) illimitato, invece preferisco seguire la regola del 80/20, e distribuire i test automatizzati tra Unit Tests e Integration Tests, di cui sono un grande fan.

Ovviamente gli Integration Tests non sostituiscono gli Unit Test, ma permettono di testare facilmente tutto il flusso tra i vari componenti, e quindi anche parzialmente si sovrappone agli Unit Test. Gli Unit Test – invece – richiederebbero un grande lavoro (capacità e tempo) per testare tutto il flusso dei dati che, quando ad esempio include anche REST Api, obbligherebbe a fare il mock di grande parte delle dipendenze, e quindi per me il test è molto debole, in quanto fin troppo simulato. Quello appunto, risulterebbe molto più semplice con gli Integration Tests. D’altra parte utilizzare gli Integration Tests per testare tutti i possibili casi (ad esempio una semplice regola di validazione) sarebbe una “pazzia”, essendo molto più semplice (e veloce da eseguire) utilizzare gli Unit test. In pratica è molto semplice: a ogni strumento risolve molto bene una determinata casistica di problemi.

Trovo un po’ di difficoltà a trovare alleati sul fronte Integration Tests, anche se poi di fronte ai fatti, bug trovati, velocità di risoluzione, e semplicità di implementazione, tutti riconoscono l’efficacia e adottano compiaciuti la nuova modalità. Probabilmente è una questione di “cultura” dello sviluppatore, ovvero gli Integration Tests non sono l’ultima moda, infatti non trovo molto materiale e fan. Ho pensato quindi di condividere il mio punto di vista.

Come implementare gli Integration Tests con successo

Innanzitutto, di solito utilizzo un framework dedicato, sviluppato in C# (mio linguaggio principale) e una serie di classi con generics che implementano una fluent interface. Il tutto per rendere molto descrittivo, generico e flessibile il framework e la scrittura dei test.

L’approccio non è il classico OOP/SOLID, in quanto risulterebbe troppo complesso implementare il tutto e quindi OOP e SOLID “puro” sarebbero un anti-pattern in questo caso, ma vengono create delle apposite classi, chiamate Scenario, costruite da partial class con tutte le request/response pubbliche, in modo tale che sia semplice sovrascrivere/modificare qualsiasi parte, in quindi poter generare tutte i possibili casi da testare. Questo anche perchè lo scopo degli Integration Tests non è solamente testare l’happy scenario, ma anche i casi particolari, gestione degli errori, etc.

Il framework viene utilizzato per testare diversi scenari di test end-to-end, che coinvolge sia le chiamate HTTP alle API pubbliche, sia l’invio e lettura di messaggi con RabbitMQ.

Per implementare i test con RabbitMQ (o comunque messaggistica distribuita in genere) il sistema è semplice:

  • ricevere tutti i messaggi tramite una coda creata con un nome random, e che si auto distrugge al termine del test
  • per ogni messaggio ricevuto verifichiamo il nome del messaggio che coincida con il messaggio voluto
  • verifichiamo che l’identificatore univoco del messaggio sia quello desiderato

A questo punto possiamo effettuare le verifiche di validazione sul contenuto del messaggio.

La parte difficile – secondo me – sta nello creare il giusto livello di astrazione e dividere le responsabilità.

Regole base per scrivere un test

Nel mio caso, utilizzo la regola di:

  • un progetto per Rest API/Applicazione
  • un test si appoggia al framework, cuore della piattaforma di test, che tramite ereditarietà, generics, etc è in grado di permette di scrivere/descrivere un test chiaramente in poche righe e occuparsi dei dettagli di implementazione
  • un test deve riguardare un unico end-point, ma potrebbe coinvolgerne molti altri tramite l’esecuzione dello scenario di preparazione delle dipendenze (importante: non sono unit-test).
  • un test deve contenere poche righe, massimo 10, i casi più complessi 20
  • un test deve affidarsi sui test precedenti, ovvero un test non deve ri-testare quello già testato, al fine di evitare ridondanza e soprattutto aumentare la facilità di manutenzione
  • un test deve solamente descrivere la logica, capibile anche da persone non-tecniche (un tester non sempre è un buon programmatore)
  • un test deve avere righe semplici, evitare ciò che rende il test illeggibile (viola la regola del “descrivere” il test)
    • le cose “cool” e “ultima moda” se non necessarie a semplificare la lettura del test (es. array di byte convertiti in stringa convertita in un oggetto, tramite la libreria hai scoperto stamattina)
    • lunghe righe di codice o righe multi-metodo (es. doTest().doTestB().doTest(c) ? doTest(e)!.doTest(f) : doTest(g); )
    • ricordo che più si comprende una cosa, più si è capaci di semplificare: semplice è sempre meglio
  • un test non deve contenere flussi logici (IF, switch, null-conditional-operator/null coalescing operator, etc). Se li avete, state sbagliando il concetto di “cosa è un test”.
  • un test non deve contenere tecnicismi, ma devono essere delegati al framework
  • un test deve essere descritto tramite una fluent interface. Questo articolo fornisce un’introduzione di base per la definizione di “fluent interface”.
  • un test, tramite la fluent interface esposta dal framework, deve poter sovrascrivere:
    • eventuali timeout
    • sovrascrivere il body json di un metodo di una REST Api con un raw json (anche solo parziale, ovvero un “merge”)
    • eventuali parametri di configurazione
  • un test deve appoggiarsi a classi Scenario per preparare l’ambiente di esecuzione e dipendenze, suddivise (per comodità) in partial classes:
    • rendono il codice più facile da capire e da mantenere, pur lasciando ogni test avere accesso ad altri scenari di test come parte di uno scenario più complesso. Per esempio, avete bisogno di fare un ordine prima di poterlo annullare.
    • nelle classi scenario “tutto è pubblico”: la maggior parte delle proprietà e dei metodi sono pubblici. Questo per rendere semplice ispezionare o modificare le proprietà nel test che le diverse fasi richiedono.
    • il costruttore deve avere i possibili parametri configurabili tramite parametri opzionali, con una configurazione di base di default. In pratica potremo modificare solo quello che ci interessa per quel specifico test, come ad esempio la lingua, l’importo, ecc ecc.
  • un test deve essere scritto con l’ottica della manutenzione a lungo termine, questo sarà il segreto del successo degli Integration Tests. Quindi la manutenzione a lungo termine è fondamentale: generalizzare, riutilizzare, semplificare e riordinare le API di testing ogni volta possibile, non posticipare questo processo, i risultati saranno deleteri. In pratica niente hack per stare meno, lo pagherete in futuro.
  • un test potrebbe essere eseguito in modo parallelo, per simulare una situazione reale

Esempio di un Integration Test descrittivo

Un esempio di pseudo-codice potrebbe essere questo, che testa un happy case per cancellare un ordine tramite una REST Api. Lo scenario preparerà l’ordine di base, ma con solo importo e lingua non di default, e verificherà che la REST Api, il database, il messaggio sul bus siano corretti. Nei commenti un esempio per come utilizzare lo scenario per creare un caso-non-valido.

private void CancelOrderRequest_cancel_an_order_successful() {
    var scenario = new EcommerceScenario(language: Language.English, amount: 100);
    scenario.CreateOrder();

    var request = scenario.GetCancelOrderRequest();
    //qui si possono sovrascrivere eventuali proprietà della Request
    //request.Order.Amount = scenario.amount + 10; // test an invalid amount

    test
    .SetRequest (request)
    .Execute (api.Service.CancelOrder)
    .ValidateResponse(IsValidResponse)
    .ValidateDatabase("orders", "isCancelled", scenario.orderId, true)
    .ValidateMessage("order.cancelled.event", scenario.orderId)
    .Wait();

    var msg = test.Messages[scenario.orderId] as OrderCancelledEvent;
    Assert.IsTrue(msg.cancelled);
    Assert.IsEqual(msg.amount, scenario.amount);
}

private void CancelOrderRequest_cancel_an_order_successful_parallel() {
    Parallel.For(0, 5, i =>
    {
        CancelOrderRequest_cancel_an_order_successful();
    });
}

In questo repository github potete trovare un progetto di esempio funzionante, comprendente una REST API e integration test basati su .NET e c#: integration-tests su github.com

Potreste trovare interessante questo articolo:

One Comment

Add a Comment

Il tuo indirizzo email non sarà pubblicato.