Testaus

Tämän oppitunnin tavoitteena on tutustua testauksen eri tasoihin yksikkötesteistä järjestelmätesteihin ja tutustua testiautomaation käsitteistöön ja työkaluihin.

Aiheen opiskelun jälkeen osaat kirjoittaa Python-funktioillesi yksikkötestit ja tiedät mistä lähteä liikkeelle, kun sinulle tulee tarve kirjoittaa automatisoituja testejä. Osaat myös huomioida testausnäkökulmaa jäsentäessäsi Python-ohjelmiasi eri moduuleihin ja funktioihin.

Sisällysluettelo

Suositeltava oheisvideo: What is Automated Testing?

What is Automated Testing?

“In this video we start diving into the world of quality assurance and discuss automated testing for our web and mobile applications…”

The Startup Lab. What is Automated Testing?

Oppitunnin sisältö ja tavoitteet

Oppitunnin tavoitteena on erityisesti tustua yksikkötestauksen käsitteisiin ja hahmottaa hyviä käytäntöjä testauksen toteuttamiseksi ja testattavan koodin kirjoittamiseksi.

Tällä oppitunnilla kokeilemme testausta eri tasoilla hyödyntäen Pythonin pytest-moduulia:

“pytest is a mature full-featured Python testing tool that helps you write better programs.”

https://docs.pytest.org/en/stable/

Testattava ohjelmisto on edellisen viikon kotitehtävästä tuttu ohjelma, joka hakee GitHubista JSON-muotoisen postinumeroaineiston ja näyttää käyttäjälle joko tiettyyn postinumeroon liittyvän postitoimipaikan nimen tai postitoimipaikkaan liittyvät postinumerot.

Pytestin sijaan testejä voitaisiin kirjoittaa myös muita työkaluja hyödyntäen, kuten Pythonin unittest-moduulilla. Pytest on kuitenkin valittu kurssille siksi, että se ei edellytä minkään ulkoisten riippuvuuksien käyttämistä testikoodeissasi, vaan voit kirjoittaa testit kuten kirjoittaisit mitä tahansa muutakin Python-koodia.

Mikäli haluat tutustua pytest-työkaluun oppituntia syvällisemmin, suosittelen lukemaan esimerkiksi artikkelin Testing Python Applications with Pytest.

Yksikkötestaus

“Yksikkötestauksella tarkoitetaan pienimmän mahdollisen ohjelman osan, esimerkiksi aliohjelman, toiminnan testaamista. Yksikkötesteillä varmistetaan, että ohjelman pienimmät osat toimivat odotetulla tavalla, ja että mahdolliset virhetilanteet on niiden osalta ennakoitu.”

“Yksikkötestauksen hyödyt näkyvät kehitysprosessin aikana erityisesti silloin, kun jo kirjoitettuun koodiin joudutaan tekemään muutoksia. Automatisoiduilla yksikkötesteillä voidaan nopeasti todeta, aiheuttavatko tehdyt muutokset virheitä.”

Jyväskylän Yliopisto, Informaatioteknologian tiedekunta. Testauksen tasot.

Katsotaan ensimmäiseksi alla esitettyä funktiota, joka vaihtaa annetulta listalta kahdessa indeksissä olevat alkiot keskenään:

# tiedosto swap.py

def swap(lista, i, j):
    lista[i], lista[j] = lista[j], lista[i]

Tämä swap-funktio voisi olla yksi yksittäinen yksikkö esimerkiksi lajittelualgoritmin toteuttavassa Python-moduulissa. Mutta miten tätä funktiota voitaisiin testata?

Testitapaus

Yksinkertaisimmillaan voimme kirjoittaa yksittäisen testifunktion, eli testitapauksen, joka kutsuu yllä esitettyä testattavaa funktiota ja tarkistaa sen tuloksen.

“A test case is the individual unit of testing. It checks for a specific response to a particular set of inputs.

Python Software Foundation. Unit testing framework. https://docs.python.org/3/library/unittest.html

# tiedosto test_swap.py

from swap import swap


def test_swapping_two_strings():
    lista = ['eka', 'vika']

    swap(lista, 0, 1)

    assert lista == ['vika', 'eka']

test_swapping_two_strings() # TODO: tästä halutaan myöhemmin eroon

