Testing av dataintensiv kode med Go, del 1

Oversikt

Mange ikke-trivielle systemer er også datakrevende eller datadrevne. Testing av delene av systemene som er dataintensive, er veldig forskjellig fra testing av kodeintensive systemer. For det første kan det være mye sofistikering i selve datalaget, for eksempel hybriddatabutikker, caching, backup og redundans.

Alt dette maskineriet har ingenting å gjøre med selve applikasjonen, men må testes. For det andre kan koden være veldig generisk, og for å teste den må du generere data som er strukturert på en bestemt måte. I denne serien med fem opplæringsprogrammer vil jeg ta opp alle disse aspektene, utforske flere strategier for å designe testbare dataintensive systemer med Go, og dykke inn i konkrete eksempler. 

I del ett vil jeg gå over utformingen av et abstrakt datalag som muliggjør riktig testing, hvordan man gjør feilhåndtering i datalaget, hvordan man mocker dataadgangskoden, og hvordan man tester mot et abstrakt datalag. 

Testing mot et datalag

Å håndtere virkelige datalager og deres intricacies er komplisert og uavhengig av forretningslogikken. Konseptet med et datalag gjør at du kan avsløre et pent grensesnitt til dataene dine og skjule gory detaljer om nøyaktig hvordan dataene er lagret og hvordan du får tilgang til det. Jeg bruker et eksempelprogram som heter "Songify" for personlig musikkbehandling for å illustrere konseptene med ekte kode.

Utforming av et abstrakt datalag

La oss se gjennom det personlige musikkadministrasjonsdomenet - brukere kan legge til sanger og merke dem - og vurdere hvilke data vi må lagre og hvordan vi skal få tilgang til det. Objektene i vårt domene er brukere, sanger og etiketter. Det er to kategorier operasjoner du vil utføre på data: spørringer (skrivebeskyttet) og tilstandsendringer (opprett, oppdater, slett). Her er et grunnleggende grensesnitt for datalaget:

pakke abstract_data_layer import "time" type Song struktur Url streng Navn streng Beskrivelse streng type Etikett struktur Navn streng type Brukerstruktur Navn streng E-post streng Registrert time.Time LastLogin time.Time type DataLayer grensesnitt // Queries (read -Enne) GetUsers () ([] Bruker, feil) GetUserByEmail (e-poststreng) (Bruker, feil) GetLabels () ([] Etikett, feil) GetSongs () ([] Sang, feil) GetSongsByUser (brukerbruker) ] Sang, feil) GetSongsByLabel (etikettstreng) ([] Sang, feil) // Statens skiftende operasjoner CreateUser (bruker Bruker) feil Endre brukernavn (bruker Bruker, navn streng) feil AddLabel (label string) error AddSong (bruker Bruker, sang Song , etiketter [] Etikett) feil 

Merk at formålet med denne domenemodellen er å presentere et enkelt, men ikke helt, trivielt datalag for å demonstrere testaspekter. Tydeligvis vil det i en ekte applikasjon være flere objekter som album, sjangere, artister og mye mer informasjon om hver sang. Hvis push kommer til å skyve, kan du alltid lagre vilkårlig informasjon om en sang i beskrivelsen, samt legge til så mange etiketter som du vil.

I praksis kan det være lurt å dele data lagret i flere grensesnitt. Noen av strukturene kan ha flere attributter, og metodene kan kreve flere argumenter (for eksempel alle GetXXX ()metoder vil sannsynligvis kreve noen personsøkingsargumenter). Det kan hende du trenger andre grensesnitt for datatilgang og metoder for vedlikeholdsoperasjoner som masseinnlasting, sikkerhetskopiering og overføringer. Det er noen ganger fornuftig å eksponere et asynkront datatilgangsgrensesnitt i stedet for eller i tillegg til det synkrone grensesnittet.

Hva fikk vi fra dette abstrakte datalaget?

  • One-stop-butikk for data tilgang operasjoner.
  • Klar visning av datahåndteringskravene til våre applikasjoner i domenevilkår.
  • Evne til å endre konkret datalag implementering på vilje.
  • Evne til å utvikle domenet / forretningslogikklaget tidlig mot grensesnittet før det konkrete datalaget er komplett eller stabilt.
  • Sist men ikke minst, evnen til å spotte datalaget for rask og fleksibel testing av domenet / forretningslogikken.

Feil og feilhåndtering i datalaget

