Test di codice intensivo dei dati con Go, parte 1

Panoramica

Molti sistemi non banali sono anche ad alta intensità di dati o basati su dati. Testare le parti dei sistemi ad alta intensità di dati è molto diverso rispetto a testare i sistemi ad alta intensità di codice. In primo luogo, potrebbe esserci un sacco di sofisticazione nel livello dati stesso, come ad esempio archivi dati ibridi, memorizzazione nella cache, backup e ridondanza.

Tutto questo meccanismo non ha nulla a che fare con l'applicazione stessa, ma deve essere testato. In secondo luogo, il codice potrebbe essere molto generico e per testarlo è necessario generare dati strutturati in un certo modo. In questa serie di cinque tutorial, affronterò tutti questi aspetti, esplorerò diverse strategie per la progettazione di sistemi testabili ad alta intensità di dati con Go e analizzeremo esempi specifici. 

Nella prima parte, esaminerò il progetto di un livello dati astratto che consente il test corretto, come gestire gli errori nel livello dati, come simulare il codice di accesso ai dati e come testare un livello dati astratto. 

Test contro un livello dati

Trattare con archivi di dati reali e le loro complessità è complicato e non correlato alla logica di business. Il concetto di un livello dati consente di esporre un'interfaccia accurata ai dati e di nascondere i dettagli cruenti su come esattamente i dati sono archiviati e su come accedervi. Userò una applicazione di esempio chiamata "Songify" per la gestione della musica personale per illustrare i concetti con codice reale.

Progettazione di un livello dati astratto

Esaminiamo il dominio di gestione della musica personale: gli utenti possono aggiungere brani ed etichettarli e considerare quali dati devono essere archiviati e come accedervi. Gli oggetti nel nostro dominio sono utenti, brani ed etichette. Esistono due categorie di operazioni che si desidera eseguire su qualsiasi dato: query (sola lettura) e modifiche allo stato (creazione, aggiornamento, eliminazione). Ecco un'interfaccia di base per il livello dati:

pacchetto abstract_data_layer import "time" tipo Song struct Url string Nome string Descrizione string tipo Label struct Nome stringa tipo User struct Nome stringa Email stringa RegisteredAt time.Time LastLogin time.Time tipo DataLayer interface // Query (leggi -solo) GetUsers () ([] Utente, errore) GetUserByEmail (stringa email) (Utente, errore) GetLabels () ([] Etichetta, errore) GetSongs () ([] Song, errore) GetSongsByUser (utente utente) ([ ] Song, errore) GetSongsByLabel (stringa etichetta) ([] Song, errore) // Operazioni di cambio stato Errore CreateUser (utente utente) ChangeUserName (utente User, stringa nome) errore AddLabel (stringa etichetta) AddSong (utente Utente, brano Song , etichette [] Etichetta) errore 

Si noti che lo scopo di questo modello di dominio è presentare un livello dati semplice ma non del tutto banale per dimostrare gli aspetti del test. Ovviamente, in un'applicazione reale ci saranno più oggetti come album, generi, artisti e molte più informazioni su ogni canzone. Se arriva il momento critico, puoi sempre archiviare informazioni arbitrarie su un brano nella sua descrizione e allegare tutte le etichette che vuoi.

In pratica, potresti voler dividere il tuo livello dati in più interfacce. Alcune delle strutture possono avere più attributi e i metodi potrebbero richiedere più argomenti (ad esempio tutti i GetXXX ()i metodi richiedono probabilmente alcuni argomenti di paging). Potrebbero essere necessarie altre interfacce e metodi di accesso ai dati per operazioni di manutenzione come il caricamento di massa, i backup e le migrazioni. A volte ha senso esporre un'interfaccia di accesso ai dati asincrona o in aggiunta all'interfaccia sincrona.

Cosa abbiamo ricavato da questo livello di dati astratti?

  • Uno sportello unico per le operazioni di accesso ai dati.
  • Visione chiara dei requisiti di gestione dei dati delle nostre applicazioni in termini di dominio.
  • Possibilità di modificare a piacimento l'implementazione del livello dati concreto.
  • Capacità di sviluppare lo strato di dominio / business logic in anticipo rispetto all'interfaccia prima che il livello di dati concreti sia completo o stabile.
  • Ultimo ma non meno importante, la capacità di deridere il livello dati per test rapidi e flessibili della logica dominio / business.

Errori e gestione degli errori nel livello dati

I dati possono essere archiviati in più archivi di dati distribuiti, su più cluster in diverse posizioni geografiche in una combinazione di data center on-premise e cloud. 

