I 1960’erne blev det betragtet som en grundlæggende god praksis inden for softwareudvikling at teste din kode, mens du skrev den. Pionererne inden for softwareudvikling i den tid var fortalere for forskellige niveauer af testning; nogle gik ind for “unit”-testning og andre gjorde ikke, men alle anerkendte vigtigheden af at teste kode.

Exekverbare tests blev muligvis først introduceret af Margaret Hamilton på Apollo-projektet i midten af 1960’erne, hvor hun var ophavsmand til en form for eksekverbar kontrol, som vi i dag kalder “statisk kodeanalyse”. Hun kaldte det “higher-order software”, hvormed hun mente software, der opererer mod anden software i stedet for direkte mod problemdomænet. Hendes “higher-order software” undersøgte kildekoden for at finde mønstre, som man vidste kunne føre til integrationsproblemer.

I 1970 havde folk stort set glemt alt om eksekverbar testning. Selvfølgelig kørte folk programmer og pillede ved dem her og der i hånden, men så længe bygningen ikke brændte ned omkring dem, mente de, at koden var “god nok”. Resultatet har været over 35 år med kode i produktion verden over, som er utilstrækkeligt testet, og som i mange tilfælde ikke fungerer helt efter hensigten eller på en måde, der tilfredsstiller kunderne.

Den idé, at programmører tester undervejs, gjorde sit comeback fra midten af 1990’erne, selv om langt de fleste programmører stadig ikke gør det. Infrastrukturingeniører og systemadministratorer tester deres scripts endnu mindre flittigt, end programmører tester deres applikationskode.

Da vi bevæger os ind i en æra, hvor hurtig implementering af komplicerede løsninger bestående af mange autonome komponenter er ved at blive normen, og “cloud”-infrastrukturer kræver, at vi skal administrere tusindvis af VM’er og containere, der kommer og går, i en skala, der ikke kan håndteres med manuelle metoder, kan man ikke ignorere vigtigheden af eksekverbar, automatiseret testning og kontrol i hele udviklings- og leveringsprocessen; ikke kun for applikationsprogrammerere, men for alle, der er involveret i it-arbejde.

Med fremkomsten af devops (krydsbestøvning af færdigheder, metoder og værktøjer inden for udvikling og drift) og tendenser som “infrastruktur som kode” og “automatisér alle ting” er unit testing blevet en grundlæggende færdighed for både programmører, testere, systemadministratorer og infrastrukturingeniører.

I denne serie af indlæg vil vi introducere idéen om enhedstest af shell-scripts, og derefter vil vi udforske flere enhedstestframeworks, der kan hjælpe med at gøre denne opgave praktisk og bæredygtig i stor skala.

En anden praksis, der måske er ukendt for mange infrastrukturingeniører, er versionsstyring. Senere i denne serie vil vi berøre versionsstyringssystemer og arbejdsgange, som applikationsudviklere bruger, og som også kan være effektive og nyttige for infrastrukturingeniører.

Et script til test

Vivek Gite offentliggjorde et eksempel på et shellscript til overvågning af diskforbrug og til generering af en e-mail-meddelelse, når visse filsystemer overskrider en tærskelværdi. Hans artikel er her: https://www.cyberciti.biz/tips/shell-script-to-watch-the-disk-space.html. Lad os bruge det som testobjekt.

Den oprindelige version af hans script, med tilføjelse af -P-indstillingen på df-kommandoen for at forhindre linjeskift i output, som foreslået i en kommentar fra Per Lindahl, ser således ud:

#!/bin/shdf -HP | grep -vE '^Filesystem|tmpfs|cdrom' | awk '{ print  " "  }' | while read output;do usep=$(echo $output | awk '{ print }' | cut -d'%' -f1 ) partition=$(echo $output | awk '{ print  }' ) if ; then echo "Running out of space \"$partition ($usep%)\" on $(hostname) as on $(date)" | mail -s "Alert: Almost out of disk space $usep%" [email protected] fidone

Vivek fortsætter med at forfine scriptet ud over dette punkt, men denne version vil tjene formålet med dette indlæg.

Automatiserede funktionskontroller