Dataene kan lagres i flere distribuerte datalager, på flere klynger på forskjellige geografiske steder i en kombinasjon av on-premise datasentre og skyen. 

Det blir feil, og disse feilene må håndteres. Ideelt sett kan feilhåndteringslogikken (retries, timeouts, melding om katastrofale feil) håndteres av det konkrete datalaget. Domenekodekoden skal bare få tilbake dataene eller en generisk feil når dataene ikke er tilgjengelig. 

I noen tilfeller kan domenelogikken kanskje ha mer granulær tilgang til dataene og velge en tilbakekallingsstrategi i visse situasjoner (f.eks. Bare delvise data er tilgjengelige fordi en del av klyngen er utilgjengelig, eller dataene er foreldede fordi hurtigbufferen ikke ble fornyet ). Disse aspektene har implikasjoner for utformingen av datalaget og for testing. 

Når det gjelder testing, bør du returnere dine egne feil definert i abstrakt datalag og kartlegge alle konkrete feilmeldinger til dine egne feiltyper eller stole på svært generiske feilmeldinger..   

Mocking Data Access Code

La oss mocke vårt datalag. Formålet med mock er å erstatte det virkelige datalaget under testene. Det krever at mock data lag å utstyre det samme grensesnittet og å kunne reagere på hver rekkefølge av metoder med en hermetisk (eller beregnet) respons. 

I tillegg er det nyttig å holde oversikt over hvor mange ganger hver metode ble kalt. Jeg vil ikke demonstrere det her, men det er også mulig å holde styr på rekkefølgen på samtaler til forskjellige metoder og hvilke argumenter som ble sendt til hver metode for å sikre en bestemt kjede av samtaler. 

Her er mock data lagstrukturen.

pakke 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 FEIL) type MockDataLayer struct Feil [] feil GetUsersResponses [] [] Bruker GetUserByEmailResponses [] User GetLabelsResponses [] [] Etikett GetSongsResponses [] [] Song GetSongsByUserResponses [] [] Song GetSongsByLabelResponses [] [] Sangindikatorer [] int func NewMockDataLayer () MockDataLayer return MockDataLayer Indices: [] int 0, 0, 0, 0, 0, 0, 0, 0  

De konst setningen viser alle støttede operasjoner og feilene. Hver operasjon har sin egen indeks i indekser skjære. Indeksen for hver operasjon representerer hvor mange ganger den tilsvarende metoden ble kalt, så vel som hva neste svar og feil burde være. 

For hver metode som har en returverdi i tillegg til en feil, er det en del svar. Når mock-metoden blir kalt, returneres tilsvarende svar og feil (basert på indeksen for denne metoden). For metoder som ikke har en returverdi unntatt en feil, er det ikke nødvendig å definere a XXXResponses skjære. 

Merk at feilene deles av alle metoder. Det betyr at hvis du vil teste en sekvens av anrop, må du injisere riktig antall feil i riktig rekkefølge. Et alternativt design vil bruke for hver respons et par bestående av returverdi og feil. De NewMockDataLayer () funksjon returnerer en ny mock data lag struktur med alle indeksene initialisert til null.

Her er implementeringen av GetUsers () metode som illustrerer disse konseptene. 

func (m * MockDataLayer) GetUsers () (brukere [] Bruker, feil feil) i: = m.Indices [GET_USERS] users = m.GetUsersResponses [i] hvis len (m.Errors)> 0 err = m. Feil [m.Indices [ERRORS]] m.Indices [ERRORS] ++ m.Indices [GET_USERS] ++ return 

Den første linjen får den nåværende indeksen til GET_USERS operasjon (vil være 0 først). 

Den andre linjen får svaret for gjeldende indeks. 

Den tredje til femte linjen tilordner feilen til den nåværende indeksen hvis feil feltet ble fylt ut og øke feilindeksen. Når du tester den gode banen, vil feilen være null. For å gjøre det enklere å bruke, kan du bare unngå å initialisere feil feltet og deretter vil hver metode returnere null for feilen.

Neste linje øker indeksen, slik at neste anrop får riktig svar.

Den siste linjen returnerer bare. De navngitte returverdiene for brukere og feil er allerede befolket (eller null som standard for feil).

Her er en annen metode, GetLabels (), som følger det samme mønsteret. Den eneste forskjellen er hvilken indeks som brukes og hvilken samling av hermetiske svar som brukes.

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

