Testing av dataintensiv kod med go, del 1

Översikt

Många icke triviala system är också datakrävande eller datadrivna. Att testa delar av systemen som är dataintensiva är väldigt annorlunda än att testa kodintensiva system. För det första kan det finnas mycket sofistikering i själva dataskiktet, såsom hybriddatabutiker, cachning, backup och redundans.

Allt detta maskineri har inget att göra med själva applikationen, utan måste testas. För det andra kan koden vara mycket generisk, och för att testa den måste du generera data som är strukturerad på ett visst sätt. I denna serie med fem handledningar ska jag ta itu med alla dessa aspekter, utforska flera strategier för att designa testbara datintensiva system med Go och dyka in i specifika exempel. 

I del ett går jag över utformningen av ett abstrakt datalager som möjliggör korrekt testning, hur man gör felhantering i datalagret, hur man mockar dataåtkomstkoden och hur man testar mot ett abstrakt datalag. 

Testa mot ett datalager

Att hantera riktiga datalager och deras komplexa är komplicerade och orelaterade med affärslogiken. Konceptet med ett datalag gör att du kan avslöja ett snyggt gränssnitt till dina data och dölja de gory detaljerna om exakt hur data lagras och hur du får tillgång till den. Jag använder en sampleapplication som heter "Songify" för personlig musikhantering för att illustrera begreppen med riktig kod.

Designing a Abstract Data Layer

Låt oss granska den privata musikhanteringsdomänen. Användare kan lägga till sånger och märka dem - och ta reda på vilka data vi behöver lagra och hur vi ska komma åt det. Föremålen i vår domän är användare, låtar och etiketter. Det finns två kategorier av operationer som du vill utföra på några data: frågor (skrivskyddade) och tillståndsändringar (skapa, uppdatera, ta bort). Här är ett grundläggande gränssnitt för datalagret:

paketet abstract_data_layer import "time" typ Song struct Url-sträng Namnsträng Beskrivningsträng typ Etikettstruktur Namnsträng typ User struct Namnsträng E-poststräng RegisteredAt time.Time LastLogin time.Time typ DataLayer-gränssnitt // Queries (read -Only) GetUsers () ([] Användare, Fel) GetUserByEmail (Användare, Fel) GetLabels () ([] Etikett, Fel) GetSongs () ([] Song, Error) GetSongsByUser (User User) ] Song, error) GetSongsByLabel (etikettsträng) ([] Song, error) // Statusändringsoperationer CreateUser (användarens användarnamn) fel Byt användarnamn (användarnamn, namnsträng) fel AddLabel (label string) error AddSong (användaranvändare, Song Song , etiketter [] Etikettfel 

Observera att syftet med denna domänmodell är att presentera ett enkelt men ändå inte helt trivialt dataskikt för att demonstrera testaspekterna. Självklart kommer det i en verklig applikation att finnas fler objekt som album, genrer, artister och mycket mer information om varje låt. Om push kommer att skjuta, kan du alltid lagra godtycklig information om en sång i beskrivningen, samt bifoga så många etiketter som du vill.

I praktiken kanske du vill dela upp dataskiktet i flera gränssnitt. Vissa av strukturerna kan ha fler attribut, och metoderna kan kräva fler argument (t.ex. alla GetXXX ()metoder kommer förmodligen att kräva några personsökningsargument). Det kan hända att du behöver andra datatillbehörsgränssnitt och metoder för underhållsoperationer som bulklastning, säkerhetskopiering och migreringar. Det är ibland vettigt att exponera ett asynkront dataåtkomstgränssnitt istället för eller i tillägg till det synkrona gränssnittet.

Vad fick vi från detta abstrakta datalager?

  • One-stop-shop för datatillgångsoperationer.
  • Tydlig bild av kraven på datahantering i våra applikationer i domänvillkor.
  • Förmåga att ändra konkreta datalagdimplementering efter vilja.
  • Möjlighet att utveckla domän- / affärslogiklagret tidigt mot gränssnittet innan betongdataskiktet är fullständigt eller stabilt.
  • Sist men inte minst, förmågan att mocka data lagret för snabb och flexibel testning av domänen / affärslogiken.

Fel och felhantering i datalagret

Uppgifterna kan lagras i flera distribuerade datalager på flera kluster över olika geografiska platser i en kombination av datacentre och molnet på plats. 