Et par tommelfingerregler om automatiserede funktionskontroller, uanset om vi kontrollerer applikationskode eller et script eller anden form for software:

  • kontrollen skal køre identisk hver gang, uden at der kræves manuelle justeringer for at forberede hver kørsel, og
  • resultatet må ikke være sårbart over for ændringer i udførelsesmiljøet eller data eller andre faktorer, der er eksterne i forhold til den kode, der testes.

Pass, Fail og Error

Du kan påpege, at det er muligt, at scriptet slet ikke vil køre. Det er normalt for enhver form for enhedstestramme for enhver form for applikation. Der er tre udfald, i stedet for to, mulige:

  • Koden under testen udviser den forventede adfærd
  • Koden under testen kører, men udviser ikke den forventede adfærd
  • Koden under testen kører ikke

For praktiske formål er det tredje udfald det samme som det andet; vi skal finde ud af, hvad der gik galt, og rette det. Så vi tænker generelt på disse ting som binære:

Hvad skal vi kontrollere?

I dette tilfælde er vi interesseret i at verificere, at scriptet opfører sig som forventet ved forskellige inputværdier. Vi ønsker ikke at forurene vores enhedskontroller med anden verifikation ud over dette.

Ved gennemgang af den kode, der testes, kan vi se, at når diskforbruget når en tærskelværdi på 90 %, kalder scriptet mail for at sende en meddelelse til systemadministratoren.

I overensstemmelse med generelt accepteret god praksis for unit checks ønsker vi at definere separate cases for at verificere hver adfærd, vi forventer for hvert sæt af startbetingelser.

Med vores “tester”-hat på, kan vi se, at dette er en slags grænsebetingelse. Vi behøver ikke at kontrollere mange forskellige procentdele af diskforbrug individuelt. Vi har kun brug for at kontrollere adfærd ved grænserne. Derfor vil minimumssættet af cases for at give en meningsfuld dækning være:

  • Det sender en e-mail, når diskforbruget når tærsklen
  • Det sender ikke en e-mail, når diskforbruget er under tærsklen

Hvad skal vi ikke kontrollere?

I overensstemmelse med generelt accepteret god praksis for isolation af enhedstests ønsker vi at sikre, at hver af vores cases kan fejle af præcis én grund: Den forventede adfærd sker ikke. I det omfang det er praktisk muligt, ønsker vi at opsætte vores kontroller, så andre faktorer ikke får sagen til at mislykkes.

Det er måske ikke altid omkostningseffektivt (eller endda muligt) at garantere, at eksterne faktorer ikke påvirker vores automatiserede kontroller. Der er tidspunkter, hvor vi ikke kan kontrollere et eksternt element, eller hvor det ville kræve mere tid, kræfter og omkostninger end værdien af kontrollen, og/eller involverer et obskurt edge case, som har en meget lav sandsynlighed for at opstå eller meget lille indvirkning, når det opstår. Det er et spørgsmål om din professionelle vurdering. Som en generel regel skal du gøre dit bedste for at undgå at skabe afhængigheder af faktorer, der ligger uden for den testede kodes rækkevidde.

Vi behøver ikke at verificere, at kommandoerne df, grep, awk, cut og mail virker. Det er uden for rammerne af vores formål. Den, der vedligeholder hjælpeprogrammerne, er ansvarlig for det.

Vi ønsker at vide, om output fra df-kommandoen ikke behandles på den måde, som vi forventer af grep eller awk. Derfor ønsker vi, at de rigtige grep- og awk-kommandoer skal køre i vores kontroller, baseret på output fra df-kommandoen, der passer til hensigten med hvert enkelt testtilfælde. Det er inden for rammerne, fordi kommandolinjeargumenterne til df er en del af scriptet, og scriptet er den kode, der testes.

Det betyder, at vi har brug for en falsk version af df-kommandoen til brug i vores enhedskontroller. Den slags falske komponenter kaldes ofte en mock. En mock står i stedet for en rigtig komponent og leverer foruddefineret output for at styre systemets adfærd på en kontrolleret måde, så vi kan kontrollere opførslen af den kode, der testes, på en pålidelig måde.

Vi ser, at scriptet sender en e-mail-meddelelse, når et filsystem når tærskelbrugsniveauet. Vi ønsker ikke, at vores enhedstjek spytter en masse ubrugelige e-mails ud, så vi vil også gerne mocke mailkommandoen.

