UNIBO
Department of Computer Science
Relazione progetto MaraffaOnline - SAP
Matteo Santoro
Supervisor: Alessandro ricci
2026-02-17
I, Matteo Santoro, del Dipartimento di Informatica dell’Università di
Bologna, confermo che il presente lavoro è di mia proprietà e che le
figure, le tabelle, le equazioni, i frammenti di codice, le opere d’arte
e le illustrazioni contenute in questa relazione sono originali e non
sono state tratte da lavori di altre persone, tranne nei casi in cui i
lavori di altri sono stati esplicitamente riconosciuti, citati e
referenziati. Sono consapevole che, in caso contrario, si configurerà un
caso di plagio. Il plagio è una forma di cattiva condotta accademica e
sarà sanzionato di conseguenza.
Matteo Santoro
2026-02-17
School of Mathematical, Physical and Computational Sciences
MaraffaOnline è un’applicazione che permette alle persone di giocare
al gioco di carte Maraffa/Beccacino.
Il progetto consiste nell’eseguire una manutenzione evolutiva del gioco
di carte reperibile su MaraffaOnline, attualmente sviluppato dalla
prof.ssa Lumini. In particolare la nuova versione avrà un’architettura a
microservizi e introdurrà anche nuove funzionalità come formazione
personalizzata delle squadre, una nuova modalità di gioco (vittoria 11 a
0 in caso di violazione delle regole da parte di una squadra),
salvataggio delle statistiche delle partite e degli utenti, ...
Per lo sviluppo è stato seguito un approccio Domain Driven Design, per
il quale si è approfondito il dominio del gioco.
È stata posta particolare attenzione alle tecniche di continuos
integration, alle quali è stato dedicato uno dei capitoli di questo
report.
La progettazione dell’architettura microservices di MaraffaOnline segue il metodo iterativo, articolato in due passi principali: l’identificazione delle system operations e la decomposizione in servizi tramite l’approccio Domain-Driven Design (DDD).
Il primo passo consiste nel costruire un domain model di alto livello, derivato dai sostantivi delle user stories principali. Questo modello definisce il vocabolario per descrivere le system operations.
Le classi chiave identificate per MaraffaOnline sono:
Player – Giocatore registrato nella piattaforma
User – Profilo utente con credenziali e statistiche
Game – Partita di Maraffa (4 giocatori, 2 squadre)
Team – Coppia di giocatori alleati
Card – Carta da gioco del mazzo
Hand – Mano di carte di un giocatore
Trick – Singola presa (4 carte giocate)
Round – Singola mano di gioco (distribuzione carte fino a esaurimento)
ChatMessage – Messaggio nella chat di gioco
Si noti come il termine “Player” e “User” rappresentino lo stesso individuo ma in contesti diversi: User nel contesto di autenticazione e gestione profilo, Player nel contesto della logica di gioco. Questa polisemia è un segnale naturale dell’esistenza di bounded context distinti, dove lo stesso concetto del mondo reale assume significati e attributi diversi in ciascun contesto.
Si riporta di seguito lo schema dei casi d’uso che modella l’interazione dell’utente con l’applicazione.
Per raffinare il domain model e identificare le operazioni chiave, è stato utilizzato l’Event Storming. L’analisi ha prodotto i seguenti elementi:

Domain Events:
UserRegistered, UserLoggedIn,
UserLoggedOut
GameCreated, PlayerJoined,
GameStarted
CardsDealt, CardPlayed,
TrickWon
MaraffaDeclared, RoundEnded,
GameEnded
MessageSent
Commands:
RegisterUser, Login,
Logout
CreateGame, JoinGame,
StartGame
DealCards, PlayCard,
DeclareMaraffa
SendMessage
Dall’analisi degli eventi e dei comandi si derivano le system operations, suddivise in commands (operazioni che modificano lo stato) e queries (operazioni di sola lettura):
Commands:
createUser(username, password, email) – Registra un
nuovo utente
login(username, password) → token – Autentica l’utente
createGame(playerId) → gameId – Crea una nuova partita
joinGame(playerId, gameId) – Un giocatore entra
nella partita
playCard(playerId, gameId, card) – Gioca una carta
nel turno corrente
sendMessage(playerId, gameId, text) – Invia un
messaggio in chat
Queries:
getGameState(gameId) – Stato corrente della
partita
getUserProfile(userId) – Profilo e statistiche
utente
getUserStats(userId) – Statistiche di gioco
dell’utente
getActiveGames() – Lista partite
disponibili
Seguendo l’approccio DDD, il primo passo della decomposizione consiste nell’analizzare il dominio di business per identificare i subdomains, ossia le diverse aree di competenza dell’applicazione. I subdomains vengono scoperti attraverso l’analisi del business, non progettati.
Per MaraffaOnline sono stati identificati tre subdomains:
Game Logic (Core Subdomain) – Contiene le regole della Maraffa, la gestione delle partite, il calcolo dei punteggi e la logica dei turni. È il subdomain core perché rappresenta ciò che l’applicazione fa diversamente da qualsiasi altra piattaforma: implementare correttamente le regole specifiche della Maraffa, un gioco di carte regionale con regole non banali.
User Management (Generic Subdomain) – Comprende registrazione, autenticazione, gestione profili e statistiche. È un subdomain generic perché l’autenticazione e la gestione utenti sono attività che tutte le applicazioni svolgono allo stesso modo.
Chat (Supporting Subdomain) – Gestisce la messaggistica in tempo reale durante le partite. È un subdomain supporting perché supporta l’esperienza di gioco ma non fornisce nessuna funzionalità fondamentale.
Per MaraffaOnline sono stati progettati due bounded contexts principali, con una corrispondenza quasi uno-a-uno con i subdomains:
Game Context – Corrisponde al core subdomain
Game Logic. Il domain model include le entità
Game, Player, Round,
Card, Team, Trick. In questo
contesto, “Player” indica un partecipante attivo nella partita, con
attributi come la mano di carte, il team di appartenenza e il turno
corrente. L’ubiquitous language include termini specifici del gioco:
mano, presa, briscola, Maraffa.
User Context – Corrisponde al generic subdomain
User Management. Il domain model include User,
Credentials, UserStats. In questo contesto,
“User” indica un profilo registrato con credenziali, email e storico
partite. L’ubiquitous language è quello standard della gestione utenti:
registrazione, login, profilo.
Il subdomain Chat non è stato implementato come bounded context autonomo: la funzionalità di messaggistica è stata integrata nel middleware, una scelta discussa nella Sezione 2.2.5.
La context map rappresenta visivamente le relazioni e i pattern di integrazione tra i bounded contexts del sistema:
Game Context ↔︎ User Context: relazione di tipo Customer-Supplier. Il Game Context ha bisogno di verificare l’identità dei giocatori e aggiornare le statistiche. Lo User Context (supplier) fornisce il servizio di autenticazione e l’accesso ai profili. Il Game Context si conforma al contratto esposto dallo User Context per l’autenticazione, accettando il formato del token JWT così com’è.
Middleware ↔︎ Game Context: il middleware agisce come API Gateway e instrada le richieste verso il Game Context. Traducendo le richieste HTTP/WebSocket del frontend nel formato atteso dal servizio di business logic.
Middleware ↔︎ User Context: il middleware instrada le richieste di autenticazione e gestione profilo verso lo User Context, operando come semplice proxy, senza trasformare il modello.
Ogni bounded context è stato mappato a un microservizio indipendente, seguendo l’allineamento naturale tra DDD e architettura microservices:
| Subdomain | Tipo | Bounded Context | Servizio |
|---|---|---|---|
| Game Logic | Core | Game Context | business-logic (porta 3000) |
| User Management | Generic | User Context | user-service (porta 3001) |
| Chat | Supporting | (nel middleware) | middleware (porta 3003) |
Il servizio middleware svolge molteplici responsabilità
che, in un’architettura microservices ideale, sarebbero separate:
API Gateway – Routing delle richieste HTTP verso i servizi interni
WebSocket Management – Gestione delle connessioni in tempo reale per gli aggiornamenti di gioco
Chat – Logica di messaggistica tra giocatori (subdomain supporting)
Orchestrazione parziale – Coordinamento tra user-service e business-logic per alcune operazioni
Questa concentrazione di responsabilità rappresenta un trade-off consapevole: in un progetto accademico con un team ridotto, separare ulteriormente il middleware avrebbe introdotto complessità operativa (più container, più configurazioni di rete, più punti di failure) senza un beneficio proporzionale. Tuttavia, è importante riconoscere che questa scelta viola parzialmente il principio di singola responsabilità a livello di servizio e potrebbe rappresentare un ostacolo alla scalabilità indipendente dei componenti in un contesto produttivo.
In una evoluzione futura, si potrebbe considerare:
Estrarre la chat in un servizio dedicato
(chat-service)
Separare la gestione WebSocket in un servizio di notifiche
(notification-service)
Mantenere il middleware come puro API Gateway
Il Middleware implementa il pattern API Gateway, fungendo da unico punto di ingresso per tutti i client. Questo approccio centralizza diverse funzionalità critiche:
Routing: smista le richieste ai microservizi corretti
Authentication: valida i token JWT prima di inoltrare le richieste
Game Orchestration: coordina la logica di gioco tra i servizi
WebSocket Management: gestisce le connessioni real-time per gioco e chat
Si è seguito un approccio modulare nello sviluppo di questo componente che, in un primo step, conteneva soltanto la logica di gestione delle partite, al quale sono stati aggiunti i moduli per la comunicazione con il servizio degli utenti e della business logic. Ogni modulo segue la stessa struttura, ossia una classe denominata controller che si occupa esclusivamente della dichiarazione delle rotte HTTP e che a sua volta contiene un service. Ogni servizio contiene poi le logiche di calcolo e crea un JSON di risposta che viene passato al controller che lo invia come risposta HTTP, questo per poter separare completamente e rispettare il principio di single responsibility.
Nel modulo contenente le logiche di gioco è stato necessario aggiungere un ulteriore strato di separazione alla struttura controller-service, data la struttura multi-attore della gestione delle partite che non permetteva una separazione netta. Quindi è stato aggiunto, tra il controller e il service, un componente ibrido per poter comunque avere servizi indipendenti.
Questa separazione, oltre a essere una buona pratica, è stata scelta per poter effettuare del testing comodamente soltanto sui servizi, testando quindi solamente la parte relativa all’elaborazione di una risposta.
È stato applicato parzialmente il pattern "Database per Service" per garantire il disaccoppiamento dei dati:
UserService: utilizza MySQL per dati relazionali strutturati (utenti, credenziali, statistiche)
Middleware: utilizza MongoDB per lo storico delle partite e dati flessibili
BusinessLogic: servizio completamente stateless, non necessita di database
Ogni microservizio gestisce i propri dati in modo indipendente, evitando dipendenze dirette sui database di altri servizi.
Sono state implementate diverse forme di comunicazione tra servizi in base alle caratteristiche del messaggio che viene scambiato. Si è scelto di utilizzare la comunicazione REST nella maggior parte dei servizi, vista la sua interoperabilità.
Non è stato necessario implementare un vero e proprio broker di messaggi, ma è stato implementato un servizio denominato middleware che svolge il ruolo di API gateway delle richieste dai client verso il sistema.
Queste chiamate non sono obbligatoriamente dirette verso il servizio interessato, ma possono anche essere routine che effettuano molteplici chiamate per ottenere il risultato desiderato. Implementare queste chiamate direttamente nel front-end avrebbe reso il codice molto più complesso e difficile da mantenere, e la gestione di partite multiple sarebbe stata molto più complicata.
Non è stato implementato nessun meccanismo di discovery per scoprire l’indirizzo degli altri servizi, è stata sfruttata la tecnologia built-in di docker che permette di poter utilizzare degli alias e nascondere al proprio interno i meccanismi di registration, questo comunque implica che tutto debba essere sempre mantenuto nell’ambiente virtualizzato e quindi ogni futuro servizio containerizzato.
L’interazione con il frontend avviene prevalentemente tramite il protocollo HTTP, ma per la gestione di alcuni aspetti in tempo reale sono state introdotte le WebSocket.
In una prima implementazione era stato utilizzato un polling per aggiornare i dati delle partite. Tramite WebSocket, è direttamente il middleware che notifica tutti i client della creazione di una nuova partita via broadcast, migliorando significativamente la reattività dell’interfaccia.
Questo componente è stato creato per la gestione delle chat di gioco, in modo che i giocatori possano comunicare tra loro in tempo reale. Il sistema implementa due tipologie di chat:
Chat globale: accessibile a tutti gli utenti connessi
Chat di partita: accessibile solo ai giocatori della partita in corso
Ogni client, istanziato sul computer degli utenti, effettua una connessione WebSocket con il middleware utilizzando come endpoint il proprio UUID, che viene assegnato automaticamente all’avvio, e lo username del giocatore, dopo aver effettuato il login. Il middleware contiene al proprio interno una mappa di tutti i client e giocatori connessi con la relativa WebSocket e una classe che espone due metodi per inviare messaggi a un singolo giocatore o effettuare un messaggio broadcast a tutti i giocatori connessi.
Il middleware si occupa di instradare i messaggi alle giuste chat, garantendo la privacy delle conversazioni di partita.
Il modulo di game si occupa di gestire le partite, quindi è l’unico che può sapere quali giocatori devono ricevere determinati messaggi relativi a una singola partita. Per utilizzare il servizio di WebSocket, è stata implementata una semplice interfaccia all’interno dell’attore di gioco con la quale si effettuano chiamate "onEvent". Ogni volta che un particolare evento si verifica, un’invocazione a queste chiamate permette di inviare messaggi alle WebSocket dei giocatori interessati.
L’interfaccia definisce i principali eventi del ciclo di vita di una partita:
public interface IGameActor {
void onCreateGame(User user);
void onNewRound();
void onJoinGame(User user);
void onStartGame();
void onPlayCard();
void onTrickCompleted(Trick latestTrick);
void onEndRound();
void onEndGame();
// ... altri eventi
}
Ogni servizio è stato dockerizzato utilizzando multi-stage builds per ottenere immagini leggere e ottimizzate. Questa tecnica permette di separare la fase di build dalla fase di esecuzione, includendo nell’immagine finale solo i file strettamente necessari.
Per i servizi Node.js (UserService e BusinessLogic), il primo stage utilizza un’immagine completa per la compilazione, mentre il secondo stage copia solo i file compilati e le dipendenze di produzione.
Il middleware Java utilizza un’immagine Gradle per la build e OpenJDK per l’esecuzione, producendo un Fat JAR che include tutte le dipendenze necessarie.
Il frontend Angular viene compilato in un primo stage e poi servito tramite Nginx, che gestisce anche il reverse proxy verso i servizi backend.
Il sistema utilizza reti Docker interne per la comunicazione tra container, con IP privati assegnati automaticamente. I servizi non sono esposti direttamente all’esterno, creando un’architettura più sicura. Gli indirizzi sono configurati tramite variabili d’ambiente, facilitando la gestione di diversi ambienti di deployment.
Il progetto utilizza repository separate per ogni microservizio, collegate tramite git submodules. Questa scelta garantisce:
Indipendenza nello sviluppo di ogni componente
Possibilità di utilizzare workflow CI/CD specifici per ogni servizio
Versionamento indipendente dei servizi
La repository principale contiene i file docker-compose per l’orchestrazione dei container e i submodules che puntano alle singole repository dei servizi.
Ogni repository ha workflow automatizzati tramite GitHub Actions che includono:
Build: compilazione del codice
Test: esecuzione dei test automatici
Deploy: build e push dell’immagine Docker
I workflow vengono attivati su pull request verso il branch develop e su release manuali sul branch main. Il progetto adotta Conventional Commits per standardizzare i messaggi di commit e facilitare la generazione automatica di changelog.
È stato implementato uno stack completo di monitoraggio basato su:
cAdvisor: raccolta metriche dei container (CPU, memoria, rete, disco)
Prometheus: storage delle metriche time-series e sistema di alerting
Grafana: dashboard per la visualizzazione e analisi delle performance
Questo sistema permette di monitorare in tempo reale lo stato di salute dei servizi e identificare eventuali colli di bottiglia o anomalie.
Per la documentazione delle API è stato utilizzato Swagger, che genera automaticamente la documentazione interattiva:
Per i servizi Node.js: libreria che genera documentazione tramite decoratori
Per i servizi Java: documentazione generata tramite annotazioni
È stato utilizzato Postman per il testing delle API, con:
Collection di richieste esportate da Swagger
Payload parametrizzati con variabili d’ambiente
Flows: diagrammi di flusso delle richieste per simulare partite complete end-to-end
Il middleware implementa un sistema di log automatici tramite LogBack che permette:
Log su file per analisi in caso di problemi
Configurazione XML per personalizzazione (package, livelli, log rotate)
Compressione automatica dei log oltre una certa dimensione
La scelta dell’architettura a microservizi permette di isolare parti del sistema, evitando che gli errori si propaghino. Ogni servizio opera in modo indipendente con le proprie risorse. Tuttavia, il middleware rappresenta un Single Point of Failure che potrebbe essere mitigato implementando backup dei processi di gestione partite o ridondanza con istanze multiple.
Ogni microservizio può scalare in modo indipendente in base al carico. Utilizzando orchestratori come Kubernetes è possibile bilanciare il carico tra nodi e scalare orizzontalmente aggiungendo repliche dei servizi sotto maggior stress.
L’utilizzo di due stack tecnologici differenti (Java/Vert.x per il middleware e NestJS per gli altri servizi) dimostra la flessibilità dell’architettura e permette di scegliere la tecnologia più adatta per ogni componente.
Durante lo sviluppo sono emerse alcune criticità:
Complessità nella gestione della comunicazione tra servizi
Necessità di coordinamento per la definizione delle interfacce API
Overhead di deployment con tecnologie multiple
Il middleware come single point of failure richiede attenzione particolare
La decomposizione del monolite MaraffaOnline in un’architettura a microservizi ha permesso di ottenere significativi vantaggi in termini di manutenibilità, scalabilità e fault isolation.
I pattern applicati con successo includono:
API Gateway per centralizzare l’accesso ai servizi
Database per Service per il disaccoppiamento dei dati
Decompose by Subdomain per definire confini chiari seguendo DDD
WebSocket per la comunicazione real-time
Il sistema risultante è facilmente deployabile tramite Docker, monitorabile attraverso strumenti standard (Prometheus/Grafana) e pronto per essere scalato in base alle necessità. La separazione in microservizi indipendenti facilita inoltre la manutenzione evolutiva e l’aggiunta di nuove funzionalità senza impattare l’intero sistema.
In questa sezione vengono analizzati gli attributi di qualità (quality attributes) rilevanti per l’architettura del sistema sviluppato. Si tratta di requisiti non funzionali che influenzano profondamente le decisioni progettuali e architetturali.
Robustezza: l’interfaccia limita abbastana le operazione che possono esserer effettuate, rendendo il sistema più robusto.
Availability/Scalabilità: il sistema è progettato per essere sempre disponibile, la replica di servizi mirati sul nodo swarm garantisce che il sistema possa continuare a funzionare anche in caso di guasti e bilanciarsi.
Usabilità: l’interfaccia è progettata per essere intuitiva e facile da usare, con un design che facilita l’interazione dell’utente. Non è stato previsto nel progetto la creazione di un tutorial o di una guida, ma si è cercato di rendere l’interfaccia il più semplice possibile. (Conoscere le regole del gioco di carte è stato considerato sufficiente per l’utilizzo del sistema).
Evolvabilità: il sistema è progettato per essere facilmente estendibile e modificabile, con un’architettura modulare che consente l’aggiunta di nuove funzionalità senza impattare le parti esistenti.
Portabilità: il sistema è progettato per essere facilmente distribuito su diverse piattaforme e ambienti, grazie all’uso di tecnologie containerizzate come Docker..
Per ogni servizio del sistema è stato adottato, ove possibile, il paradigma di programmazione TDD (Test Driven Development). Questo approccio prevede che i test vengano scritti prima del codice, in modo da guidarne lo sviluppo e garantire che il software prodotto soddisfi i requisiti specificati.
La strategia di testing adottata si ispira alla test pyramid descritta in , che prevede una distribuzione dei test su livelli crescenti di granularità: molti unit test alla base, un numero inferiore di integration test, e pochi test ai livelli superiori.
Le operazioni di testing sono state implementate con Jest per quanto riguarda i servizi basati su Node.js, generando report chiari e di facile lettura. Il componente middleware, scritto in Java, produceva invece risultati di test meno comprensibili. Per risolvere questo problema è stata introdotta, nel file di build Gradle, la libreria Test Logger, tramite la seguente configurazione:
import com.adarshr.gradle.testlogger.TestLoggerExtension
import com.adarshr.gradle.testlogger.TestLoggerPlugin
import com.adarshr.gradle.testlogger.theme.ThemeType
testlogger {
theme = ThemeType.MOCHA
showExceptions = true
showStackTraces = true
showFullStackTraces = false
showCauses = true
slowThreshold = 2000
showSummary = true
showSimpleNames = false
showPassed = true
showSkipped = true
showFailed = true
showOnlySlow = false
showStandardStreams = false
showPassedStandardStreams = true
showSkippedStandardStreams = true
showFailedStandardStreams = true
logLevel = LogLevel.LIFECYCLE
}L’architettura interna di ciascun servizio segue una struttura a due livelli:
Classi controller: espongono le rotte e delegano alle classi di servizio.
Classi di servizio: contengono la logica di business.
Questa separazione consente di applicare strategie di testing differenziate per ciascun livello, coerentemente con quanto suggerito dall’architettura esagonale: le classi di servizio vengono testate con solitary unit test che ne verificano la logica in isolamento, mentre le interazioni tra servizi e con l’infrastruttura vengono verificate ai livelli superiori della piramide.
Gli unit test costituiscono la base della test pyramid e sono orientati alla tecnologia. Nel nostro sistema, sono stati implementati come solitary unit test sulle classi di servizio, verificando la correttezza della logica di business in isolamento dalle dipendenze esterne.
Non sono stati realizzati unit test dedicati per le classi controller dei singoli servizi. I controller andrebbero testati con solitary unit test che mockano i servizi sottostanti; nel nostro caso, si è scelto di verificarne il comportamento ai livelli superiori della piramide, tramite i test descritti nella Sezione 4.5. Si tratta di una limitazione consapevole, motivata dalla semplicità dei controller che si limitano a delegare alle classi di servizio.
Gli integration test si collocano al livello immediatamente superiore agli unit test nella piramide. Il loro scopo è verificare che un servizio possa interagire correttamente con i servizi infrastrutturali da cui dipende, in particolare i database .
Nel nostro sistema, ad eccezione del servizio contenente la business logic — che non ha dipendenze verso database né verso altri servizi — sono stati realizzati persistence integration test per verificare il corretto collegamento tra i servizi e i rispettivi database (MySQL e MongoDB).
L’esempio seguente mostra un persistence integration test che verifica l’interazione tra il servizio di gioco e MongoDB:
@TestInstance(Lifecycle.PER_CLASS)
@ExtendWith(VertxExtension.class)
public class StatisticMongoTest {
private static final int TRICKS = 10;
private final User userTest = new User("user", UUID.randomUUID(), false);
private static final int MARAFFA_PLAYERS = 4;
private static final int EXPECTED_SCORE = 11;
private static final String PASSWORD = "1234";
private static final GameMode GAME_MODE = GameMode.CLASSIC;
private Vertx vertx;
private GameService gameService;
private static final CardSuit UNDEFINED_TRUMP = CardSuit.NONE;
private final Card<CardSuit, CardValue> cardTest = new Card<>(CardValue.THREE, CardSuit.CLUBS);
private final Boolean isSuitFinished = true;
private final MongoStatisticManager mongoStatisticManager = new MongoStatisticManager(
Dotenv.configure().filename("env.example").load().get("MONGO_USER", "user"),
Dotenv.configure().filename("env.example").load().get("MONGO_PASSWORD", "password"),
Dotenv.configure().filename("env.example").load().get("MONGO_HOST", "localhost"),
Integer.parseInt(Dotenv.configure().filename("env.example").load().get("MONGO_PORT", "27127")),
Dotenv.configure().filename("env.example").load().get("MONGO_DATABASE", "maraffa-test")
);
@BeforeAll
public void setUp() {
this.vertx = Vertx.vertx();
this.gameService = new GameService(this.vertx, this.mongoStatisticManager);
}
@AfterAll
public void tearDown() {
this.vertx.close();
}
@Test
public void prepareGame() {
final String gameID = this.gameService
.createGame(MARAFFA_PLAYERS, this.userTest, EXPECTED_SCORE, GAME_MODE.toString())
.getString(Constants.GAME_ID);
final var doc = this.mongoStatisticManager.getRecord(gameID + "-0");
assertNotNull(doc);
}
}Il test segue le fasi canoniche: setup (inizializzazione di Vert.x e del servizio con connessione a MongoDB), execute (creazione di una partita e recupero del record), verify (asserzione sulla presenza del documento), teardown (chiusura di Vert.x).
I component test verificano il comportamento di un servizio trattandolo come una black box e interagendo con esso attraverso la sua API .
Nel nostro sistema, il componente middleware svolge il ruolo di API Gateway e si occupa di mettere in comunicazione i servizi tra loro e con il frontend. I test realizzati al suo interno verificano che le API esposte dagli altri servizi rispondano correttamente e che i contenuti vengano deserializzati nel formato atteso.
Questi test, pur non utilizzando un framework di contract testing come Spring Cloud Contract o Pact, svolgono nella pratica un ruolo analogo ai consumer-driven contract test: il middleware agisce come consumer delle API degli altri servizi, e i test verificano che il provider rispetti il contratto implicito (endpoint corretto, struttura della response attesa, status code).
Durante la scrittura dei test del servizio Java, alcune operazioni risultavano asincrone; è stato quindi necessario attendere il completamento di tali operazioni prima di poter validare i test. La struttura di test standard fornita da Vert.x non era sufficiente in un primo momento. La forma classica dei test è la seguente:
@Test
public void createGameTest(final VertxTestContext context) {
final JsonObject gameResponse = this.gameService.createGame(
MARAFFA_PLAYERS, TEST_USER, EXPECTED_SCORE, GAME_MODE.toString(), PASSWORD);
Assertions.assertEquals(UUID_SIZE, gameResponse.getString(Constants.GAME_ID).length());
context.completeNow();
}Per gestire correttamente le operazioni asincrone, sono stati adottati i seguenti accorgimenti:
utilizzo del decoratore @Timeout fornito da JUnit Jupiter, che consente di impostare un limite di tempo oltre il quale il test fallisce automaticamente, utile per gestire servizi esterni che non rispondono;
uso asincrono del VertxTestContext, che consente di completare il test solo quando tutte le operazioni asincrone sono terminate;
introduzione, nei servizi, di una struttura di risposta che
include una chiave "error" in caso di fallimento, poiché il
test non può accedere direttamente alla risposta HTTP e quindi allo
status code.
Il seguente esempio mostra un test asincrono correttamente strutturato:
@Timeout(value = 10, unit = TimeUnit.SECONDS)
@Test
public void testGetShuffledDeckOK(final VertxTestContext context) {
final JsonObject gameResponse = this.gameService.createGame(
4, TEST_USER, 41, GameMode.CLASSIC.toString(), PASSWORD);
this.businessLogicController
.getShuffledDeck(UUID.fromString(gameResponse.getString(Constants.GAME_ID)), 4)
.whenComplete((res, err) -> {
context.verify(() -> {
assertNull(res.getString("error"));
context.completeNow();
});
});
}I livelli superiori della test pyramid — in particolare gli end-to-end test, che dovrebbero essere minimizzati data la loro complessità, lentezza e fragilità — non sono stati implementati in forma automatizzata. La verifica del comportamento complessivo dell’applicazione è stata condotta tramite sessioni di alpha testing con più utenti attraverso il frontend, coprendo in modo esplorativo i percorsi principali dell’utente (user journey).
La parte di sicurezza è relativamente semplice nel progetto MaraffaOnline, viene utilizzato una form login con utente/passwword e questi vengono passati al servizio degli utente che verifica la validatà e rilascia un authorization bearer che viene salvato all’interno del browser per poter autorizzare tutte le successive chiamate ai servizi, che comunque non sono mai dirette ma vengono sempre intercettate dal servizio di API gatetway che ne controlla la validità. Dato il dominio non ci sono permessi differenti in base alle tipologie di utenti, visto che sono tutti giocatori.
Il sistema è stato studiato per poter essere pienamente configurabile sfruttando le funzionalità offerte da docker e in particolare docker swarm, ogni servizio è in grado di leggere le proprie configurazioni di sistema da un file ’.env’.
In ogni servizio creato ad-hoc per il progetto sono state inserite le API per il controllo di health, forniscono un test di liveness del sistema tramite uno status code 200 e inoltre sono state aggiunte metriche di ogni servizio che potrebbero risultare utili in uno scenario di deploy in cui è importante conoscere lo spazio su disco utlizzato da ogni servizio e che percentuale occupa. All’interno dello stack, su ogni container, vengono chiamate queste API periodicamente per poter assicurare che non ci siano down di servizi.
healthcheck:
test:
[
"CMD",
"curl", "-f", "http://localhost:3000/actuator/health", "||",
"curl", "-f", "http://localhost:3000/health", "||",
"curl", "-f", "http://localhost:3000/", "||","exit","1",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s