An Absolute Unit – ohjeita testin suunnitteluun

Pidän itseäni pätevänä devaajana, joka on onnekkaan sattuman myötä ollut ensimmäiseksi IT-alan työkseen validointiharjoittelijana. Työ itsessään oli lähinnä automaatiotestien (useimmiten unit-testien) kirjoittamista, mutta saamani mentorointi oli ensiluokkaista. Olen viimeaikoina pureskellut näitä samoja periaatteita eteenpäin mentoroinnin muodossa ja ajattelin jakaa niitä nyt myös blogimme lukijoille.

Unit-testien tarkoitus projektissa on kaksijakoinen: toisaalta ne toimivat kuvauksena siitä, miten ohjelman osan tulee esitetyssä tilanteessa toimia, toisaalta ne toimivat ns. “sanity checkeinä” koodin muutoksille. Mikäli muutos aiheuttaa virheen aiemmin toimineessa unit-testissä, on aihetta varmistaa että muutos on todella haluttu. Testien tehokas suunnittelu on kotonaan jokaisen devaajan työkalupakissa.

Unit-testien kirjoittamisella on myös eräs piilohyöty: jos testin suunnittelu tuntuu vaikealta, tai lähes mahdottomalta, voi se johtua siitä, että testin kohde on suunniteltu huonosti ja että sitä tulisi ehkä refaktoroida.

TOIM. HUOM: Yleisesti on paljon mielipiteitä siitä, mikä lasketaan unit-testiksi ja mikä esimerkiksi integraatiotestiksi. Ei huolta. Alempia ohjeita voi helposti käyttää yleisenä ohjelmistotestien ohjenuorana.

Osa 1 - Testin sanoittaminen

Uusilla testaajilla on usein vaikeuksia hahmottaa, mikä testitapauksen pitäisi olla. Olipa testin kohde yksittäinen funktio tai isompi kokonaisuus, pitää testi pystyä pelkistämään simppeliksi testitapaukseksi. Testitapauksen muotoilu on helppoa, kun sen muotoilee seuraavan lauseen tyylisesti:

“Kun tila on X ja tapahtuu Y, seurauksena on Z

…jossa:

X = alkuoletukset*
Y = testin ajo
Z = ajon tuottamat tulokset

*testin kohteen tila ja kohteen ulkoiset oletetut tekijät
95% testeistä voidaan hahmotella kyseisellä tavalla. Käytetään esimerkkinä seuraavaa testitapausta:

When getSocks() function returns red socks and putOnSocks() function is called, the color of footwear changes to red

Tämä yksinkertaistus on varsinkin aloittelevalle testin kirjoittajalle hyvä niksi hahmottaa millainen haluttu testitapaus oikeasti on. Tämä helpottaa pitämään testitapaukset selkeinä ja jakamaan monimutkaisemman toiminnallisuuden testauksen useampaan testiin.

Siirtyessämme seuraavaan osaan huomaamme myös, että tämänkaltaisesta sanoittamisesta on erityistä hyötyä kun rajaamme testiä.

Osa 2 - Testin kohteen rajaus

Testin suunnittelun kannalta on olennaista rajata, mitä halutaan testata ja mitä sulkea testauksen ulkopuolelle. Tämä rajaus kertoo meille suoraan, mikä toiminnallisuus on testin kannalta olennaista ja mikä on riippuvuus, joka voidaan yksinkertaistaa ja mallintaa (englanniksi “mock”) testin puitteissa. Matemaattisista todistuksista tuttu termi “alkuoletus” on enemmän, kuin omiaan kuvaamaan tällaista mallinnettavaa riippuvuutta.

Laajennetaan edellisen osan putOnSocks()-esimerkkiä ja oletetaan seuraavanlainen kuvaus:

getFootWearColor() {

returns the color of the worn footwear, otherwise null

}

putOnSocks() {
getSocks();
[]

}

getSocks() {

const socks = {

amount: 2,
color: randomColor()

};
return socks;

}

Kun mietimme, miten testi kannattaa kirjoittaa, kertoo oheinen kuvaus meille suoraan, mitä voimme mallintaa eli mockata, ja mikä on testin kannalta olennaista toiminnallisuutta. Koska testitapauksessa on erikseen mainittu alkuoletuksessa, että getSocks()-funktion tulee palauttaa jotain tiettyä (punaiset sukat), on se jo rajattu testin ulkopuolelle. Tämä tarkoittaa, että se voidaan hyvin mockata palauttamaan testin ajan vain punaisia sukkia.