Dette script er et godt eksempel til at illustrere mocking af disse kommandoer, da vi vil gøre det på en anden måde for mail end for df.

Mocking the df Command

Scriptet er bygget op omkring df-kommandoen. Den relevante linje i scriptet er:

df -HP | grep -vE '^Filesystem|tmpfs|cdrom' | awk '{ print  " "  }'

Hvis du bare kører df -HP, uden at indsætte piping i grep, vil du se output, der ligner dette:

Filesystem Size Used Avail Use% Mounted onudev 492M 0 492M 0% /devtmpfs 103M 6.0M 97M 6% /run/dev/sda1 20G 9.9G 9.2G 52% /tmpfs 511M 44M 468M 9% /dev/shmtmpfs 5.3M 0 5.3M 0% /run/locktmpfs 511M 0 511M 0% /sys/fs/cgrouptmpfs 103M 8.2k 103M 1% /run/user/1000

Grep- og awk-kommandoerne stripper outputtet ned til dette:

0% udev52% /dev/sda1

Vi har brug for at styre output fra df for at drive vores testcases. Vi ønsker ikke, at resultatet af kontrollen skal variere afhængigt af det faktiske diskforbrug på det system, hvor vi udfører testpakken. Vi kontrollerer ikke diskforbruget; vi kontrollerer logikken i scriptet. Når scriptet kører i produktion, vil det kontrollere diskforbruget. Det, vi gør her, er til validering, ikke til produktionsoperationer. Derfor har vi brug for en falsk eller “mock” df-kommando, hvormed vi kan generere “testdata” for hvert tilfælde.

På en *nix-platform er det muligt at tilsidesætte den rigtige df-kommando ved at definere et alias. Vi ønsker, at den aliaserede kommando skal udstede testværdier i samme format som output fra df -HP. Her er en måde at gøre det på (det hele er én linje; det er opdelt nedenfor for at gøre det lettere at læse):

alias df="shift;echo -e 'Filesystem Size Used Avail Use% Mounted on'; echo -e 'tempfs 511M 31M 481M 6% /dev/shm'; echo -e '/dev/sda1 20G 9.9G 9.2G 52% /'"

Skiftet springer over ‘-HP’-argumentet, når scriptet køres, så systemet ikke klager over, at -HP er en ukendt kommando. Den aliaserede df-kommando udsender output i samme form som df -HP.

Testværdierne ledes ind i grep og derefter awk, når scriptet køres, så vi mocker kun det minimum, der er nødvendigt for at kontrollere vores testtilfælde. Vi ønsker, at vores testtilfælde skal være så tæt som muligt på den “virkelige ting”, så vi ikke får falske positive resultater.

Mocks, der oprettes via et mocking-bibliotek, kan returnere en foruddefineret værdi, når de kaldes. Vores tilgang til mocking af df-kommandoen afspejler denne funktion for en mock; vi angiver et foruddefineret output, der returneres, når den kode, der testes, kalder df.

Mocking af mailkommandoen

Vi vil vide, om scriptet forsøger at sende en e-mail under de rigtige betingelser, men vi ønsker ikke, at det skal sende en rigtig e-mail nogen steder. Derfor ønsker vi at give alias til mailkommandoen, ligesom vi gjorde med df-kommandoen tidligere. Vi skal opsætte noget, som vi kan kontrollere efter hvert testtilfælde. En mulighed er at skrive en værdi til en fil, når mail bliver kaldt, og derefter kontrollere værdien i vores testcase. Dette er vist i eksemplet nedenfor. Andre metoder er også mulige.

Mocks, der oprettes via et mocking-bibliotek, kan tælle antallet af gange, de bliver kaldt af den kode, der testes, og vi kan bekræfte det forventede antal påkald. Vores tilgang til mocking af mailkommandoen afspejler denne funktion for en mock; hvis teksten “mail” er til stede i filen mailsent, efter at vi har kørt scriptet diskusage.sh, betyder det, at scriptet faktisk kaldte mailkommandoen.

Mønster for udførelse af automatiserede kontroller