Tämä testi luo ensin listan kahdesta merkkijonosta, minkä jälkeen se yrittää vaihtaa niiden paikkoja. Lopuksi testi hyödyntää assert -komentoa varmistaakseen, että lopputulos vastaa odotuksia.

assert-komento

Python-kielessä on valmiina assert-komento, jolla voidaan suoraviivaisesti varmistaa, että tietyn lausekkeen arvo on True:

assert len('hello') == 5 # True, ei aiheuta poikkeusta

Mikäli arvo puolestaan on epätosi, aiheuttaa assert-komento AssertionError-poikkeuksen:

assert len('welcome') == 5 # False, aiheuttaa poikkeuksen:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

Testeissä assert-komentoa käytetään yksinkertaisesti varmistamaan, että testatun koodin tulos on se mitä pitäisi. Jos koodi ei aiheuta poikkeusta, pytest tulkitsee testin onnistuneeksi. Jos taas koodista aiheutuu AssertionError, pytest tulkitsee testin epäonnistuneeksi:

sanat = ['ajattelen', 'siis', 'olen']

swap(lista, 0, 2)

assert sanat == ['olen', 'siis', 'ajattelen']

Tämä testitapaus tulee vielä kirjoittaa omaan funktioonsa, jotta se olisi yksittäinen testitapaus:

def test_swapping_two_strings():
    sanat = ['ajattelen', 'siis', 'olen']

    swap(lista, 0, 2)

    assert sanat == ['olen', 'siis', 'ajattelen']

test_swapping_two_strings() # TODO: tästä halutaan myöhemmin eroon

Testien suoritustyökalu: test runner

Suoritimme ensimmäisen testitapauksen ylempänä suorittamalla testitapauksen itse tiedoston lopussa:

test_swapping_two_strings() # TODO: tästä halutaan myöhemmin eroon

Tämä ei kuitenkaan ole kovin kätevää, koska joutuisimme huolehtimaan itse siitä, että kaikki testitapaukset kaikissa eri testitiedostoissa tulevat suoritetuiksi. Tulokset olisi myös hyvä koostaa raportiksi. Lisäksi poikkeuksia heittävien testitapausten ei suotaisi lopettavan muiden testien suorittamista, vaan meidän tulisi kehittää sitä verten poikkeustenhallinta. Onkin paljon kätevämpää käyttää “test runner” -työkalua testien suorittamiseksi ja testiraportin generoimiseksi:

Testien automatisoimiseksi käytämme mieluummin aiemmin mainittua pytest-moduulia. Pytest huolehtii testitiedostojen ja niiden sisältämien testifunktioiden etsimisestä ja suorittamisesta automaattisesti. Se myös tuottaa selkokielisen raportin testien tuloksista.

Jotta Pytest käsittelee tiedostojamme testimoduuleina ja niissä olevia funktioita testitapauksina, sekä tiedostojen että funktioiden nimen alussa tulee olla etuliite test_.

Pytest-moduulin asentaminen ja suorittaminen

Voit asentaa Pythonin pytest-moduulin itsellesi seuraavalla komennolla:

pip3 install pytest

Pytest-moduulia voidaan käyttää joko erillisellä pytest-komennolla tai python3-komennon kautta valitsemalla -m -vivulla moduuliksi pytest. Voit varmistaa asennuksen toimivuuden esimerkiksi seuraavasti:

$ python3 -m pytest
======== test session starts =========
collected 1 item

src/test_swap.py .      [100%]

========= 1 passed in 0.06s ==========

pytest voidaan käynnistää myös omalla komennollaan:

$ pytest
======== test session starts =========
collected 1 item

src/test_swap.py .      [100%]

========= 1 passed in 0.06s ==========

Testien kattavuus

Testien kattavuutta voidaan mitata lukuisilla eri tavoilla. Tyypillisiä tapoja on mitata testeissä suoritettujen rivien tai vaihtoehtoisten suorituspolkujen määrää. Hyvällä testikirjastolla katamme kuitenkin myös koodin logiikan kannalta oleelliset tapaukset.

Edellä esitetyn swap-funktion testejä olisikin kenties syytä laajentaa vielä esim. seuraavilla testitapauksilla:

  • kahden merkkijonon vaihtaminen pidemmällä listalla
  • kahden kokonaisluvun paikan vaihtaminen
  • merkkijonon ja kokonaisluvun paikkojen vaihtaminen
  • sanakirjatyyppisten arvojen paikkojen vaihtaminen listalla