Ci saranno dei fallimenti e questi fallimenti devono essere gestiti. Idealmente, la logica di gestione degli errori (tentativi, timeout, notifica di guasti catastrofici) può essere gestita dal livello di dati concreti. Il codice logico di dominio dovrebbe recuperare i dati o un errore generico quando i dati non sono raggiungibili. 

In alcuni casi, la logica di dominio potrebbe desiderare un accesso più granulare ai dati e selezionare una strategia di fallback in determinate situazioni (ad esempio solo i dati parziali sono disponibili perché parte del cluster è inaccessibile, oppure i dati sono scaduti perché la cache non è stata aggiornata ). Questi aspetti hanno implicazioni per la progettazione del tuo livello dati e per il suo test. 

Per quanto riguarda i test, è necessario restituire i propri errori definiti nel livello dati astratti e mappare tutti i messaggi di errore concreti ai propri tipi di errore o fare affidamento su messaggi di errore molto generici.   

Codice di accesso ai dati di simulazione

Prendiamo in giro il nostro livello di dati. Lo scopo del mock è sostituire lo strato dati reale durante i test. Ciò richiede che il livello dati simulato esponga la stessa interfaccia e sia in grado di rispondere a ciascuna sequenza di metodi con una risposta predefinita (o calcolata). 

Inoltre, è utile tenere traccia di quante volte è stato chiamato ogni metodo. Non lo dimostrerò qui, ma è anche possibile tenere traccia dell'ordine delle chiamate a diversi metodi e quali argomenti sono stati passati a ciascun metodo per garantire una determinata catena di chiamate. 

Ecco la struttura del livello dati fittizio.

pacchetto concrete_data_layer import (. "abstract_data_layer") const (GET_USERS = iota GET_USER_BY_EMAIL GET_LABELS GET_SONGS GET_SONGS_BY_USER GET_SONG_BY_LABEL ERRORS) tipo MockDataLayer struct Errori [] Errore GetUsersResponses [] [] Utente GetUserByEmailResponses [] Utente GetLabelsResponses [] [] Etichetta GetSongsResponses [] [] Song GetSongsByUserResponses [] [] Song GetSongsByLabelResponses [] [] Song Indices [] int func NewMockDataLayer () MockDataLayer return MockDataLayer Indici: [] int 0, 0, 0, 0, 0, 0, 0  

Il const istruzione elenca tutte le operazioni supportate e gli errori. Ogni operazione ha il proprio indice nel Indici fetta. L'indice per ogni operazione rappresenta quante volte è stato chiamato il metodo corrispondente e quale dovrebbe essere la risposta successiva e l'errore. 