Det kommer misslyckanden, och dessa misslyckanden måste hanteras. Idealiskt kan felhanteringslogiken (återförsök, tidsavbrott, anmälan av katastrofala fel) hanteras av det konkreta datalageret. Domänlogikoden ska bara återställa data eller ett generiskt fel när data inte är tillgänglig. 

I vissa fall kan domänlogiken kanske ha mer granulär åtkomst till data och välja en återgångsstrategi i vissa situationer (t.ex. endast deldata är tillgänglig eftersom en del av klustret är otillgängligt, eller datan är gammal eftersom cachen inte uppdaterades ). Dessa aspekter har konsekvenser för utformningen av ditt datalager och för dess testning. 

När det gäller testen ska du returnera dina egna fel som definieras i abstrakt datalagret och kartlägga alla konkreta felmeddelanden till dina egna feltyper eller förlita sig på mycket generiska felmeddelanden.   

Mocking Data Access Code

Låt oss spotta vårt datalager. Syftet med mock är att ersätta det verkliga datalaget under test. Det kräver att mock data lagret exponerar samma gränssnitt och att kunna svara på varje sekvens av metoder med ett konservat (eller beräknat) svar. 

Dessutom är det användbart att hålla reda på hur många gånger varje metod heter. Jag kommer inte att demonstrera det här, men det är även möjligt att hålla koll på ordern för samtal till olika metoder och vilka argument som skickades till varje metod för att säkerställa en viss samtalskedja. 

Här är mock data lagret struct.

paketera 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) typ MockDataLayer struct Fel [] error GetUsersResponses [] [] Användare GetUserByEmailResponses [] Användare GetLabelsResponses [] [] Etikett GetSongsResponses [] [] Song GetSongsByUserResponses [] [] Song GetSongsByLabelResponses [] [] Sångindex [] int func NewMockDataLayer () MockDataLayer return MockDataLayer Index: [] int 0, 0, 0, 0, 0, 0, 0, 0  

De const uttalande listar alla de stödda operationerna och felen. Varje operation har sitt eget index i Index skiva. Indexet för varje operation representerar hur många gånger motsvarande metod kallades samt vad nästa svar och fel ska vara. 

För varje metod som har ett returvärde utöver ett fel finns det en del svar. När mock-metoden heter, returneras motsvarande svar och fel (baserat på indexet för denna metod). För metoder som inte har ett returvärde förutom ett fel, behöver du inte definiera a XXXResponses skiva. 

Observera att felen delas av alla metoder. Det betyder att om du vill testa en sekvens av samtal måste du injicera rätt antal fel i rätt ordning. En alternativ design skulle använda för varje svar ett par bestående av returvärdet och felet. De NewMockDataLayer () funktionen returnerar en ny mock data lag struktur med alla index initierad till noll.

Här är genomförandet av GetUsers () metod som illustrerar dessa begrepp. 

func (m * MockDataLayer) GetUsers () (användare [] Användare, felfel) i: = m.Indices [GET_USERS] users = m.GetUsersResponses [i] om len (m.Errors)> 0 err = m. Fel [m.Indices [ERRORS]] m.Indices [ERRORS] ++ m.Indices [GET_USERS] ++ return 

Den första raden får nuvarande index för GET_USERS operation (kommer att vara 0 initialt). 

Den andra raden får svaret för det aktuella indexet. 

Den tredje till femte raden tilldelar felet av det aktuella indexet om fel fältet befolkade och öka felindexet. När du testar den glada sökvägen kommer felet att vara noll. För att göra det enklare att använda, kan du bara undvika att initiera fel fält och sedan returnerar varje metod noll för felet.

Nästa rad ökar indexet, så nästa samtal får det korrekta svaret.

Den sista raden returnerar bara. De angivna returvärdena för användare och err är redan befolkade (eller noll som standard för err).

Här är en annan metod, GetLabels (), som följer samma mönster. Den enda skillnaden är vilket index som används och vilken samling av konserver som används.

func (m * MockDataLayer) GetLabels () (etiketter [] Etikett, felfel) i: = m.Indices [GET_LABELS] labels = m.GetLabelsResponses [i] om len (m.Errors)> 0 err = m. Fel [m.Indices [ERRORS]] m.Indices [ERRORS] ++ m.Indices [GET_LABELS] ++ return 