A test suite is a collection of test cases, test suites, or both. It is used to aggregate tests that should be executed together.

Python Software Foundation. Unit testing framework. https://docs.python.org/3/library/unittest.html

Yksikkötestauksen haasteet

Ohjelman rakenteesta riippuen sen testaaminen voi olla hyvin hankalaa. Esimerkiksi globaalit muuttujat, ulkoiset riippuvuudet ja “spagettikoodi” vaikeuttavat testausta merkittävästi. Jos testattavassa koodissa tehdään esimerkiksi HTTP-pyyntöjä tai tietokantakyselyjä, näiden operaatioiden tulokset vaikuttavat testien tuloksiin, joten testattavan aineiston muuttuessa myös testien tulokset voivat muuttua, vaikka koodi edelleen toimisi toivotulla tavalla. Oppitunnilla sivuamme myös tällaisten riippuvuuksien korvaamista testikohtaisilla mock-toteutuksilla.

Ulkoisten riippuvuuksien vaikutuksen minimoimiseksi testit suoritetaan usein erillisessä QA-ympäristössä (quality assurance), jossa eri rajapintojen vastaukset ja toiminta on hallittavissa. Tällä oppitunnilla meillä ei ole käytössä QA-ympäristöä, joten testaamme integraatiota postinumeroaineiston “tuotantodataa” vasten.

Oman postinumerologiikan testaaminen

Mikäli oma postinumerotehtävän ratkaisusi noudattaa malliratkaisun kaltaista arkkitehtuuria, jossa kaikki logiikka on toteutettu moduulin tasolle, joudut refaktoroimaan koodia testaamisen mahdollistamiseksi. Tämä johtuu siitä, että yksikkötestissä et halua kysyä syötettä käyttäjältä tai antaa ohjelman tulostaa konsoliin, vaan haluat itse ohjelmallisesti tarkistaa oikean lopputuloksen tietyllä syötteellä.

Toinen ongelma alkuperäisessä toteutuksessamme on logiikan toteuttaminen “skriptinä”, eli koodina, joka suoritetaan saman tien, mutta joka ei ole hyödynnettävissä muista Python-moduuleista.

“Python files which are used to run as a stand-alone Python app (top-level files) are usually designed to run as scripts and importing them would actually run the commands in the script.

Pavloski, M. Python Modules: Creating, Importing, and Sharing. https://stackabuse.com/python-modules-creating-importing-and-sharing/

Koodin automaattisen suorittamisen sijaan haluamme suorittaa sen ainoastaan silloin, kun sitä ollaan suorittamassa skriptinä. Lue lisää aiheesta artikkelin kohdasta “Dual-Mode Code”:

if __name__ == '__main__':
    main()

Kun testattavan moduulin import onnistuu, joudumme vielä muokkaamaan koodia siten, että se koostuu erikseen testattavissa olevista yksiköistä. Voimme toteuttaa esimerkiksi funktion, joka ottaa parametreinaan etsittävän postinumeron sekä sanakirjan postinumeroista ja postitoimipaikoista, ja palauttaa annettua toimipaikkaa vastaavan postinumeron nimen tulostusta varten muotoiltuna:

def etsi_postitoimipaikka(postinumero, postinumerot_sanakirja):
    if postinumero in postinumerot_sanakirja:
        return postinumerot_sanakirja[postinumero].title()
    else:
        return None

Tämän funktion testaaminen yksikkötestillä onkin jo huomattavasti helpompaa, koska se ei kysy käyttäjältä mitään eikä tee tulostuksia.

Yksikkötestien suorittaminen VS Codella

VS Codessa on oma erillinen näkymänsä testeille. Tämän näkymän kautta testien suoritusta voidaan nopeuttaa ja tehdä vielä havainnollisemmaksi kuin komentoriviltä.

Testing in Python is disabled by default. To enable testing, use the Python: Configure Tests command on the Command Palette. This command prompts you to select a test framework, the folder containing tests, and the pattern used to identify test files.

Python testing in Visual Studio Code. https://code.visualstudio.com/docs/python/testing