Dette er et godt eksempel på en brukstilfeller der generikk kan lagre en mye av boilerplate kode. Det er mulig å utnytte refleksjon til samme effekt, men det er utenfor omfanget av denne opplæringen. Hovedopptaket her er at mock datalaget kan følge et generelt formål og støtte ethvert test scenario, som du snart vil se.

Hva med noen metoder som bare gir en feil? Sjekk ut Opprett bruker() metode. Det er enda enklere fordi det bare handler om feil og ikke trenger å håndtere de hermetiske svarene.

func (m * MockDataLayer) CreateUser (bruker bruker) (feil feil) hvis len (m.Errors)> 0 i: = m.Indices [CREATE_USER] err = m.Errors [m.Indices [ERRORS]] m. Indekser [FEIL] ++ retur 

Dette mock datalaget er bare et eksempel på det som trengs for å mocke et grensesnitt og gi noen nyttige tjenester for å teste. Du kan komme med din egen mock implementering eller bruke tilgjengelige mock biblioteker. Det er enda et standard GoMock-rammeverk. 

Personlig finner jeg mock rammeverk enkelt å implementere og foretrekker å rulle mine egne (ofte genererer dem automatisk) fordi jeg tilbringer mesteparten av min utviklingstid, skriver tester og mocking avhengigheter. YMMV.

Testing mot et abstrakt datalag

Nå som vi har et mock datalag, la oss skrive noen tester mot det. Det er viktig å innse at vi ikke tester datalaget selv. Vi vil teste data laget selv med andre metoder senere i denne serien. Hensikten er å teste logikken til koden som avhenger av det abstrakte datalaget.

Anta for eksempel at en bruker vil legge til en sang, men vi har en kvote på 100 sanger per bruker. Den forventede oppførselen er at hvis brukeren har færre enn 100 sanger og den ekstra sangen er ny, vil den bli lagt til. Hvis sangen allerede eksisterer, returnerer den en "Duplicate song" -feil. Hvis brukeren allerede har 100 sanger, returnerer den en "Sangkvot overskredet" -feil.   

La oss skrive en test for disse test sakene ved hjelp av vårt mock datalag. Dette er en white-box-test, noe som betyr at du må vite hvilke metoder i datalaget koden under test skal ringe og i hvilken rekkefølge, slik at du kan fylle ut svarene og feilene på riktig måte. Så test-første tilnærmingen er ikke ideell her. La oss skrive koden først. 

Her er SongManager struct. Det avhenger bare av det abstrakte datalaget. Det vil gjøre det mulig for deg å overføre det til en implementering av et ekte datalag i produksjon, men et mock datalag under testingen.

De SongManager i seg selv er helt agnostisk til den konkrete gjennomføringen av layer grensesnitt. De SongManager struct aksepterer også en bruker, som den lagrer. Formentlig har hver aktiv bruker sin egen SongManager eksempel, og brukere kan bare legge til sanger for seg selv. De NewSongManager ()funksjonen sikrer inngangen layer grensesnittet er ikke null.

pakke song_manager import ("feil". "abstract_data_layer") const (MAX_SONGS_PER_USER = 100) type SongManager struktur bruker User dal DataLayer func NewSongManager (bruker Bruker, dal DataLayer) (* SongManager, feil) if dal == nil return null, feil.Ny ("DataLayer kan ikke være null") returnere og SongManager user, dal, null 

La oss implementere en AddSong () metode. Metoden kaller datalaget GetSongsByUser () først, og så går det gjennom flere sjekker. Hvis alt er greit, kaller det datalaget AddSong () metode og returnerer resultatet.

funk (lm * SongManager) AddSong (newSong Song, etiketter [] Label) error songs, err: = lm.dal.GetSongsByUser (lm.user) hvis feil! = null return nil // Sjekk om sangen er en duplikat for _, sang: = rekke sanger hvis sang.Url == newSong.Url return error.New ("Duplicate song") // Sjekk om brukeren har maksimalt antall sanger hvis len (sanger) == MAX_SONGS_PER_USER  returnere feil.Ny ("Sangkvot overskredet") returnere lm.dal.AddSong (bruker, newSong, etiketter) 