Per ogni metodo che ha un valore di ritorno in aggiunta a un errore, c'è una fetta di risposte. Quando viene chiamato il metodo mock, vengono restituiti la risposta e l'errore corrispondenti (basati sull'indice per questo metodo). Per i metodi che non hanno un valore di ritorno eccetto un errore, non è necessario definire a XXXResponses fetta. 

Si noti che gli errori sono condivisi da tutti i metodi. Ciò significa che se vuoi testare una sequenza di chiamate, devi inserire il numero corretto di errori nell'ordine corretto. Un progetto alternativo userebbe per ogni risposta una coppia composta dal valore di ritorno e dall'errore. Il NewMockDataLayer () la funzione restituisce una nuova struttura del livello dati fittizio con tutti gli indici inizializzati a zero.

Ecco l'implementazione del Getusers () metodo, che illustra questi concetti. 

func (m * MockDataLayer) GetUsers () (utenti [] Utente, err error) i: = m.Indices [GET_USERS] users = m.GetUsersResponses [i] se len (m.Errors)> 0 err = m. Errori [m.Indices [ERRORS]] m.Indices [ERRORS] ++ m.Indices [GET_USERS] ++ return 

La prima riga ottiene l'indice corrente del GET_USERS operazione (sarà inizialmente 0). 

La seconda riga ottiene la risposta per l'indice corrente. 

La terza alla quinta riga assegna l'errore dell'indice corrente se il Errori il campo è stato popolato e incrementa l'indice degli errori. Quando si verifica il percorso felice, l'errore sarà pari a zero. Per renderlo più facile da usare, puoi semplicemente evitare di inizializzare il Errori campo e quindi ogni metodo restituirà nil per l'errore.

La riga successiva incrementa l'indice, quindi la prossima chiamata otterrà la risposta corretta.

L'ultima riga torna indietro. I valori di ritorno con nome per utenti ed err sono già popolati (o nil per impostazione predefinita err).

Ecco un altro metodo, GetLabels (), che segue lo stesso schema. L'unica differenza è quale indice viene utilizzato e quale raccolta di risposte predefinite viene utilizzata.

func (m * MockDataLayer) GetLabels () (labels [] Label, err error) i: = m.Indices [GET_LABELS] labels = m.GetLabelsResponses [i] se len (m.Errors)> 0 err = m. Errori [m.Indices [ERRORS]] m.Indices [ERRORS] ++ m.Indices [GET_LABELS] ++ return 

Questo è un primo esempio di un caso d'uso in cui i generici potrebbero salvare un lotto del codice boilerplate. È possibile sfruttare il riflesso allo stesso effetto, ma è al di fuori dello scopo di questo tutorial. Il principale take-away qui è che il livello dati simulato può seguire uno schema generico e supportare qualsiasi scenario di test, come vedrai presto.

Che ne dici di alcuni metodi che restituiscono un errore? Guarda il Creare un utente() metodo. È ancora più semplice perché riguarda solo gli errori e non ha bisogno di gestire le risposte predefinite.

func (m * MockDataLayer) CreateUser (utente utente) (err error) if len (m.Errors)> 0 i: = m.Indices [CREATE_USER] err = m.Errors [m.Indices [ERRORS]] m. Indici [ERRORS] ++ return 

Questo finto strato di dati è solo un esempio di ciò che serve per prendere in giro un'interfaccia e fornire alcuni servizi utili da testare. Puoi venire con la tua implementazione di simulazione o usare le librerie di simulazione disponibili. Esiste persino un framework standard di GoMock. 

Personalmente trovo i framework finti facili da implementare e preferisco eseguire il rollover (spesso generandoli automaticamente) perché trascorro la maggior parte del mio tempo di sviluppo scrivendo test e dipendenze di derisione. YMMV.

Test contro un livello di dati astratti

Ora che abbiamo uno strato di dati simulati, scriviamo alcuni test su di esso. È importante rendersi conto che qui non testiamo il livello dati stesso. Testeremo il livello dati stesso con altri metodi più avanti in questa serie. Lo scopo qui è di testare la logica del codice che dipende dal livello di dati astratti.

Ad esempio, supponiamo che un utente desideri aggiungere un brano, ma abbiamo una quota di 100 brani per utente. Il comportamento previsto è che se l'utente ha meno di 100 canzoni e la canzone aggiunta è nuova, verrà aggiunta. Se il brano esiste già, restituisce un errore "Duplicate song". Se l'utente ha già 100 canzoni, restituisce un errore "Quota canzone superata".   

Scriviamo un test per questi casi di test usando il nostro livello dati simulato. Questo è un test in white-box, il che significa che è necessario conoscere i metodi del livello dati che il codice sotto test chiamerà e in quale ordine in modo da poter popolare correttamente le risposte e gli errori simulati. Quindi l'approccio test-first non è l'ideale qui. Scriviamo prima il codice. 

Ecco il SongManager struct. Dipende solo dal livello di dati astratti. Ciò ti consentirà di passare a un'implementazione di un livello dati reale in produzione, ma a un livello dati fittizio durante il test.

Il SongManager stesso è completamente agnostico all'attuazione concreta del DataLayer interfaccia. Il SongManager struct accetta anche un utente, che memorizza. Presumibilmente, ogni utente attivo ha il suo SongManager istanza, e gli utenti possono solo aggiungere canzoni per se stessi. Il NewSongManager ()la funzione garantisce l'input DataLayer l'interfaccia non è nulla.

pacchetto song_manager import ("errors". "abstract_data_layer") const (MAX_SONGS_PER_USER = 100) digita SongManager struct user User dal DataLayer func NewSongManager (utente User, dal DataLayer) (* SongManager, errore) if dal == nil return nil, errors.New ("DataLayer non può essere nil") return & SongManager user, dal, nil 

Implementiamo un AddSong () metodo. Il metodo chiama il livello dati GetSongsByUser () prima, e poi passa attraverso diversi controlli. Se tutto è OK, chiama il livello dati AddSong () metodo e restituisce il risultato.

func (lm * SongManager) Errore AddSong (newSong Song, labels [] Label) songs, err: = lm.dal.GetSongsByUser (lm.user) se err! = nil return nil // Verifica se la canzone è un duplicato per _, song: = range songs if song.Url == newSong.Url return errors.New ("Duplicate song") // Verifica se l'utente ha il numero massimo di brani se len (songs) == MAX_SONGS_PER_USER  return errors.New ("Quota canzone superata") return lm.dal.AddSong (user, newSong, labels) 

Guardando questo codice, puoi vedere che ci sono altri due casi di test che abbiamo trascurato: le chiamate ai metodi del livello dati GetSongByUser () e AddSong () potrebbe fallire per altri motivi. Ora, con l'implementazione di SongManager.AddSong () di fronte a noi, possiamo scrivere un test completo che copre tutti i casi d'uso. Iniziamo con il percorso felice. Il TestAddSong_Success () metodo crea un utente di nome Gigi e un mock layer di dati.

Compila il GetSongsByUserResponses campo con una sezione che contiene una sezione vuota, che si tradurrà in una sezione vuota quando il SongManager chiama GetSongsByUser () sul livello dati fittizio senza errori. Non è necessario fare nulla per la chiamata al livello dati fittizio AddSong () metodo, che restituirà l'errore nil di default. Il test verifica solo che in effetti non è stato restituito alcun errore dalla chiamata genitore al SongManager AddSong () metodo.   

pacchetto song_manager import ("testing". "abstract_data_layer". "concrete_data_layer") func TestAddSong_Success (t * testing.T) u: = Utente Nome: "Gigi", Email: "[email protected]" simulato: = NewMockDataLayer () // Prepara mock risposte mock.GetSongsByUserResponses = [] [] Canzone  lm, err: = NewSongManager (u, & mock) se err! = Nil t.Error ("NewSongManager () ha restituito 'nil' ") url: = https://www.youtube.com/watch?v=MlW7T0SUH0E" err = lm.AddSong (Canzone Url: url ", Nome:" Chacarron ", nil) se err! = nil  t.Error ("AddSong () failed") $ go test PASS ok song_manager 0.006 s 

Anche testare le condizioni di errore è semplicissimo. Hai il pieno controllo su ciò che il livello dati restituisce dalle chiamate a GetSongsByUser () e AddSong (). Ecco un test per verificare che quando si aggiunge una canzone duplicata si ottiene di nuovo il messaggio di errore corretto.

func TestAddSong_Duplicate (t * testing.T) u: = Utente Nome: "Gigi", Email: "[email protected]" mock: = NewMockDataLayer () // Prepara risposte fittizie mock.GetSongsByUserResponses = [] [] Song testSong lm, err: = NewSongManager (u, & mock) se err! = Nil t.Error ("NewSongManager () ha restituito 'nil'") err = lm.AddSong (testSong, nil) se err == nil t.Error ("AddSong () dovrebbe avere fallito") if err.Error ()! = "Duplicate song" t.Error ("AddSong () errato errore:" + err.Error ())  

I seguenti due test test verificano che il messaggio di errore corretto venga restituito quando lo stesso livello dati non riesce. Nel primo caso il livello dei dati GetSongsByUser () restituisce un errore.

func TestAddSong_DataLayerFailure_1 (t * testing.T) u: = Utente Nome: "Gigi", Email: "[email protected]" mock: = NewMockDataLayer () // Prepara mock risposte mock.GetSongsByUserResponses = [] [] Song  e: = errors.New ("GetSongsByUser () failure") mock.Errors = [] error e lm, err: = NewSongManager (u, & mock) se err! = Nil t.Error ( "NewSongManager () ha restituito 'nil'") err = lm.AddSong (testSong, nil) se err == nil t.Error ("AddSong () dovrebbe avere avuto esito negativo") if err.Error ()! = " GetSongsByUser () failure "t.Error (" AddSong () Errore errato: "+ err.Error ()) 

Nel secondo caso, il livello dei dati AddSong () metodo restituisce un errore. Dalla prima chiamata a GetSongsByUser () dovrebbe avere successo, il mock.Errors slice contiene due elementi: nil per la prima chiamata e l'errore per la seconda chiamata. 

func TestAddSong_DataLayerFailure_2 (t * testing.T) u: = Utente Nome: "Gigi", Email: "[email protected]" mock: = NewMockDataLayer () // Prepara mock risposte mock.GetSongsByUserResponses = [] [] Song  e: = errors.New ("AddSong () failure") mock.Errors = [] error nil, e lm, err: = NewSongManager (u, & mock) se err! = Nil t. Errore ("NewSongManager () ha restituito 'nil'") err = lm.AddSong (testSong, nil) se err == nil t.Error ("AddSong () dovrebbe avere esito negativo") if err.Error ()! = "AddSong () failure" t.Error ("AddSong () Errore errato:" + err.Error ())

Conclusione

In questo tutorial, abbiamo introdotto il concetto di un livello dati astratto. Quindi, utilizzando il dominio di gestione della musica personale, abbiamo dimostrato come progettare un livello dati, creare un livello dati fittizio e utilizzare il livello dati fittizio per testare l'applicazione. 

Nella seconda parte, ci concentreremo sui test utilizzando un livello dati reale in memoria. Rimanete sintonizzati.