Automatiserede eller eksekverbare kontroller på ethvert abstraktionsniveau, for enhver form for program eller script, i ethvert sprog, består typisk af tre trin. Disse har normalt navnene:

  • Arrange
  • Act
  • Assert

Grunden til dette er sandsynligvis, at alle elsker allitteration, især på bogstavet A, da ordet “allitteration” selv begynder med bogstavet A.

Hvad end grunden er, så opstiller vi i arrange-trinnet forudsætningerne for vores testcase. I act-trinnet påkalder vi den kode, der skal testes. I assert-trinnet angiver vi det resultat, vi forventer at se.

Når vi bruger en testramme eller et bibliotek, håndterer værktøjet assert-trinnet fint for os, så vi ikke behøver at kode en masse besværlig if/else-logik i vores testsuiter. I vores første eksempel her bruger vi ikke en testramme eller et bibliotek, så vi kontrollerer resultaterne af hver case med en if/else-blok. I næste afsnit vil vi lege med enhedstestrammer for shellsprog og se, hvordan det ser ud.

Her er vores grove, men effektive testskript til test af Viveks shellscript, som vi har kaldt diskusage.sh:

#!/bin/bashshopt -s expand_aliases# Before allalias mail="echo 'mail' > mailsent;false"echo 'Test results for diskusage.sh' > test_resultstcnt=0# It does nothing when disk usage is below 90%# Before (arrange)alias df="echo 'Filesystem Size Used Avail Use% Mounted on';echo '/dev/sda2 100G 89.0G 11.0G 89% /'"echo 'no mail' > mailsent# Run code under test (act). ./diskusage.sh# Check result (assert)((tcnt=tcnt+1))if ]; then echo "$tcnt. FAIL: Expected no mail to be sent for disk usage under 90%" >> test_resultselse echo "$tcnt. PASS: No action taken for disk usage under 90%" >> test_resultsfi # It sends an email notification when disk usage is at 90%alias df="echo 'Filesystem Size Used Avail Use% Mounted on';echo '/dev/sda1 100G 90.0G 10.0G 90% /'"echo 'no mail' > mailsent. ./diskusage.sh((tcnt=tcnt+1))if ]; then echo "$tcnt. PASS: Notification was sent for disk usage of 90%" >> test_resultselse echo "$tcnt. FAIL: Disk usage was 90% but no notification was sent" >> test_resultsfi # After allunalias dfunalias mail# Display test results cat test_results

Her er en gennemgang af testskriptet.

Først ser du, at vi bruger bash til at teste en almindelig gammel .sh-fil. Det er helt fint. Det er ikke nødvendigt, men det er fint.

Dernæst ser du en shopt-kommando. Det vil få shell’en til at udvide vores testaliaser, når subshell’en bliver kaldt for at køre diskusage.sh-scriptet. I de fleste brugstilfælde ville vi ikke videregive aliaser til subshells, men enhedstest er en undtagelse.

Kommentaren “Før alt” er for folk, der er bekendt med enhedstestrammer, der har opsætnings- og nedrivningskommandoer. Disse hedder ofte noget i retning af “før” og “efter”, og der er normalt et par, der sætter hele testpakken i parentes, og et andet par, der udføres individuelt for hver testcase.

Vi ønskede at vise, at definitionen af aliaset for mail, initialiseringen af testresultatfilen og initialiseringen af testcase-tælleren alle udføres præcis én gang, nemlig i begyndelsen af testpakken. Denne slags ting er normalt i eksekverbare testsuiter. Det faktum, at vi tester et shellscript i stedet for et applikationsprogram, ændrer ikke på det.

Den næste kommentar, “It does nothing…”, angiver starten på vores første individuelle testcase. De fleste unit test frameworks tilbyder en måde at angive et navn til hver case, så vi kan holde styr på, hvad der foregår, og så andre værktøjer kan søge, filtrere og udtrække testcases af forskellige årsager.

Dernæst er der en kommentar, der lyder: “Before (arrange)”. Denne repræsenterer opstilling, der kun gælder for den ene testcase. Vi indstiller df-aliaset til at udsende det output, som vi har brug for i denne særlige sag. Vi skriver også teksten, “no mail”, til en fil. På den måde vil vi kunne se, om diskusage.sh-scriptet forsøgte at sende en notifikationsmail.

