På 1960-talet ansågs det vara en grundläggande god praxis inom programvaruteknik att testa koden när du skrev den. Pionjärerna inom mjukvaruutveckling under den epoken var förespråkare för olika nivåer av testning; vissa förespråkade ”unit”-testning och andra inte, men alla erkände vikten av att testa koden.
Exekverbara tester kan ha introducerats för första gången av Margaret Hamilton på Apollo-projektet i mitten av 1960-talet, där hon hade sitt ursprung i en typ av exekverbar kontroll som vi nu kallar ”statisk kodanalys”. Hon kallade det ”programvara av högre ordning”, varmed hon menade programvara som fungerar mot annan programvara snarare än direkt mot problemområdet. Hennes högre ordningsprogramvara undersökte källkoden för att leta efter mönster som var kända för att leda till integrationsproblem.
Häromkring 1970 hade folk i stort sett glömt bort exekverbar testning. Visst, folk körde program och pillade på dem här och där för hand, men så länge byggnaden inte brann ner runt omkring dem, trodde de att koden var ”tillräckligt bra”. Resultatet har blivit över 35 år av kod i produktion världen över som är otillräckligt testad och som i många fall inte fungerar helt som det var tänkt, eller på ett sätt som tillfredsställer kunderna.
Tanken på att programmerare ska testa medan de arbetar gjorde ett comeback från och med mitten av 1990-talet, även om det stora flertalet programmerare än i dag inte gör det. Infrastrukturingenjörer och systemadministratörer testar sina skript ännu mindre flitigt än vad programmerare testar sin programkod.
När vi går in i en era där snabb utplacering av komplicerade lösningar som består av många autonoma komponenter blir normen, och ”moln”-infrastrukturer kräver att vi hanterar tusentals virtuella maskiner och containrar som kommer och går i en skala som inte kan hanteras med manuella metoder, kan betydelsen av exekverbar, automatiserad testning och kontroll under hela utvecklings- och leveransprocessen inte ignoreras, inte bara för programprogrammerare, utan för alla som är involverade i IT-arbete.
Med tillkomsten av devops (korsbefruktning av färdigheter, metoder och verktyg för utveckling och drift) och trender som ”infrastruktur som kod” och ”automatisera alla saker” har enhetstestning blivit en grundläggande färdighet för programmerare, testare, systemadministratörer och infrastrukturingenjörer.
I den här serien av inlägg kommer vi att introducera idén om enhetstestning av skalskript, och sedan kommer vi att utforska flera ramverk för enhetstest som kan hjälpa till att göra den uppgiften praktisk och hållbar i stor skala.
En annan metod som kan vara obekant för många infrastrukturingenjörer är versionskontroll. Senare i den här serien kommer vi att beröra versionskontrollsystem och arbetsflöden som applikationsutvecklare använder och som kan vara effektiva och användbara även för infrastrukturingenjörer.
- Ett skript att testa
- Automatiserade funktionskontroller
- Godkännande, misslyckande och fel
- Vad ska vi kontrollera?
- Vad ska vi inte kontrollera?
- Mockning av df-kommandot
- Mocking the mail Command
- Mönster för att köra automatiserade kontroller
- Varför använda ett testramverk/bibliotek?
- Frameworks för enhetstest för skript
Ett skript att testa
Vivek Gite publicerade ett exempel på ett skalskript för att övervaka diskanvändning och för att generera ett e-postmeddelande när vissa filsystem överskrider ett tröskelvärde. Hans artikel finns här: https://www.cyberciti.biz/tips/shell-script-to-watch-the-disk-space.html. Låt oss använda det som testobjekt.
Den första versionen av hans skript, med tillägget av alternativet -P på kommandot df för att förhindra radbrytningar i utmatningen, vilket föreslogs i en kommentar från Per Lindahl, ser ut så här:
#!/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 att förfina skriptet bortom den punkten, men den här versionen kommer att tjäna syftet med det här inlägget.
Automatiserade funktionskontroller
Ett par tumregler om automatiserade funktionskontroller, oavsett om vi kontrollerar applikationskod eller ett skript eller någon annan typ av programvara:
- kontrollen måste köras identiskt varje gång, utan att det krävs några manuella justeringar för att förbereda sig för varje körning, och
- resultatet kan inte vara sårbart för förändringar i exekveringsmiljön, data eller andra faktorer som är externa för koden under test.
Godkännande, misslyckande och fel
Du kanske påpekar att det är möjligt att skriptet inte kommer att köras alls. Det är normalt för alla typer av enhetstestramverk för alla typer av program. Tre utfall, snarare än två, är möjliga:
- Koden som testas uppvisar det förväntade beteendet
- Koden som testas körs, men uppvisar inte det förväntade beteendet
- Koden som testas körs inte
För praktiska syften är det tredje utfallet detsamma som det andra; vi måste ta reda på vad som gick snett och åtgärda det. Så vi tänker i allmänhet på dessa saker som binära:
Vad ska vi kontrollera?
I det här fallet är vi intresserade av att verifiera att skriptet kommer att bete sig som förväntat med olika inmatningsvärden. Vi vill inte förorena våra enhetskontroller med någon annan verifiering utöver detta.
När vi granskar den kod som testas ser vi att när diskanvändningen når ett tröskelvärde på 90 % anropar skriptet mail för att skicka ett meddelande till systemadministratören.
I enlighet med allmänt accepterad god praxis för enhetskontroller vill vi definiera separata fall för att verifiera varje beteende som vi förväntar oss för varje uppsättning av initiala villkor.
Med vår ”testare”-hatt på ser vi att det här är en typ av gränsvillkor. Vi behöver inte kontrollera många olika procentsatser av diskanvändning individuellt. Vi behöver bara kontrollera beteendet vid gränserna. Därför kommer den minsta uppsättningen fall för att ge en meningsfull täckning att vara:
- Den skickar ett e-postmeddelande när diskanvändningen når tröskelvärdet
- Den skickar inte ett e-postmeddelande när diskanvändningen är under tröskelvärdet
Vad ska vi inte kontrollera?
I enlighet med allmänt vedertagen god praxis för isolering av enhetstester vill vi se till att vart och ett av våra fall kan misslyckas av exakt en anledning: Det förväntade beteendet inträffar inte. I den mån det är praktiskt möjligt vill vi ställa in våra kontroller så att andra faktorer inte får fallet att misslyckas.
Det är kanske inte alltid kostnadseffektivt (eller ens möjligt) att garantera att externa faktorer inte kommer att påverka våra automatiserade kontroller. Det finns tillfällen när vi inte kan kontrollera en extern faktor, eller när det skulle innebära mer tid, arbete och kostnader än värdet av kontrollen, och/eller när det rör sig om ett obskyrt kantfall som har en mycket låg sannolikhet att inträffa eller mycket liten inverkan när det inträffar. Det är en fråga för ditt professionella omdöme. Som en allmän regel bör du göra ditt bästa för att undvika att skapa beroenden av faktorer som ligger utanför räckvidden för den kod som testas.
Vi behöver inte verifiera att kommandona df, grep, awk, cut och mail fungerar. Det ligger utanför räckvidden för våra syften. Den som underhåller verktygen är ansvarig för detta.
Vi vill veta om utdata från df-kommandot inte behandlas på det sätt som vi förväntar oss av grep eller awk. Därför vill vi att de riktiga grep- och awk-kommandona ska köras i våra kontroller, baserat på utdata från df-kommandot som matchar avsikten med varje testfall. Det är inom räckvidden eftersom kommandoradsargumenten till df är en del av skriptet, och skriptet är den kod som testas.
Det betyder att vi behöver en falsk version av df-kommandot för att använda med våra enhetskontroller. Den typen av falsk komponent kallas ofta för en mock. En mock ersätter en riktig komponent och ger fördefinierade utdata för att driva systemets beteende på ett kontrollerat sätt, så att vi kan kontrollera beteendet hos den kod som testas på ett tillförlitligt sätt.
Vi ser att skriptet skickar ett e-postmeddelande när ett filsystem når tröskelanvändningsnivån. Vi vill inte att våra enhetskontroller ska spy ut en massa onödiga e-postmeddelanden, så vi vill också mocka mailkommandot.
Det här skriptet är ett bra exempel för att illustrera mockning av dessa kommandon, eftersom vi gör det på ett annat sätt för mail än för df.
Mockning av df-kommandot
Skriptet är uppbyggt kring df-kommandot. Den relevanta raden i skriptet är:
df -HP | grep -vE '^Filesystem|tmpfs|cdrom' | awk '{ print " " }'
Om du bara kör df -HP, utan att pipa in i grep, skulle du se ett utdata som liknar detta:
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
Kommandona grep och awk strippar ner utdata till detta:
0% udev52% /dev/sda1
Vi behöver kontrollera utdata från df för att driva våra testfall. Vi vill inte att resultatet av kontrollen ska variera beroende på den faktiska diskanvändningen på systemet där vi utför testsviten. Vi kontrollerar inte diskanvändningen; vi kontrollerar logiken i skriptet. När skriptet körs i produktion kommer det att kontrollera diskanvändningen. Det vi gör här är för validering, inte för produktion. Därför behöver vi ett falskt eller ”mock” df-kommando med vilket vi kan generera ”testdata” för varje fall.
På en *nix-plattform är det möjligt att åsidosätta det riktiga df-kommandot genom att definiera ett alias. Vi vill att det aliasade kommandot ska ge testvärden i samma format som utdata från df -HP. Här är ett sätt att göra det (detta är en enda rad; det är uppdelat nedan för läsbarhetens skull):
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% /'"
Shiftet hoppar över argumentet ’-HP’ när skriptet körs, så att systemet inte kommer att klaga på att -HP är ett okänt kommando. Det aliaserade df-kommandot ger utdata i samma form som df -HP.
Testvärdena leds in i grep och sedan awk när skriptet exekveras, så vi mockar bara det minimum som är nödvändigt för att kontrollera våra testfall. Vi vill att vårt testfall ska ligga så nära den ”riktiga saken” som möjligt så att vi inte får falska positiva resultat.
Mocks som skapas via ett mocking-bibliotek kan returnera ett fördefinierat värde när de anropas. Vårt tillvägagångssätt för att mocka kommandot df speglar den funktionen hos en mock; vi specificerar fördefinierade utdata som ska returneras när den kod som testas anropar df.
Mocking the mail Command
Vi vill veta om skriptet försöker skicka ett e-postmeddelande under rätt förhållanden, men vi vill inte att det ska skicka ett riktigt e-postmeddelande någonstans. Därför vill vi ge mailkommandot ett alias, på samma sätt som vi gjorde med df-kommandot tidigare. Vi måste ställa in något som vi kan kontrollera efter varje testfall. En möjlighet är att skriva ett värde till en fil när mail anropas och sedan kontrollera värdet i vårt testfall. Detta visas i exemplet nedan. Andra metoder är också möjliga.
Mocks som skapas via ett mockingbibliotek kan räkna antalet gånger de anropas av den kod som testas, och vi kan försäkra oss om det förväntade antalet anrop. Vårt tillvägagångssätt för att mocka mailkommandot speglar den funktionen hos en mock; om texten ”mail” finns i filen mailsent efter att vi har kört skriptet diskusage.sh betyder det att skriptet anropade mailkommandot.
Mönster för att köra automatiserade kontroller
Automatiserade eller exekverbara kontroller på vilken abstraktionsnivå som helst, för vilken typ av program eller skript som helst, i vilket språk som helst, omfattar vanligtvis tre steg. Dessa brukar ha namnen:
- Arrange
- Act
- Assert
Anledningen till detta är antagligen att alla älskar alliteration, särskilt på bokstaven A, eftersom ordet ”alliteration” i sig självt börjar på bokstaven A.
Oavsett orsaken ställer vi i arrange-steget upp förutsättningarna för vårt testfall. I steget act åberopar vi den kod som ska testas. I assert-steget deklarerar vi det resultat vi förväntar oss att se.
När vi använder ett testramverk eller bibliotek hanterar verktyget assert-steget snyggt åt oss så att vi inte behöver koda en massa besvärlig om/else-logik i våra testsviter. I vårt inledande exempel här använder vi inget testramverk eller bibliotek, så vi kontrollerar resultaten av varje fall med ett if/else-block. I nästa avsnitt ska vi leka med enhetstestramverk för skalspråk och se hur det ser ut.
Här är vårt grova men effektiva testskript för att testa Viveks skalskript, som vi har döpt till 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
Här är en genomgång av testskriptet.
För det första ser du att vi använder bash för att testa en vanlig gammal .sh-fil. Det är helt okej. Det är inte nödvändigt, men det går bra.
Nästan ser du ett shopt-kommando. Det kommer att få skalet att expandera våra testalias när underskalet anropas för att köra skriptet diskusage.sh. I de flesta användningsfall skulle vi inte skicka alias till underskal, men enhetstester är ett undantag.
Kommentaren ”Before all” är för personer som är bekanta med ramverk för enhetstester som har kommandon för att sätta upp och riva ner. Dessa heter ofta något i stil med ”före” och ”efter”, och det finns vanligtvis ett par som sätter hela testsviten inom parentes och ett annat par som exekveras individuellt för varje testfall.
Vi ville visa att definitionen av aliaset för mail, initialiseringen av testresultatfilen och initialiseringen av testfallsräknaren görs exakt en gång, i början av testsviten. Den här typen av saker är normala i körbara testsviter. Det faktum att vi testar ett skalskript istället för ett tillämpningsprogram ändrar inte detta.
Nästa kommentar, ”It does nothing…” indikerar början på vårt första enskilda testfall. De flesta ramverk för enhetstest erbjuder ett sätt att ange ett namn för varje fall, så att vi kan hålla reda på vad som händer och så att andra verktyg kan söka, filtrera och extrahera testfall av olika anledningar.
Nästan finns en kommentar som lyder: ”Before (arrange)”. Denna representerar uppställning som bara gäller för det ena testfallet. Vi ställer in df-aliaset så att det ger den utdata vi behöver för just det här fallet. Vi skriver också texten ”no mail” till en fil. Det är så vi kommer att kunna avgöra om diskusage.sh-skriptet försökte skicka ett meddelande via e-post.
Atteststeget kommer härnäst, där vi tränar den kod som testas. I det här fallet innebär det att vi kör själva diskusage.sh-skriptet. Vi källkodar det istället för att exekvera det direkt .
Nu gör vi assert-steget, som vi gör på det svåra sättet i det här exemplet eftersom vi inte har infört ett testramverk ännu. Vi ökar testräknaren så att vi kan numrera testfallen i resultatfilen. Om vi annars hade ett stort antal fall skulle det kunna bli svårt att räkna ut vilka som hade misslyckats. Testramverk hanterar detta åt oss.
Aliaset som vi definierade för mailkommandot skriver texten ”mail” till mailsent-filen. Om diskusage.sh anropar mail kommer mailsent-filen att innehålla ”mail” i stället för det ursprungliga värdet ”no mail”. Du kan se vad villkoren för godkänt och misslyckat är genom att läsa de strängar som ekas i testresultatfilen.
Med utgångspunkt i kommentaren, ”It sends an email notification…” upprepar vi stegen arrange, act, assert för ett annat testfall. Vi låter vårt falska df-kommando sända ut olika data den här gången, för att driva fram ett annat beteende från den kod som testas.
När kommentaren ”After all” visas städar vi upp efter oss själva genom att eliminera de definitioner som vi skapade i ”Before all”-uppställningen nära toppen av testskriptet.
Slutligen dumpar vi ut innehållet i filen test_results så att vi kan se vad vi fick. Den ser ut så här:
Test results for diskusage.sh1. PASS: No action taken for disk usage under 90%2. PASS: Notification was sent for disk usage of 90%
Varför använda ett testramverk/bibliotek?
Vi har just skrivit ett par enhetstestfall för ett skalskript utan att använda ett testramverk, ett mocking-bibliotek eller ett assertion-bibliotek. Vi upptäckte att systemkommandon kan mockas genom att definiera alias (åtminstone på *nix-system), att assertions kan implementeras som villkorliga påståenden och att den grundläggande strukturen för ett enhetstest är lätt att sätta upp för hand.
Det var inte svårt att göra detta utan ett ramverk eller bibliotek. Så vad är fördelen?
Testramverk och bibliotek förenklar och standardiserar testkoden och möjliggör mycket mer lättlästa testsviter än handgjorda skript som innehåller en massa villkorliga påståenden. Vissa bibliotek innehåller användbara tilläggsfunktioner, t.ex. möjligheten att fånga upp undantag eller möjligheten att skriva tabell- och datadrivna testfall. Vissa är skräddarsydda för att stödja specifika produkter av intresse för infrastrukturingenjörer, t.ex. Chef och Puppet. Och vissa innehåller funktionalitet för att spåra kodtäckning och/eller för att formatera testresultat i ett format som kan konsumeras av verktyg i CI/CD-pipelinen, eller åtminstone en webbläsare.
Frameworks för enhetstest för skript
I den här serien kommer vi att utforska flera ramverk för enhetstest för skalskript och skriptspråk. Här är en översikt:
- shunit2 är ett mycket solitt Open Source-projekt med en tioårig historia. Det utvecklades ursprungligen av Kate Ward, en Site Reliability Engineer och Manager på Google baserad i Zürich, och utvecklas och stöds aktivt av ett team på sex personer. Från en blygsam början som en punktlösning för att testa ett loggningsbibliotek för skalskript har det avsiktligt utvecklats till ett allmängiltigt ramverk för enhetstest som stöder flera skalspråk och operativsystem. Det innehåller ett antal användbara funktioner utöver enkla påståenden, inklusive stöd för datadrivna och tabelldrivna tester. Den använder den traditionella ”assertThat”-stilen för påståenden. Projektets webbplats innehåller utmärkt dokumentation. För allmän enhetstestning av skalskript är detta min främsta rekommendation.
- BATS (Bash Automated Testing System) är ett ramverk för enhetstest för bash. Det skapades av Sam Stephenson för ungefär sju år sedan och har haft ett tiotal bidragsgivare. Den senaste uppdateringen gjordes för fyra år sedan, men det är inget att oroa sig för eftersom den här typen av verktyg inte kräver frekventa uppdateringar eller underhåll. BATS bygger på Test Anything Protocol (TAP), som definierar ett konsekvent textbaserat gränssnitt mellan moduler i vilken typ av testharness som helst. Det möjliggör ren och konsekvent syntax i testfall, även om det inte verkar lägga till särskilt mycket syntaktiskt socker utöver raka bash-utsagor. Det finns till exempel ingen särskild syntax för assertions; du skriver bash-kommandon för att testa resultat. Med detta i åtanke kan dess största värde ligga i att organisera testsviter och testfall på ett logiskt sätt. Observera också att det att skriva testskript i bash inte hindrar oss från att testa skript som inte är bash; det gjorde vi tidigare i det här inlägget. Det faktum att BATS-syntaxen ligger så nära vanlig bash-syntax ger oss mycket flexibilitet för att hantera olika skalspråk i våra testsviter, på bekostnad eventuellt av läsbarheten (beroende på vad du tycker är ”läsbart”; den tänkta målgruppen för det här inlägget tycker antagligen att vanlig skalspråkssyntax är ganska läsbar). En särskilt intressant funktion (enligt min mening) är att du kan ställa in din textredigerare med syntaxmarkering för BATS, vilket dokumenteras på projektets wiki. Emacs, Sublime Text 2, TextMate, Vim och Atom stöds vid tidpunkten för detta inlägg.
- zunit (inte den från IBM, den andra) är ett ramverk för enhetstest för zsh som utvecklats av James Dinsdale. På projektets webbplats står det att zunit är inspirerat av BATS och att det innehåller de mycket användbara variablerna $state, $output och $lines. Men det har också en definitiv syntax för påståenden som följer mönstret ”assert actual matches expected”. Var och en av dessa ramverk har några unika funktioner. En intressant funktion i ZUnit, enligt min mening, är att den kommer att flagga alla testfall som inte innehåller en assertion som ”riskabla”. Du kan åsidosätta detta och tvinga fallen att köras, men som standard hjälper ramverket dig att komma ihåg att inkludera ett påstående i varje testfall.
- bash-spec är ett testramverk i beteendestil som endast stöder bash (eller åtminstone har det endast testats mot bash-skript). Det är ett ödmjukt sidoprojekt av mig som har funnits i över fyra år och har några ”riktiga” användare. Det uppdateras inte mycket, eftersom det för närvarande gör det som det var tänkt att göra. Ett mål med projektet var att använda bash-funktioner i en ”flytande” stil. Funktioner anropas i sekvens, var och en överlämnar hela argumentlistan till nästa efter att ha förbrukat hur många argument som helst för att utföra sin uppgift. Resultatet är en läsbar testföljd med uttalanden som ”expect package-name to_be_installed” och ”expect arrayname not to_contain value”. När den används för att styra utvecklingen av skript med utgångspunkt i testerna tenderar dess utformning att leda utvecklaren till att skriva funktioner som stöder idén om ”modularitet” eller ”enskilt ansvar” eller ”separation of concerns” (kalla det vad du vill), vilket leder till att underhållet blir enkelt och att funktionerna lätt kan återanvändas. ”Beteendestil” innebär att påståendena har formen ”förvänta dig att det här ska matcha det där.”
- korn-spec är en anpassning av bash-spec för korn shell.
- Pester är det föredragna ramverket för enhetstest för Powershell. Powershell ser ut och känns mer som ett programvaruspråk än ett rent skriptspråk, och Pester erbjuder en helt konsekvent utvecklarupplevelse. Pester levereras med Windows 10 och kan installeras på alla andra system som stöder Powershell. Det har ett robust assertion-bibliotek, inbyggt stöd för mocking och samlar in kodtäckningsmätningar.
- ChefSpec bygger på rspec för att tillhandahålla ett testramverk i beteendestil för Chef-recept. Chef är en Ruby-applikation och ChefSpec drar full nytta av rspecs kapacitet plus inbyggt stöd för Chef-specifik funktionalitet.
- rspec-puppet är ett ramverk i beteendestil för Puppet, som funktionellt liknar ChefSpec.