Ota testausominaisuudet käyttöön seuraamalla oppituntia tai ohjeita sivulla: https://code.visualstudio.com/docs/python/testing

Testidata

Testien kirjoittamisen ja hyödyllisyyden kannalta testattava data on avainasemassa. Jos testattava data vaihtelee, myös testien tulokset vaihtelevat. Lisäksi on tärkeää käyttää sellaista dataa, joka vastaa sopivan tarkasti oikeaa dataa, vaikka olisikin laajuudeltaan merkittävästi rajatumpaa.

“A test fixture represents the preparation needed to perform one or more tests, and any associated cleanup actions. This may involve, for example, creating temporary or proxy databases, directories, or starting a server process.”

Python Software Foundation. Unit testing framework. https://docs.python.org/3/library/unittest.html

Postinumerologiikkaa voitaisiin testata esimerkiksi seuraavanlaisella valmiilla tietorakenteella:

TOIMIPAIKAT = {
    "74701": "KIURUVESI",
    "35540": "JUUPAJOKI",
    "74700": "KIURUVESI",
    "73460": "MUURUVESI"
}

Tämän testiaineiston pohjalta voidaan testata jo tapauksia, joissa samaa postitoimipaikkan nimeä kohden löytyy yksi, useampi tai ei yhtään postinumeroa. Lisäksi voisi olla hyvä testata erityistapauksia, joissa toimipaikan nimessä esiintyy esimerkiksi ääkkösiä, välilyöntejä tai välimerkkejä:

ERIKOISTAPAUKSET = {
    "43800": "KIVIJÄRVI",
    "91150": "YLI-OLHAVA",
    "65374": "SMART POST"
}

Tämän yksinkertaisen ohjelmalogiikan osalta testidata voidaan luoda yksinkertaisesti Pythonin sanakirjoina. Tietokantapohjaisessa ohjelmistossa ennalta määrätty testidata voidaan tyypillisesti syöttää tietokantaan ennen jokaista testiä, jotta jokaisen testin alussa tietokannan sisältö on varmasti sama.

Monissa tapauksissa tietokannan alustaminenkaan ei riitä testien alustamiseksi. Esimerkiksi postinumero-ohjelmassamme yksi funktio hakee HTTP-pyynnön avulla JSON-tietorakenteen, jonka sisältö jossain vaiheessa tulee muuttumaan. Näiden riippuvuuksien korvaaminen testidatalla onkin laajemman ohjelman testaamisessa keskeinen tehtävä.

Miten testata koodia, jolla on riippuvuuksia?

“Some of the parts of our application may have dependencies for other libraries or objects. To isolate behaviour of our parts we need to substitue external dependencies. Here comes the mocking. We mock external API to have certain behaviours such as proper return values that we previously defined.”

Krzysztof Żuraw, 2016. https://krzysztofzuraw.com/blog/2016/mocks-monkeypatching-in-python

Ohjelmakoodiin toteuttamamme hae_postinumerot tekee HTTP-kutsun ja parsii vastauksena saadun JSON-olion sanakirjaksi:

def hae_postinumerot():
    with urllib.request.urlopen(URL) as response:
        data = response.read()
    return json.loads(data)

Koska HTTP-rajapinnasta saatava vastaus muuttuu Postin postinumeroiden muuttuessa, joten tuloksena saatava sanakirja vaihtelee ajan kuluessa. Tämän aineiston muuttumisnopeus ei välttämättä ole nopea, mutta ongelma olisi ilmeinen esimerkiksi hetkellisiä säähavaintoja haettaessa. Datan muuttumisen lisäksi oikea pyyntö HTTP:n yli ja vastauksen käsittely voi myös viedä tarpeettoman paljon aikaa, joten sitä ei haluta tehdä yksikkötestissä.

Yksikkötesteissä ulkoiset riippuvuudet korvataan usein ns. mock’eilla, joiden avulla testi suorittaa vain tietyn osaan koodista. Riippuvuudet voivat olla niin ulkoisiin rajapintoihin kuin vaikka kellonaikoihin liittyviä.

pytest-mock

Käyttämämme Pytest-testityökalun pytest-mock-laajennus voidaan asentaa seuraavasti:

pip3 install pytest-mock

