Test di codice intensivo dei dati con Go, parte 2

Panoramica

Questa è la seconda parte su cinque in una serie di tutorial sul test del codice ad alta intensità di dati. Nella prima parte, ho coperto il progetto di un livello di dati astratti che consente test adeguati, come gestire gli errori nel livello dati, come simulare il codice di accesso ai dati e come testare un livello dati astratto. In questo tutorial, esaminerò un vero e proprio livello di dati in memoria basato sul popolare SQLite. 

Test contro un archivio dati in memoria

Testare contro un livello di dati astratti è ottimo per alcuni casi d'uso in cui è necessaria molta precisione, capisci esattamente che cosa chiama il codice sottoposto a test contro il livello dati e stai preparando le risposte simulate.

A volte, non è così facile. La serie di chiamate al livello dati può essere difficile da capire, o ci vuole un grande sforzo per preparare risposte predefinite corrette che siano valide. In questi casi, potrebbe essere necessario lavorare contro un archivio dati in memoria. 

I vantaggi di un archivio dati in memoria sono:

  • È molto veloce. 
  • Lavori contro un vero archivio di dati.
  • Spesso puoi popolare da zero usando file o codice.

In particolare se l'archivio dati è un DB relazionale, SQLite è un'opzione fantastica. Ricorda che ci sono differenze tra SQLite e altri popolari DB relazionali come MySQL e PostgreSQL.

Assicurati di tenerne conto nei test. Si noti che si accede ancora ai dati tramite il livello dati astratto, ma ora il backup è il deposito dati in memoria durante i test. Il tuo test popolerà i dati del test in modo diverso, ma il codice in esame è beatamente inconsapevole di ciò che sta accadendo.

Utilizzando SQLite

SQLite è un DB incorporato (collegato alla tua applicazione). Non esiste un server DB separato in esecuzione. In genere memorizza i dati in un file, ma ha anche l'opzione di un backing store in memoria. 

Ecco il InMemoryDataStore struct. Fa anche parte del concrete_data_layer pacchetto, e importa il pacchetto di terze parti go-sqlite3 che implementa l'interfaccia standard "database / sql" di Golang.  

pacchetto concrete_data_layer import ("database / sql". "abstract_data_layer" _ "github.com/mattn/go-sqlite3" "time" "fmt") digita InMemoryDataLayer struct db * sql.DB

Costruire il livello dati in memoria

Il NewInMemoryDataLayer () la funzione di costruzione crea un DB sqlite in memoria e restituisce un puntatore al file InMemoryDataLayer

func NewInMemoryDataLayer () (* InMemoryDataLayer, errore) db, err: = sql.Open ("sqlite3", ": memoria:") se err! = nil return nil, err err = createSqliteSchema (db) return & InMemoryDataLayer  db, nil 

Si noti che ogni volta che si apre un nuovo DB: "memoria:", si inizia da zero. Se vuoi persistenza attraverso più chiamate a NewInMemoryDataLayer (), dovresti usare File :: memoria:? cache = condivisa. Vedi questo thread di discussione su GitHub per maggiori dettagli.

Il InMemoryDataLayer implementa il DataLayer interfaccia e archivia effettivamente i dati con relazioni corrette nel suo database SQLite. Per fare ciò, dobbiamo prima creare uno schema appropriato, che è esattamente il lavoro del createSqliteSchema () funzione nel costruttore. Crea tre tabelle di dati: song, utente e etichetta e due tabelle di riferimenti incrociati, label_song e user_song.

Aggiunge alcuni vincoli, indici e chiavi esterne per mettere in relazione le tabelle l'una con l'altra. Non mi dilungherò sui dettagli specifici. L'essenza di questo è che l'intero schema DDL è dichiarato come una singola stringa (costituita da più istruzioni DDL) che vengono poi eseguite utilizzando il db.Exec () metodo, e se qualcosa va storto, restituisce un errore. 