Detta är ett utmärkt exempel på ett användningsfall där generik kan spara en massa av pannkodskod. Det är möjligt att utnyttja reflektion med samma effekt, men det ligger utanför ramen för denna handledning. Den viktigaste borttagningen här är att mock data lagret kan följa ett generellt mönster och stödja alla test scenario, som du kommer snart.

Vad sägs om några metoder som bara returnerar ett fel? Kolla in Skapa användare() metod. Det är ännu enklare eftersom det bara handlar om fel och behöver inte hantera de blöta svaren.

func (m * MockDataLayer) CreateUser (användaranvändare) (felfel) om len (m.Errors)> 0 i: = m.Indices [CREATE_USER] err = m.Errors [m.Indices [ERRORS]] m. Index [ERRORS] ++ return 

Detta mocka data lagret är bara ett exempel på vad som krävs för att mocka ett gränssnitt och ge några användbara tjänster att testa. Du kan komma med din egen mock-implementering eller använda tillgängliga mock-bibliotek. Det finns till och med en standard GoMock-ram. 

Personligen finner jag mock ramar lätt att implementera och föredrar att rulla min egen (ofta genererar dem automatiskt) eftersom jag spenderar merparten av min utvecklingstid med att skriva test och mocking beroenden. YMMV.

Testa mot ett abstrakt datalag

Nu när vi har ett mocka data lager, låt oss skriva några tester mot det. Det är viktigt att inse att vi här inte testar själva data lagret. Vi kommer att testa dataskiktet själv med andra metoder senare i denna serie. Syftet här är att testa logiken i koden som beror på det abstrakta datalagret.

Anta att en användare vill lägga till en sång, men vi har en kvot på 100 låtar per användare. Det förväntade beteendet är att om användaren har färre än 100 låtar och den tillagda låten är ny kommer den att läggas till. Om låten redan existerar returnerar det ett "Duplicate song" -fel. Om användaren redan har 100 låtar returnerar det ett "Song quota exceeded" -fel.   

Låt oss skriva ett test för dessa testfall med vårt mocka datalager. Detta är ett white-box-test, vilket innebär att du behöver veta vilka metoder för datalagret koden som testas kommer att ringa och i vilken ordning så att du kan fylla de svåra svaren och felen på rätt sätt. Så test-första tillvägagångssättet är inte idealiskt här. Låt oss skriva koden först. 

Här är SongManager struct. Det beror bara på det abstrakta datalagret. Det gör det möjligt för dig att överföra det till en implementering av ett riktigt datalag i produktionen, men ett mocka data lager under testning.

De SongManager själv är helt agnostisk för konkret genomförande av datalayer gränssnitt. De SongManager struct accepterar också en användare som den lagrar. Förmodligen har varje aktiv användare sin egen SongManager exempel och användare kan bara lägga till låtar för sig själva. De NewSongManager ()funktionen garanterar ingången datalayer gränssnittet är inte noll.

paketet song_manager import ("user", "data_layer") const (MAX_SONGS_PER_USER = 100) typ SongManager struktur användare User dal DataLayer func NewSongManager (användaranvändare, dal DataLayer) (* SongManager, fel) if dal == nil return noll, fel.Ny ("DataLayer kan inte vara noll") returnera & SongManager user, dal, nil 

Låt oss genomföra en AddSong () metod. Metoden kallar datalaget GetSongsByUser () först och sedan går det igenom flera kontroller. Om allt är OK kallar det datalagret AddSong () metod och returnerar resultatet.

func (lm * SongManager) AddSong (newSong Song, etiketter [] Etikett) error songs, err: = lm.dal.GetSongsByUser (lm.user) om err! = nil return nil // Kontrollera om låten är en dubblett för _, låt: = intervall låtar om låt.Url == newSong.Url return error.New ("Duplicate song") // Kontrollera om användaren har max antal låtar om len (låtar) == MAX_SONGS_PER_USER  returnera errors.New ("Song quota exceeded") returnera lm.dal.AddSong (användare, newSong, etiketter) 

Om du tittar på den här koden kan du se att det finns två andra testfall som vi försummade: samtalen till datalagerets metoder GetSongByUser () och AddSong () kan misslyckas av andra skäl. Nu med genomförandet av SongManager.AddSong () framför oss kan vi skriva ett omfattande test som täcker alla användarfall. Låt oss börja med den lyckliga vägen. De TestAddSong_Success () Metoden skapar en användare som heter Gigi och ett mock data lagret.