Pystest-mock (https://pypi.org/project/pytest-mock/) lisää testeihin käytettäväksi mocker-olion, joka saadaan injektoitua testifunktioon kirjoittamalla testin parametrimuuttujiin mocker:

# dependency injection huolehtii `mocker`-olion injektoimisesta,
# kunhan olemme asentaneet pytest-mock-paketin:
def test_tama_testi_tarvitsee_mockerin(mocker):
    pass

Koska määrittelimme parametrin mocker-nimiseksi, Pytest tietää, että tätä testiä varten täytyy injektoida juuri mocker-olio. Muilla nimillä saisimme käyttöömme muita laajennoksia.

Kun mocker on injektoitu funktioon, sen avulla voidaan korvata tilapäisesti esimerkiksi funktioita uusilla mock-funktioilla, joilla on aina sama paluuarvo:

def test_tassa_testissa_hae_postinumerot_on_mockattu(mocker):
    mocker.patch('http_pyynto.hae_postinumerot', return_value={ '00100': 'HELSINKI' })

Yllä oleva koodirivi korvaa testitapauksen suorituksen ajaksi http_pyynto.hae_postinumerot-funktion toisella, joka palauttaa aina saman vastauksen.

Mocker-olio ja injektointi huolehtivat siitä, että hae_postinumerot-funktiota ei korvata pysyvästi, vaan kyseisen testitapauksen suorittamisen jälkeen tämä ja kaikki muut korvatut funktiot palautetaan taas ennalleen. hae_postinumerot voidaan siis testata korvaamalla sen riippuvuus staattisella paluuarvolla, jonka tuloksen tiedämme ennalta.

Integraatiotestaus

“Integraatiotestauksessa testataan useiden komponenttien yhteistoimintaa tavoitteena löytää virheitä, jotka eivät tulleet esiin yksikkötesteissä. Testeissä suoritetaan tiettyjä suorituspolkuja, jotka hyödyntävät useita eri yksiköitä tai laajempia komponentteja, ja tarkastellaan toiminnan tuloksia.”

Jyväskylän Yliopisto, Informaatioteknologian tiedekunta. Testauksen tasot.

Koska edellisissä testeissä käytimme itse luotua keinotekoista dataa, ei testit välttämättä paljasta kaikkia virheitä, jotka ilmenevät rajapinnan oikeassa datassa. Siksi on tärkeää testata myös oman ohjelmamme ja rajapinnan välistä yhteistoimintaa integraatiotestillä.

Integraatiotestit voivat olla luonteeltaan yksikkötestejä monimutkaisempia ja hitaampia, joten niitä suoritetaan tyypillisesti keskitetyssä CI-järjestelmässä (continuous integration) eikä välttämättä vain kehittäjän omalla työasemalla.

Integraatiotestejä voidaan toteuttaa samoilla teknologioilla kuin yksikkötestejä. Käytännössä voisimme toteuttaa integraatiotestin oman Python-sovelluksemme ja JSON-rajapinnan välille kirjoittamalla samankaltaisen testin kuin aikaisemmin, mutta ilman mock-vastausta.

Järjestelmätestaus

“Järjestelmätestauksessa testataan kokonaista ohjelmaa, ja tarkastellaan vastaako ohjelma sille asetettuja vaatimuksia ja käyttötarkoitusta. Aitoon ympäristöön kuuluvat mm. käytettävä laitteisto, tietokannat ja käyttäjät.”

Jyväskylän Yliopisto, Informaatioteknologian tiedekunta. Testauksen tasot

Järjestelmätestauksella varmistetaan usein monivaiheisia käyttötapauksia. Testattava käyttötapaus voisi pitää sisällään esimerkiksi kirjautumisen järjestelmään, jonkin datan muokkaamisen ja muokatun datan tarkastelemisen. Järjestelmätestejä tehdäänkin usein eri työkaluilla kuin yksikkötestejä. Yksi järjestelmätesteissä hyödyllinen testityökalu on kotimaista alkuperää oleva Robot Framework, jolla voidaan erilaisten laajennusten kanssa testata verkkosivuja tai vaikka matkapuhelinverkkoja. Robot Frameworkilla on oma kielensä, jolla testitapaukset voivat näyttää esim. tältä: https://github.com/robotframework/WebDemo/blob/master/login_tests/valid_login.robot.

Extra: riippuvuuksien hallinta

Olemme tämän kurssin aikana asennelleet Pythonin kirjastoja yksi kerrallaan pip3 install -komennolla. Jotta kirjoittamamme koodi olisi asennettavissa toisen kehittäjän koneelle tai tulvaisuudessa CI- ja tuotantoympäristöihin, täytyy riippuvuudet dokumentoida. Pip-pakettienhallinta mahdollistaa asennettujen riippuvuuksien listaamisen kätevästi pip3 freeze-komennolla:

$ pip3 freeze

autopep8==1.5.4
pylint==2.5.3
pytest==6.0.1
pytest-mock==3.3.1
python-dateutil==2.8.1
rope==0.17.0

# ... ja monia muita riippuvuuksia

Pip mahdollistaa myös useiden riippuvuuksien asentamisen kerralla requirements.txt -tiedostojen avulla. Voit lukea lisää näistä tiedostoista virallisesta dokumentaatiosta.

Tätä omaa projektiamme varten voimme tallentaa riippuvuudet requirements.txt-tiedostoon ohjaamalla freeze-komennon tulosteet requirements.txt-nimiseen tiedostoon:

$ pip3 freeze > requirements.txt
$ cat requirements.txt

autopep8==1.5.4
pylint==2.5.3
pytest==6.0.1
pytest-mock==3.3.1
python-dateutil==2.8.1
rope==0.17.0

Myöhemmin samat riippuvuudet on asennettavissa uuteen ympäristöön yksinkertaisesti käyttämällä install-komentoa -r -vivulla:

$ pip3 install -r requirements.txt

Extra: testien kattavuus (coverage)

Yksi tapa mitata testien laatua on testikattavuus (coverage), joka tarkoittaa niiden kirjoitettujen koodirivien osuutta, jotka suoritetaan testien aikana. Testikattavuutta voidaan rivien määrän lisäksi mitata myös erilaisten suorituspolkujen määrillä. Pythonin coverage-moduuli auttaa selvittämään, mitkä rivit tulevat suoritetuksi testien aikana. Voit halutessasi tutustua tähän työkaluun itsenäisesti.

$ pip3 install coverage     # coverage-moduulin asentaminen

Suoritetaan testit coverage -komennolla ja lasketaan testikattavuus testatulle swap.py-tiedostolle:

$ coverage run --source=swap -m pytest test_swap.py

Katsotaan raportti:

$ coverage report

Name                Stmts   Miss  Cover
---------------------------------------
swap.py                48      3    94%

Voit käyttää myös coverage html-komentoa, joka muodostaa raportin staattisen verkkosivun muodossa.

Tehtävä

Tällä viikolla harjoittelemme koodin refaktorointia ja yksikkötestausta kirjoittamalla testejä aikaisemmin koodatulle postinumerot.py-skriptille. Mikäli aikaisempi tehtävä jäi sinulta palauttamatta tai et halua käyttää vanhaa koodiasi, voit käyttää myös tehtävän malliratkaisun tiedostoja.

Postitoimipaikkalogiikan testaaminen

Kirjoita yksikkötestit edellisen viikon Python-tehtävän osan 2 ratkaisullesi. Mikäli kyseinen tehtävä jäi sinulta toteuttamatta, voit käyttää testattavana koodina tehtävän malliratkaisua.

Sinun ei tarvitse testata koko ohjelmalogiikkaa, vaan riittää, että testaat esimerkiksi malliratkaisussa esitetyn ryhmittele_toimipaikoittain-funktion. Lisäksi joudut refaktoroimaan Python-tiedostoa siten, että sen testaaminen on ylipäänsä mahdollista.

Testeissä kannattaa varmistaa ainakin seuraavien tapausten toiminta:

  1. postitoimipaikan nimellä löytyy vain yksi postinumero
  2. postitoimipaikan nimellä löytyy useita postinumeroita.

Saadaksesi täydet pisteet tästä osasta sinun ei tarvitse testata syötteitä pyytäviä tai tulosteita tekeviä kohtia koodista. Voit oman harkintasi mukaan käyttää testeissä joko kovakoodattua testidataa tai antaa testattavan koodin lukea postinumeroaineiston verkosta tai levyltä. Testiaineiston käyttämisessä pytest-mock voi olla avuksi, mutta sitä ei ole välttämätöntä käyttää.