func createSqliteSchema (db * sql.DB) error schema: = 'CREATE TABLE SE NON ESISTE song (id INTEGER PRIMARY KEY AUTOINCREMENT, url TESTO UNICO, nome TEXT, descrizione TEXT); CREATE TABLE SE NON ESISTE utente (id INTEGER PRIMARY KEY AUTOINCREMENT, nome TEXT, email TEXT UNICO, registered_at TIMESTAMP, last_login TIMESTAMP); CREATE INDEX user_email_idx ON utente (email); CREATE TABLE SE NON ESISTE etichetta (id INTEGER PRIMARY KEY AUTOINCREMENT, nome TEXT UNICO); CREATE INDEX label_name_idx ON label (name); CREATE TABLE SE NON ESISTE label_song (label_id INTEGER NOT NULL REFERENCES etichetta (id), song_id INTEGER NOT NULL REFERENCES song (id), PRIMARY KEY (label_id, song_id)); CREATE TABLE SE NON ESISTE user_song (user_id INTEGER NOT NULL REFERENCES utente (id), song_id INTEGER NOT NULL REFERENCES song (id), PRIMARY KEY (user_id, song_id)); ' _, err: = db.Exec (schema) return err 

È importante rendersi conto che mentre SQL è standard, ogni sistema di gestione del database (DBMS) ha il proprio sapore e la definizione esatta dello schema non funzionerà necessariamente come lo è per un altro DB.

Implementazione del livello dati in memoria

Per darti un'idea dello sforzo di implementazione di un livello dati in memoria, ecco un paio di metodi: AddSong () e GetSongsByUser ()

Il AddSong () il metodo fa molto lavoro. Inserisce un record nel canzone tabella così come in ciascuna delle tabelle di riferimento: label_song e user_song. Ad ogni punto, se qualche operazione fallisce, restituisce solo un errore. Non utilizzo nessuna transazione perché è progettata solo a scopo di test e non mi preoccupo dei dati parziali nel DB.

func (m * InMemoryDataLayer) AddSong (utente User, song Song, labels [] Label) error s: = 'INSERT INTO istruzione (url, nome, descrizione) valori (?,?,?)', err: = m .db.Prepare (s) se err! = nil return err risultato, err: = istruzione.Exec (song.Url, song.Name, song.Description) se err! = nil return err songId, err: = result.LastInsertId () se err! = nil return err s = "ID SELECT FROM utente dove email =?" rows, err: = m.db.Query (s, user.Email) se err! = nil return err var userId int per rows.Next () err = rows.Scan (& userId) se err! = nil  return err s = 'INSERT INTO istruzione user_song (user_id, song_id) (?,?)', err = m.db.Prepare (s) se err! = nil return err _, err = statement.Exec (userId, songId) se err! = nil return err var labelId int64 s: = "INSERIRE i valori dell'etichetta (nome) (?)" label_ins, err: = m.db.Prepare (s) se err! = nil return err s = 'INSERISCI i valori label_song (label_id, song_id) (?,?)' label_song_ins, err: = m.db.Prepare (s) se err! = nil return err per _, t: = range labels s = "SELECT id FROM label where name =?" rows, err: = m.db.Query (s, t.Name) se err! = nil return err labelId = -1 per rows.Next () err = rows.Scan (& labelId) se err! = nil return err if labelId == -1 result, err = label_ins.Exec (t.Name) se err! = nil return err labelId, err = result.LastInsertId () se err! = nil return err  result, err = label_song_ins.Exec (labelId, songId) se err! = nil return err return nil 

Il GetSongsByUser () usa un join + sub-select dal user_song riferimenti incrociati per restituire i brani per un utente specifico. Usa il Query () metodi e poi scansioni ogni riga per popolare a Canzone struct dal modello a oggetti di dominio e restituisce una porzione di canzoni. L'implementazione di basso livello come un DB relazionale è nascosta in modo sicuro.

func (m * InMemoryDataLayer) GetSongsByUser (u Utente) ([] Canzone, errore) s: = 'SELEZIONA url, titolo, descrizione FROM song L INNER JOIN user_song UL ON UL.song_id = L.id WHERE UL.user_id = ( SELECT id from user WHERE email =?) 'Rows, err: = m.db.Query (s, u.Email) se err! = Nil return nil, err per rows.Next () var song Song err = rows.Scan (& song.Url, & song.Title, & song.Description) se err! = nil return nil, err songs = append (canzoni, song) return songs, nil 

Questo è un grande esempio di utilizzo di un DB relazionale reale come sqlite per implementare l'archivio dati in-memory e il rollover nostro, il che richiederebbe di mantenere le mappe e garantire che tutta la contabilità sia corretta. 

Esecuzione di test contro SQLite

