Non è la prima volta che parliamo di integration test, croce e delizia dello sviluppo. Sono complessi da fare perché richiedono la compartecipazione dei sistemi necessari all’applicazione che siamo sviluppando. Avevamo visto come nel ciclo di build di Maven l’uso del plugin failsafe ci aiuta ad eseguire questo tipo di test nella fase giusta. Abbiamo visto poi come Arquillian permette di controllare un application server durante gli integration test. Quello che non abbiamo visto fino adesso è come controllare i sistemi a contorno, per esempio un database, per costruire dei veri e propri test automatici end-to-end. In questo post vedremo quindi come eseguire dei test di integrazione di una applicazione basata su Tomcat e PostgreSQL con l’aiuto di Docker!
Scenario
Immaginiamo di dover sviluppare i test di integrazione su una applicazione web, basata su Tomcat, che esegue il CRUD dei dati su un database (PostgreSQL in questo caso), attraverso una API REST (in questo caso possiamo definirli anche end-to-end, visto che no abbiamo interfaccia grafica). Il test più efficace è sicuramente quello più vicino ad una situazione reale: chiamate HTTP dai nostri test alla nostra applicazione deployata su un Tomcat che fa le query sul database.
Gli obiettivi sono:
- eseguire i test in modo automatico
- controllare l’avvio e lo spegnimento dei sistemi (Tomcat e PostgreSQL)
- test portabili, indipendenti dall’ambiente in cui si trovano i sistemi che si integrano, in modo da poter girare sia in ambiente di sviluppo che soprattutto su un server di continuos integration (come Jenkins), senza doversi preoccupare di porte occupate dai servizi richiesti dalle varie build.
Maven e Tomcat
Il primo obiettivo è facilmente raggiungibile con Maven: nel suo ciclo di build, prevede test e integration test. Ogni fase solitamente espone degli “hook” di pre e post fase, al quale si possono agganciare altri plugin per eseguire certe operazioni.
Possiamo così ottenere parzialmente il secondo e il terzo obiettivo con il tomcat7-maven-plugin, come avevamo già visto. Tomcat infatti viene scaricato al momento se non disponibile, rendendo il test portabile e indipendente dall’ambiente. Inoltre, è possibile avviare il server prima dei test e stopparlo al termine.
Ci sono però diversi vincoli:
- possiamo controllare Tomcat e il deploy, ma non la porta (default 8080) su cui si avvia il server. Questo comunque è un problema aggirabile come vedremo a breve
- il plugin non supporta le versioni di Tomcat successive alla 7 (e sembra che non siano previste versioni nuove del plugin).
E il database? Se si presuppone che il database sia sempre disponibile, possiamo accontentarci di questa soluzione (avendo presente i vincoli). Esiste un postgresql-maven-plugin molto interessante su Github, che promette di poter controllare PostgreSQL durante la fase di integration test. A giudicare dal codice sembra però che il database debba comunque essere installato sulla macchina: viene quindi meno la portabilità.
Per raggiungere pienamente i tre obiettivi sarebbe necessario che Tomcat e PostgreSQL girassero in contenitori isolati (per evitare conflitti di porte), consistenti e possibilmente scaricabili e configurabili in modo automatico. Per far questo, accanto a Maven abbiamo bisogno di Docker!
Docker Docker Docker!!
Il movimento dei DevOps ha avvicinato il mondo degli sviluppatori (Dev) a quello degli operatori (Ops), che, contaminandosi a vicenda, ha spinto l’applicazione del principio DRY (Don’t Repeat Yourself) a livello di gestione dell’infrastruttura e della delivery. Sono nati quindi strumenti fantastici come Vagrant per il provisioning delle macchine virtuali, nonché modalità di configurazione avanzate automatiche con Ansible, Puppet o Chef. L’idea è quella di creare artefatti immutabili, dove l’artefatto è una macchina virtuale!! C’è da modificare la configurazione della macchina? Bene, si aggiorna lo script e si rigenera la macchina in automatico, tutto in pochi minuti.
Gestire una infrastruttura virtualizzata con strumenti automatici permette di controllare cosa c’è installato sulle macchine (perché nessuno le modifica più) e soprattutto permette di replicarle senza nessuno sforzo. Di fatto però, è probabile che gran parte delle macchine virtuali abbiano in comune tutto lo strato del sistema operativo (Linux), che diventa quindi ridondante: perché quindi non “virtualizzare” solo una parte di esso, cioè quello che cambia tra una macchina e l’altra? Da questa idea nasce Docker!
Docker è quindi figlio della virtualizzazione, che non sostituisce: sfruttando caratteristiche specifiche del Kernel Linux (come cgroups introdotto da Google nel 2008), Docker crea dei contenitori isolati, visti dal sistema host come processi, che invece internamente appaiono come delle vere e proprie macchine virtuali con la propria interfaccia di rete e le proprie risorse (proprio grazie al partizionamento che ne fa cgroups). Già con questa premessa si capisce che sia i container Docker che la macchina host possono essere solo Linux. Recentemente sono usciti Docker per Mac e per Windows, che, invece di sfruttare VirtualBox per far girare una macchina virtuale Linux che faccia da host per Docker (come faceva fino a ieri Docker Toolbox), non fanno altro che sfruttare i virtualizzatori nativi introdotti nelle versioni più recenti di questi OS (rispettivamente xhyve e Hyper-V) per avviare Alpine Linux, una versione estremamente leggera di Linux adatta ad essere host di container, trasparente però ai sistemi operativi veri e propri ospitanti. Il risultato è quindi che si usa Docker da Mac o Windows come se si fosse su Linux.
Cosa se ne fa uno sviluppatore?
Docker può essere estremamente utile sia in fase di sviluppo che di test perché si possono creare, configurare, avviare e distruggere container in modo estremamente semplice. Inoltre, un container può essere usato anche come artefatto finale per il deploy!! Non è così più necessario preoccuparsi della configurazione dell’ambiente di produzione perché viene generato direttamente in fase di sviluppo: si parla così oggi di immutable deploy, dove ad ogni rilascio viene generata un nuova immagine Docker con la nostra applicazione. A differenza delle VM infatti, con Docker è buona norma creare contenitori con uno e un solo servizio, che si presta bene a contenere l’applicazione che stiamo sviluppando, in modo da essere facilmente replicabile (per scalare orizzontalmente) isolandolo dal sistema host.
Maven e Docker
Dopo aver tessuto le lodi di Docker e le modalità di utilizzo, vediamo come possono semplificarci la vita nei test di integrazione.
Riuscire a costruire dei test completamente automatici per la fase di “integration-test” di Maven che fossero i più vicini possibile al caso reale e che girassero anche sul server di Continuous Integration (come Jenkins) senza problemi è sempre stata una sfida. Adesso con Docker, controllato da Maven, è finalmente possibile in modo piuttosto semplice: il tempo impiegato inizialmente per “incastrare” le configurazioni è ampiamente ripagato dai risultati!
Prerequisiti:
- Maven (testato sulla v3.2.3)
- Docker for Linux/Mac/Windows (testato sulla 1.12.1 for Mac). Nel caso si usi ancora Docker Toolbox è possibile che si debba indicare esplicitamente l’host su cui si trova il Docker Daemon.
Cercando “” su Google, spicca il plugin Maven di Fabric8 per Docker, soprattutto per l’ambia documentazione. In realtà esistono quattro plugin che permettono di usare Maven con Docker e quelli di Fabric8 li hanno messi a confronto. Indovinate chi vince?
Configurazione base con Fabric8 plugin
Prima di pensare a qualsiasi container da creare e avviare, è necessario innanzitutto impostare il plugin failsafe come abbiamo già imparato e poi il plugin di Fabric8 in modo che crei e/o avvii i container in fase di pre-integration-test e li fermi (e li distrugga) in fase di post-integration-test
io.fabric8 docker-maven-plugin 0.15.16 true ...start pre-integration-test build start stop post-integration-test stop
Questo è boilerplate: la parte più interessante invece sta nel blocco configuration.
PostgreSQL e Docker: test business logic su base dati
Cominciamo quindi con le cose semplici: avviare un container con PostgreSQL, creare il database e impostare username e password.
L’immagine Docker di PostgreSQL è già pronta per noi su Docker Hub!
Basta quindi aggiungere nel blocco configuration la configurazione per l’immagine Docker che ci interessa:
postgres-integration-test postgres:9 todolist todolist todo_list postgres.port:5432 database system is ready to accept connections
Senza scendere nei dettagli, ci basta sapere che per creare un container abbiamo bisogno prima di una fase di “build” a partire da un Dockerfile che genera un’immagine (cioè una sorta di container “archetipo” in sola lettura), e poi una di “run” che genera il container vero e proprio con la nostra configurazione.
Visto che l’immagine Docker di PostgreSQL 9.5 (definita dal nome:label “postgres:9” su Docker Hub) è già pronta (cioè è già stata generata da un Dockerfile) e non abbiamo bisogno di generarne una nuova perché ci va bene così com’è, possiamo saltare direttamente alla fase di run, dove possiamo specificare una serie di parametri per il runtime:
- env: è possibile definire i valori per le variabili d’ambiente che mette a disposizione il container
-
ports: nella forma host-port:container-port; definisce su che porta dell’host esporre il servizio fornito dal container. Se è una stringa, come in questo caso, viene automaticamente generata una variabile Maven a cui viene assegnata una porta libera dell’host! Che ce ne facciamo? Per esempio, per testare direttamente le query da JUnit, abbiamo bisogno di sapere indirizzo e porta del database: failsafe infatti, tramite il tag systemPropertyVariables, può esporre una variabile Maven come variabile di sistema:
org.apache.maven.plugins maven-failsafe-plugin 2.18.1 localhost ${postgres.port} integration-test verify recuperabile facilmente in Java in modo programmatico:
String dbHost = System.getProperty("DB_PORT_5432_TCP_ADDR"); String dbPort = System.getProperty("DB_PORT_5432_TCP_PORT");
o come property di Spring (in caso venga usato):
@Value("${DB_PORT_5432_TCP_ADDR}") private String dbHost; @Value("${DB_PORT_5432_TCP_PORT}") private String dbPort;
- wait: serve per identificare quando il servizio è pronto. In questo caso, si aspetta una specifica stringa di log, per un tempo massimo di 20 secondi. Se invece si usa solo il tag time all’interno di wait, allora la build attende quel tempo prima di proseguire.
Per verificare se la configurazione funziona, basta eseguire da riga di comando:
mvn docker:run
per avviare il database (l’unico configurato al momento). Per verificare se il container è veramente attivo (e a che porta ha effettuato il binding sull’host), possiamo usare direttamente il normale comando docker:
docker ps
Per fermare e cancellare il container:
mvn docker:stop
Siamo quindi in grado di ottenere dei test di integrazione automatici e portabili (perché il database viene scaricato, configurato e avviato automaticamente) e capaci di girare anche in un contesto di continuous integration perché non dobbiamo preoccuparci di conflitto di porte.
Questo tipo di configurazione è quindi ideale per la logica di business che si appoggia ad una base dati.
Un progetto di esempio si trova sul GitHub di CodingJam, dove il modulo Maven “todo-list-jaxrs-spring-services” avvia un container PostgreSQL per testare le query.
Ma che succede se vogliamo allargare il tiro, arrivando a fare dei veri e propri test end-to-end che includono anche il livello di API?
Tomcat, PostgreSQL e Docker: test end-to-end
Per fare un test di integrazione completo di tipo end-to-end è necessario avviare tutto lo stack necessario al funzionamento dell’applicazione: vedremo quindi come eseguire i test a partire dalle API REST, effettuando chiamate HTTP da JUnit tramite JAX-RS 2 client su un Tomcat avviato in un container Docker (dove è deployata la nostra applicazione) che dialoga con un altro container (in modo isolato) dove si trova PostgreSQL (dove è presente il nostro schema).
Aggiungiamo quindi l’immagine Docker di Tomcat al pom.xml:
todolist-tomcat %g/todolist-tomcat:%l ${postgresql.version} ${project.build.finalName}.${project.packaging} ${project.basedir}/src/it/resources/docker/tomcat artifact tomcat.port:8080 http://${docker.host.address}:${tomcat.port} postgres-integration-test:db postgres-integration-test ...
Dal momento che dobbiamo personalizzare l’immagine Tomcat di Docker con la nostra applicazione e le dipendenze lato server (come il driver PostgreSQL per esempio), in questo caso useremo anche la fase di build dell’immagine, a partire da un Dockerfile. Rispetto al caso del database, abbiamo una situazione più articolata:
- args si popolano le cosiddette build-args, dichiarate nel Dockerfile. In questo caso si specifica qual è il nome dell’artefatto da copiare nell’immagine e la versione del driver di PostgreSQL da scaricare.
- dockerFileDir: specifica dove trovare il Dockerfile da cui creare l’immagine. Tutti gli altri file presenti nella cartella possono essere usati come risorse statiche a cui si può accedere durante la fase di build (per essere copiati nell’immagine per esempio). Il valore di default è ${project.basedir}/src/main/docker.
- assembly: definisce quali sono gli artefatti Maven che vanno a finire nel contesto di build di Docker (cioè cosa si può eccedere dal Dockerfile). Mentre quindi il dockerFileDir definisce il contesto base in cui si trovano i file statici da portare nel processo di build di Docker, in questo caso si fa riferimento agli artefatti veri e propri prodotti da Maven. Per capire meglio qual era il contesto Docker, date un occhio alla cartella target/docker dopo la fase di build.
Il descrittore della fase di run in questo caso ci riserva due tag nuovi rispetto a quanto visto per il database:
-
http: il container si considera avviato quando è disponibile l’indirizzo specificato. docker.host.address è la variabile che contiene l’indirizzo dell’host Docker su cui di fa il binding (che è localhost, tranne nel caso in cui si usi Docker Toolbox). Come nel caso del database, anche in questa situazione tomcat.port rappresenta un porta libera e casuale del sistema, recuperabile nei test JUnit come variabile si sistema. La Rule JUnit
WebClientRule
ne è un esempio. - links: le due macchine vengono collegate a livello network e non solo: la macchina di Tomcat quindi riceve una serie di variabili d’ambiente da quella di PostgreSQL permettendogli, per esempio, di sapere ip e porta sulla quale il database espone il servizio.
Un esempio pratico si trova sul GitHub di CodingJam, nel pom.xml del modulo Maven “todo-list-jaxrs-spring-web“.
Conclusioni
La coppia Maven + Docker permette quindi di spingere le potenzialità dei test di integrazione fino ad un livello di affidabilità dei test mai visto fino adesso. I container Docker gestiti da Maven infatti non sono dei mock o dei surrogati dei sistemi da integrare, ma sono le vere istanze isolate in sandbox. Con questa soluzione quindi abbiamo raggiunto i tre obiettivi che ci eravamo prefissati: test automatici, capaci di controllare e configurare i sistemi integranti, rieseguibili e portabili tra un ambiente e l’altro.