Det næste trin er handlingstrinnet, hvor vi øver den kode, der skal testes. I dette tilfælde betyder det, at vi kører selve diskusage.sh-scriptet. Vi kildesourcer det i stedet for at udføre det direkte .

Nu udfører vi assert-trinnet, som vi gør på den hårde måde i dette eksempel, fordi vi endnu ikke har indført en testramme. Vi øger testtælleren, så vi kan nummerere testtilfældene i resultatfilen. Hvis vi ellers havde et stort antal tilfælde, kunne det blive svært at finde ud af, hvilke af dem der var mislykkedes. Testrammer håndterer dette for os.

Det alias, vi definerede for mailkommandoen, skriver teksten ‘mail’ til mailsent-filen. Hvis diskusage.sh kalder mail, vil mailsent-filen indeholde ‘mail’ i stedet for den oprindelige værdi, ‘no mail’. Du kan se, hvad betingelserne for bestået og ikke-bestået er ved at læse de strenge, der ekkoteres i testresultatfilen.

Med udgangspunkt i kommentaren “Den sender en e-mail-meddelelse…” gentager vi arrange, act, assert-trinnene for en anden testcase. Vi får vores falske df-kommando til at udsende andre data denne gang for at fremkalde en anden adfærd fra den testede kode.

Der hvor kommentaren “After all” vises, rydder vi op efter os selv ved at fjerne de definitioner, vi oprettede i “Before all”-opsætningen nær toppen af testskriften.

Slutteligt dumper vi indholdet af filen test_results, så vi kan se, hvad vi har fået. Den ser således ud:

Test results for diskusage.sh1. PASS: No action taken for disk usage under 90%2. PASS: Notification was sent for disk usage of 90%

Hvorfor bruge en testramme/et testbibliotek?

Vi har lige skrevet et par enhedstesttilfælde til et shellscript uden at bruge en testramme, et mocking-bibliotek eller et assertion-bibliotek. Vi fandt ud af, at systemkommandoer kan mockes ved at definere aliaser (i det mindste på *nix-systemer), at assertions kan implementeres som betingede udsagn, og at den grundlæggende struktur for en enhedstest er nem at opstille i hånden.

Det var ikke svært at gøre dette uden en ramme eller et bibliotek. Så hvad er fordelen?

Testframeworks og -biblioteker forenkler og standardiserer testkoden og muliggør meget mere læsbare testsuiter end håndlavede scripts, der indeholder en masse betingede udsagn. Nogle biblioteker indeholder nyttige ekstra funktioner, som f.eks. muligheden for at fange undtagelser eller muligheden for at skrive tabel- og datadrevne testcases. Nogle er skræddersyet til at understøtte specifikke produkter af interesse for infrastrukturingeniører, som f.eks. Chef og Puppet. Og nogle indeholder funktionalitet til at spore kodedækning og/eller til at formatere testresultater i en form, der kan bruges af værktøjer i CI/CD-pipelinen eller i det mindste en webbrowser.

Unit Test Frameworks for Scripts