Ora che disponiamo di un livello dati in memoria adeguato, diamo un'occhiata ai test. Ho inserito questi test in un pacchetto separato chiamato sqlite_test, e importiamo localmente il livello di dati astratti (il modello di dominio), il livello di dati concreti (per creare il livello di dati in memoria) e il gestore di brani (il codice sotto test). Preparo anche due canzoni per i test del sensazionale artista panamense El Chombo!

pacchetto sqlite_test import ("testing". "abstract_data_layer". "concrete_data_layer". "song_manager") const (url1 = "https://www.youtube.com/watch?v=MlW7T0SUH0E" url2 = "https: // www. youtube.com/watch?v=cVFDlg4pbwM ") var testSong = Canzone Url: url1, Nome:" Chacaron " var testSong2 = Canzone Url: url2, Nome:" El Gato Volador " 

I metodi di prova creano un nuovo livello dati in memoria per iniziare da zero e ora possono chiamare metodi sul livello dati per preparare l'ambiente di test. Quando tutto è impostato, possono richiamare i metodi del gestore brani e successivamente verificare che il livello dati contenga lo stato previsto.

Ad esempio, il AddSong_Success () metodo di prova crea un utente, aggiunge una canzone usando il gestore brani AddSong () metodo, e verifica che in seguito chiamare GetSongsByUser () restituisce la canzone aggiunta. Quindi aggiunge un'altra canzone e verifica di nuovo.

func TestAddSong_Success (t * testing.T) u: = Utente Nome: "Gigi", Email: "[email protected]" dl, err: = NewInMemoryDataLayer () se err! = nil t.Error (" Impossibile creare il livello dati in memoria ") err = dl.CreateUser (u) se err! = Nil t.Error (" Impossibile creare l'utente ") lm, err: = NewSongManager (u, dl) se err ! = nil t.Error ("NewSongManager () ha restituito 'nil'") err = lm.AddSong (testSong, nil) se err! = nil t.Error ("AddSong () failed") songs, err : = dl.GetSongsByUser (u) if err! = nil t.Error ("GetSongsByUser () failed") if len (songs)! = 1 tEError ('GetSongsByUser () non ha restituito una canzone come previsto ') se songs [0]! = testSong t.Error ("Il brano aggiunto non corrisponde alla song di input") // Aggiungi un altro brano err = lm.AddSong (testSong2, nil) se err! = nil  t.Error ("AddSong () failed") songs, err = dl.GetSongsByUser (u) se err! = nil t.Error ("GetSongsByUser () failed") if len (songs)! = 2 t .Error ('GetSongsByUser () non ha restituito due brani come previsto') if songs [0]! = TestSong t.Error ("Added song non corrisponde alla song di input ") se songs [1]! = testSong2 t.Error (" La canzone aggiunta non corrisponde alla song di input ") 

Il TestAddSong_Duplicate () il metodo di prova è simile, ma invece di aggiungere una nuova canzone la seconda volta, aggiunge la stessa canzone, il che si traduce in un errore di duplicazione della canzone:

 u: = Utente Nome: "Gigi", Email: "[email protected]" dl, err: = NewInMemoryDataLayer () se err! = nil t.Error ("Impossibile creare il livello dati in memoria")  err = dl.CreateUser (u) se err! = nil t.Error ("Impossibile creare l'utente") lm, err: = NewSongManager (u, dl) se err! = nil t.Error ("NewSongManager () ha restituito 'nil' ") err = lm.AddSong (testSong, nil) se err! = nil t.Error (" AddSong () failed ") songs, err: = dl.GetSongsByUser (u) se err ! = nil t.Error ("GetSongsByUser () failed") if len (songs)! = 1 tEError ('GetSongsByUser () non ha restituito un brano come previsto') if songs [0]! = testSong t.Error ("Il brano aggiunto non corrisponde alla song di input") // Aggiungi di nuovo lo stesso brano err = lm.AddSong (testSong, nil) se err == nil t.Error ('AddSong () dovrebbe avere fallito per una canzone duplicata ') expectedErrorMsg: = "Duplicate song" errorMsg: = err.Error () if errorMsg! = expectedErrorMsg t.Error (' AddSong () ha restituito un messaggio di errore errato per la canzone duplicata ')

Conclusione

In questo tutorial, abbiamo implementato un livello dati in memoria basato su SQLite, compilato un database SQLite in memoria con dati di test e utilizzato il livello dati in memoria per testare l'applicazione.

Nella terza parte, ci concentreremo su test su un livello dati complesso locale costituito da più archivi di dati (un DB relazionale e una cache Redis). Rimanete sintonizzati.