Alemmassa kuvassa on korostettuna, mikä on testin kannalta olennaista (eli testattavaa) toiminnallisuutta ja mockattu getSocks() funktionaalisuus, jotta se palauttaa testin kannalta halutun arvon.

getFootWearColor() {

returns the color of the worn footwear, otherwise null

}

putOnSocks() {
getSocks();
[]

}

getSocks = () => ({amount: 2, color: ’red’ });

putOnSocks() ja getFootwearColor() funktiot tulee säilyttää muuttumattomina (mockaamattomina), sillä molemmat ovat testin kohteena. Näistä edellinen on testattava toiminnallisuus. Jälkimmäinen taas on tuloksen tarkistamisen kannalta olennaista säilyttää koskemattomana.

Sivuhuomio: Mikäli haluaisimme sisällyttää myös getSocks()-funktion testin kohteeseen, voisimme sen suoran mockaamisen sijaan mockata sen riippuvuutena olevan toiminnallisuuden:

randomColor = () => ‘red’

Rajauksen jälkeen meille on selvää, mitkä ominaisuudet ova testiin sisällytettäviä ja mitkä riippuvuuksia, jotka voidaan mallintaa. Nyt olemme valmiit kokoamaan testin.

Osa 3 - Testin rakenne

Kun ylemmät osiot ovat selviä, itse testin kokoaminen on huomattavan suoraviivaista. Hyvässä testissä on tapauksesta riippuen seuraavat olennaiset osat:

  1. Komponentin alustus oikeaan tilaan
  2. Ulkoisten riippuvuuksien mockaaminen halutunlaisiksi
  3. Testin ajo
  4. Ajon vaikutusten tarkistaminen

Vaikka testissä voi olla monenlaisia rakenteita, ovat ne yleensä lopunviimein erilaisia kombinaatioita ylemmistä neljästä osasta. On aivan mahdollista, että testin kulku on esimerkiksi seuraavanlainen:

Alustus → Mockaus → Ajo → Tarkistus → Mockaus → Ajo → Tarkistus → Alustus

Käyttäen edelleen esimerkkinä putOnSocks() -toiminnallisuutta, voimme hahmotella seuraavanlaisen testirakenteen:
  1. Alustetaan komponentti siten, että tällä hetkellä jalassa ei ole sukkia
  2. Mockataan getSocks() palauttamaan punaiset sukat (kts. ylempi kuva)
  3. Kutsutaan putOnSocks() toiminnallisuutta
  4. Tarkistetaan, palauttaako getFootwearColor() ‘red’, kuten odotettu
Kuten sanottu, kun kaksi aikaisempaa osaa on käyty ajatuksella läpi, on testin rakenteen kokoaminen suoraviivaista. Helppoa, eikö?

Osa 4 - Vaaran paikkoja

Testiä tehdessä on pidettävä seuraavat asiat mielessä:

Älä mockaa vääriä asioita:
Testiä tehdessä on helppo keskittyä liikaa testin läpiviemiseen ja mockata epähuomiossa vääriä asioita. Edellisessä esimerkissä putOnSocks()– tai getFootwearColor()-funktion mockaaminen olisi tehnyt testistä ilmeisen virheellisen, mutta myös riippuvuuksien kanssa kannattaa joskus olla tarkkana. Testiä tehdessäsi varmistu aina siitä, ettei mockaus vaikuta odottamattomalla tavalla ajoon tai tarkistukseen.

Onko testi järkevä:
Testitapausta sanoittaessasi osan 1 tapainen sanoitus (alkuoletus, toiminto, lopputulos) auttaa hahmottamaan kuinka järkevä testitapaus on, mutta se ei ole pakollinen.

Esimerkiksi testi, joka on sanoitettu seuraavasti…

lockForm() function should not disallow copyForm() functionality
…ei sisällä eksplisiittisesti alkuoletusta, mutta sisältää implisiittisen oletuksen, joka voidaan kirjoittaa auki seuraavasti…
copyForm() should always be allowed after lockForm() has been called

…jossa always avainsana paljastaa, että alkutila voi olla mikä vain komponentin tila.