I denne serie vil vi udforske flere unit test frameworks for shell scripts og scripting sprog. Her er en oversigt:

  • shunit2 er et meget solidt Open Source-projekt med en tiårig historie. Det blev oprindeligt udviklet af Kate Ward, en Site Reliability Engineer og Manager hos Google med base i Zürich, og det er aktivt udviklet og understøttet af et hold på seks personer. Fra en ydmyg begyndelse som en punktløsning til at teste et logningsbibliotek til shell-scripts er det bevidst blevet udviklet til en generel enhedstestramme, der understøtter flere shell-sprog og styresystemer. Den indeholder en række nyttige funktioner ud over simple assertions, herunder understøttelse af datadrevne og tabeldrevne tests. Den anvender den traditionelle “assertThat”-stil for assertions. Projektets websted indeholder fremragende dokumentation. Til generel enhedstest af shell-scripts er dette min bedste anbefaling.
  • BATS (Bash Automated Testing System) er en enhedstestramme for bash. Det blev skabt af Sam Stephenson for ca. syv år siden og har haft en halv snes bidragydere. Den sidste opdatering var for fire år siden, men det er ikke noget at bekymre sig om, da denne slags værktøj ikke kræver hyppige opdateringer eller vedligeholdelse. BATS er baseret på Test Anything Protocol (TAP), som definerer en konsistent tekstbaseret grænseflade mellem moduler i enhver form for testharnisk. Det giver mulighed for ren, konsistent syntaks i testcases, selv om det ikke ser ud til at tilføje meget syntaktisk sukker ud over rene bash-statements. For eksempel er der ingen særlig syntaks for assertions; man skriver bash-kommandoer til testresultater. Med det in mente kan dens største værdi ligge i at organisere testsuiter og cases på en logisk måde. Bemærk også, at det at skrive testskripter i bash ikke forhindrer os i at teste ikke-bash-skripter; det har vi gjort tidligere i dette indlæg. Det faktum at BATS-syntaksen er så tæt på almindelig bash-syntaks giver os en masse fleksibilitet til at håndtere forskellige shell-sprog i vores testsuiter, på den mulige bekostning af læsbarheden (afhængig af hvad man finder “læsbar”; den tiltænkte målgruppe for dette indlæg finder sandsynligvis almindelig shell-sprog-syntaks temmelig læsbar). En særlig interessant funktion (efter min mening) er, at man kan opsætte sin teksteditor med syntaksfremhævning for BATS, som dokumenteret på projektets wiki. Emacs, Sublime Text 2, TextMate, Vim og Atom blev understøttet på datoen for dette indlæg.
  • zunit (ikke den fra IBM, den anden) er en enhedstestramme for zsh udviklet af James Dinsdale. På projektwebstedet står der, at zunit er inspireret af BATS, og det indeholder de meget nyttige variabler $state, $output og $lines. Men det har også en definitiv assertion-syntaks, der følger mønsteret “assert actual matches expected”. Hver af disse frameworks har nogle unikke funktioner. En interessant funktion i ZUnit er efter min mening, at den vil markere alle testcases, der ikke indeholder en assertion, som “risikable”. Du kan tilsidesætte dette og tvinge sagerne til at køre, men som standard hjælper rammen dig med at huske at inkludere en assertion i hver testcase.
  • bash-spec er en testramme i adfærdsmæssig stil, der kun understøtter bash (eller i det mindste er den kun blevet testet mod bash- scripts). Det er et af mine ydmyge sideprojekter, der har eksisteret i over fire år og har et par “rigtige” brugere. Det bliver ikke opdateret meget, da det i øjeblikket gør det, som det var tiltænkt at gøre. Et mål med projektet var at gøre brug af bash-funktioner i en “flydende” stil. Funktioner kaldes i rækkefølge, idet de hver især videregiver hele argumentlisten til den næste efter at have forbrugt det antal argumenter, som den har brug for til at udføre sin opgave. Resultatet er en læsbar testsuite, med udsagn som “expect package-name to_be_installed” og “expect arrayname not to_contain value”. Når den bruges til at styre test-første udvikling af scripts, har dens design en tendens til at få udvikleren til at skrive funktioner, der understøtter ideen om “modularitet” eller “enkelt ansvar” eller “separation of concerns” (kald det hvad du vil), hvilket resulterer i nem vedligeholdelse og funktioner, der let kan genbruges. “Behavioral style” betyder, at assertions har formen “expect this to match that.”
  • korn-spec er en port af bash-spec til korn-shellen.
  • Pester er den foretrukne enhedstestramme til Powershell. Powershell ligner og føles mere som et programprogrammeringssprog end et rent scriptsprog, og Pester giver en fuldt konsistent udvikleroplevelse. Pester leveres med Windows 10 og kan installeres på ethvert andet system, der understøtter Powershell. Det har et robust assertion-bibliotek, indbygget understøttelse af mocking og indsamler metrikker for kodedækning.
  • ChefSpec bygger på rspec for at levere en testramme i adfærdsmæssig stil til Chef-opskrifter. Chef er en Ruby-applikation, og ChefSpec udnytter fuldt ud rspec-funktionerne plus indbygget understøttelse af Chef-specifik funktionalitet.
  • rspec-puppet er en ramme i adfærdsmæssig stil til Puppet, der funktionelt set svarer til ChefSpec.

admin

Skriv et svar

Din e-mailadresse vil ikke blive publiceret.

lg