Det befolkar GetSongsByUserResponses fält med en skiva som innehåller en tom skiva, vilket resulterar i en tom skiva när SongManager samtalar GetSongsByUser () på mock data lagret utan något fel. Det finns inget behov av att göra något för samtalet till mock data lagret AddSong () metod som kommer att returnera nil fel som standard. Testet verifierar bara att inget fel har returnerats från föräldraanropet till SongManager AddSong () metod.   

paketet song_manager import ("test". "abstract_data_layer". "concrete_data_layer") func TestAddSong_Success (t * test.T) u: = Användare Namn: "Gigi", Email: "[email protected]" Mock: = NewMockDataLayer () // Förbereda svåra svar mock.GetSongsByUserResponses = [] [] Sång  lm, err: = NewSongManager (du, & mock) om err! = Nil t.Error ("NewSongManager () returneras 'nil' ") url: = https://www.youtube.com/watch?v=MlW7T0SUH0E" err = lm.AddSong (Song Url: url ", Namn:" Chacarron ", nil) om err! = nil  t.Error ("AddSong () misslyckades") $ go test PASS ok song_manager 0.006s 

Testvillkoren är också mycket lätt. Du har full kontroll över vad data lagret returnerar från samtalen till GetSongsByUser () och AddSong (). Här är ett test för att verifiera att när du lägger till en dubblett så får du rätt felmeddelande tillbaka.

func TestAddSong_Duplicate (t * test.T) u: = Användare Namn: "Gigi", E-post: "[email protected]" mock: = NewMockDataLayer () // Förbereda svåra svar mock.GetSongsByUserResponses = [] [] Song testSong lm, err: = NewSongManager (u, & mock) om err! = Nil t.Error ("NewSongManager () returneras" nil ") err = lm.AddSong (testSong, noll) om fel == nil t.Error ("AddSong () skulle ha misslyckats") om err.Error ()! = "Duplicate song" t.Error ("AddSong () fel fel:" + err.Error ())  

Följande två testfall testar att det korrekta felmeddelandet returneras när datalagret i sig misslyckas. I det första fallet är datalaget GetSongsByUser () returnerar ett fel.

func TestAddSong_DataLayerFailure_1 (t * test.T) u: = Användare Namn: "Gigi", E-post: "[email protected]" mock: = NewMockDataLayer () // Förbereda svåra svar mock.GetSongsByUserResponses = [] [] Song  e: = errors.New ("GetSongsByUser () misslyckande") mock.Errors = [] fel e lm, err: = NewSongManager (u, & mock) om err! = Nil t.Error "NewSongManager () returneras nil") err = lm.AddSong (testSong, nil) om err == nil t.Error ("AddSong () skulle ha misslyckats") om err.Error ()! = " GetSongsByUser () fel "t.Error (" AddSong () fel fel: "+ err.Error ()) 

I det andra fallet är datalageret AddSong () Metoden returnerar ett fel. Sedan det första samtalet till GetSongsByUser () ska lyckas, den mock.Errors skivan innehåller två objekt: noll för det första samtalet och felet för det andra samtalet. 

func TestAddSong_DataLayerFailure_2 (t * test.T) u: = Användare Namn: "Gigi", E-post: "[email protected]" mock: = NewMockDataLayer () // Förbereda svåra svar mock.GetSongsByUserResponses = [] [] Song  e: = errors.New ("AddSong () misslyckande") mock.Errors = [] fel nil, e lm, err: = NewSongManager (u & mock) om err! = Nil t. Fel ("NewSongManager () returneras" nil "" err = lm.AddSong (testSong, noll) om err == nil t.Error ("AddSong () ska ha misslyckats") om err.Error ()! = "AddSong () misslyckande" t.Error ("AddSong () fel fel:" + err.Error ())

Slutsats

I denna handledning introducerade vi begreppet abstrakt datalager. Sedan demonstrerade vi med hjälp av domänen för personlig musikhantering hur man konstruerar ett datalag, bygger ett mocka datalager och använder mocka data lagret för att testa programmet. 

I del två kommer vi att fokusera på testning med hjälp av ett riktigt minnesdataskikt. Håll dig igång.