Vastaavasti seuraava sanoitus..

If formLocked = true, isFormLocked() returns true

…tuntuu testitapauksena jokseenkin tyhjältä. Jos alkuoletuksen pohjalta ollaan jo käytännössä halutussa lopputilassa, on testin mielekkyys kyseenalainen.

Tarkista asiat oikein:
Testissä tehdyt tarkistukset voivat olla sinänsä ihan oikeita, mutta joskus niiden pitää myös olla riittävän tarkkoja jotta ne todella todentavat halutun tuloksen. Alla esimerkki, joka avaa mitä tarkoitan.

Jos oletetaan, että formLocked() voi heittää tietyissä tapauksissa virheen, on ok tarkistaa, että virhe myös heitetään. Mikäli funktio voi kuitenkin heittää kahta erilaista virhettä tilanteesta riippuen, on järkevää myös tarkistaa, että heitetty virhe oli juuri odotetun kaltainen.

Mikäli esimerkiksi alustuksen pohjalta odotetaan virheviestiksi…

“ERROR: Form cannot be locked”

…mutta testissä saadaankin tuloksena…

“ERROR: Form already locked”

…on testin alkutila väärä ja testin voidaan katsoa olevan virheellinen.

Testiä tehdessä mitä tarkemmin tuloksen pystyy tarkistamaan, sitä parempi!

Osa 5 - Towards an Absolute Unit

Tässä blogitekstissä on esitetty periaatteita, joita seuraamalla uudemmankin testaajan on mahdollista suunnitella testi, joka on järkevä ja hyvin rakennettu. Identiota edustaessani haluan kuitenkin osoittaa osaavani viedä ajatuksen vielä askelta pidemmälle. Katsotaanpa siis, miten teemme esimerkin putOnSocks-testistä varmemman.

Testauksessa on hyvä olla pedantti, joten lisäämme testin alkuun ns. turhan tarkistuksen, jonka tehtävänä on todentaa että komponentin alkutila on todellakin sellainen, kuin haluamme:

  1. Alustetaan komponentti siten, että tällä hetkellä jalassa ei ole sukkia
  2. Tarkistetaan, että getFootwearColor() palauttaa arvon null
  3. […]

Tämä tarkistus ei ole tarpeellinen, mutta se antaa meille luottoa siitä, että testin tila todellakin on se, mitä ajattelimme, emmekä ole tehneet alustuksessa virhettä.

Tämän lisäksi voimme generalisoida testin! Aloitamme muokkaamalla testikuvauksen sanoitusta seuraavanlaiseksi:

putOnSocks() function should cause the color of footwear to change based on the color of the received socks”

Oletetaan, että putOnSocks()-funktion pitäisi myös vaihtaa päällä olevia sukkia, joten lisätään tämä osaksi testiä:

  1. Alustetaan komponentti siten, että tällä hetkellä jalassa ei ole sukkia
  2. Tarkistetaan, että getFootwearColor() palauttaa arvon null
  3. Mockataan getSocks() palauttamaan punaiset sukat
  4. Kutsutaan putOnSocks() toiminnallisuutta
  5. Tarkistetaan, palauttaako getFootwearColor() ‘red’, kuten odotettu
  6. Mockataan getSocks() palauttamaan ruskeat sukat
  7. Kutsutaan putOnSocks() toiminnallisuutta
  8. Tarkistetaan, palauttaako getFootwearColor() ‘brown’, kuten odotettu

Vaikka testiin ylimääräisten kohtien lisäämisen arvo ei ole välttämättä ilmiselvä, luo se lisää varmuutta, että ensimmäinen tarkistustulos ei ollut sattumaa. Erityisesti kompleksisissa tapauksissa on parempi tehdä liikaa tarkistuksia, kuin liian vähän. Ylempi testi ei ole hirveän monimutkainen, mutta testaa melkoisella varmuudella haluttua toiminnallisuutta.

Mikäli olet lukenut tänne asti, niin omastani ja Idention puolesta haluan kiittää mielenkiinnosta ja toivottaa hyviä hetkiä testauksen parissa!

Kirjoittaja

Konsta Sinisalo

Ohjelmistokehittäjä

Kategoria


+358 40 568 4617


+358 40 568 4617

Scroll to Top