Ser du på denne koden, kan du se at det er to andre testtilfeller vi forsømte: samtalene til datalagets metoder GetSongByUser () og AddSong () kan mislykkes av andre grunner. Nå, med implementeringen av SongManager.AddSong () foran oss kan vi skrive en omfattende test som dekker alle brukstilfeller. La oss starte med den lykkelige stien. De TestAddSong_Success () Metoden skaper en bruker som heter Gigi og et mock datalag.

Det befolker GetSongsByUserResponses feltet med et stykke som inneholder et tomt stykke, som vil resultere i et tomt stykke når SongManager ringer GetSongsByUser () på mock datalaget uten feil. Det er ikke nødvendig å gjøre noe for anropet til mock datalaget AddSong () metode, som vil returnere null feil som standard. Testen bekrefter bare at ingen feil ble returnert fra foreldreanropet til SongManager AddSong () metode.   

pakke song_manager import ("testing". "abstract_data_layer". "concrete_data_layer") func TestAddSong_Success (t * testing.T) u: = Bruker Navn: "Gigi", Epost: "[email protected]" mock: = NewMockDataLayer () // Forbered mock responser mock.GetSongsByUserResponses = [] [] Sang  lm, err: = NewSongManager (u, og mock) hvis feil! = Null t.Error ("NewSongManager () returnert 'nil' ") url: = https://www.youtube.com/watch?v=MlW7T0SUH0E" err = lm.AddSong (Song Url: url ", Navn:" Chacarron ", null) hvis err! = null  t.Error ("AddSong () mislyktes") $ go test PASS ok song_manager 0.006s 

Testfeilforholdene er også veldig enkle. Du har full kontroll over hva datalaget returnerer fra samtalene til GetSongsByUser () og AddSong (). Her er en test for å bekrefte at når du legger til en duplikat sang, får du den riktige feilmeldingen tilbake.

func TestAddSong_Duplicate (t * testing.T) u: = Bruker Navn: "Gigi", E-post: "[email protected]" mock: = NewMockDataLayer () // Forbered mock responser mock.GetSongsByUserResponses = [] [] Sang testSong lm, err: = NewSongManager (u, og mock) hvis feil! = Null t.Error ("NewSongManager () returned 'nil'") err = lm.AddSong (testSong, null) == nil t.Error ("AddSong () burde ha mislyktes") hvis err.Error ()! = "Dupliser sang" t.Error ("AddSong () feil feil:" + err.Error ())  

Følgende to testtilfeller tester at feilmeldingen returneres når datalaget selv mislykkes. I det første tilfellet er datalaget GetSongsByUser () returnerer en feil.

func TestAddSong_DataLayerFailure_1 (t * testing.T) u: = Bruker Navn: "Gigi", E-post: "[email protected]" mock: = NewMockDataLayer () // Forbered mock responser mock.GetSongsByUserResponses = [] [] Sang  e: = feil.Ny ("GetSongsByUser () feil") mock.Errors = [] feil e lm, err: = NewSongManager (u, og mock) hvis feil! = Null t. "NewSongManager () returnerte nil '") err = lm.AddSong (testSong, null) hvis feil == nil t.Error ("AddSong () burde ha mislyktes") hvis err.Error ()! = " GetSongsByUser () feil "t.Error (" AddSong () feil feil: "+ err.Error ()) 

I andre tilfelle er datalaget AddSong () metode returnerer en feil. Siden det første anropet til GetSongsByUser () skal lykkes, mock.Errors skive inneholder to elementer: null for første samtale og feilen for det andre anropet. 

func TestAddSong_DataLayerFailure_2 (t * testing.T) u: = Bruker Navn: "Gigi", E-post: "[email protected]" mock: = NewMockDataLayer () // Forbered mock responser mock.GetSongsByUserResponses = [] [] Sang  e: = feil.Ny ("AddSong () feil") mock.Errors = [] feil nil, e lm, err: = NewSongManager (u, & mock) hvis feil! = Null t. Feil ("NewSongManager () returnerte nil") err = lm.AddSong (testSong, null) hvis feil == nil t.Error ("AddSong () burde ha mislyktes") hvis feil. = "AddSong () feil" t.Error ("AddSong () feil feil:" + err.Error ())

Konklusjon

I denne veiledningen introduserte vi begrepet et abstrakt datalag. Deretter viste vi ved bruk av det personlige musikkadministrasjonsdomenet hvordan man skal designe et datalag, bygge et mock datalag og bruke mock data laget til å teste applikasjonen. 

I del to vil vi fokusere på testing ved hjelp av et ekte minne lagringsdata. Følg med.