Nämä ohjeet on kirjoittanut Jeremias Berg

Huomaa

Alla oleva on tarkoitettu mahdolliseksi harjoitustyön kehittämistä helpottavaksi työkaluksi. Tämän materiaalin omaksumista ei vaadita tällä kurssilla, vaikka siitä voi hyötyä ollakin.

Jatketaan siitä mihin yksikkötestauksessa jäätiin. Jos unittest sovelluskehys, poetry ja pytest ovat tuttuja, voit lukea tämän suoraan. Muuten kannattaa tutustua ensin tähän.

Muistutuksena, tämänhetkinen Maksukortti luokka:

# aterioiden hinnat ovat senteissä
EDULLINEN = 250
MAUKAS = 400

class Maksukortti:
    def __init__(self, saldo):
        # saldo on senteissä
        self.saldo = saldo

    def syo_edullisesti(self):
        if self.saldo >= EDULLINEN:
            self.saldo -= EDULLINEN

    def syo_maukkaasti(self):
        if self.saldo >= MAUKAS:
            self.saldo -= MAUKAS

    def lataa_rahaa(self, maara):
        if maara < 0:
            return

        self.saldo += maara

        if self.saldo > 15000:
            self.saldo = 15000

    def saldo_euroina(self):
        return self.saldo / 100

    def __str__(self):
        saldo_euroissa = round(self.saldo / 100, 2)

        return "Kortilla on rahaa {:0.2f} euroa".format(saldo_euroissa)

ja sen testit:

import unittest
from maksukortti import Maksukortti

class TestMaksukortti(unittest.TestCase):
    def setUp(self):
        self.kortti = Maksukortti(1000)

    def test_konstruktori_asettaa_saldon_oikein(self):
        self.assertEqual(str(self.kortti), "Kortilla on rahaa 10.00 euroa")


    def test_syo_edullisesti_vahentaa_saldoa_oikein(self):
        self.kortti.syo_edullisesti()

        self.assertEqual(self.kortti.saldo_euroina(), 7.5)

    def test_syo_maukkaasti_vahentaa_saldoa_oikein(self):
        self.kortti.syo_maukkaasti()

        self.assertEqual(self.kortti.saldo_euroina(), 6.0)

    def test_syo_edullisesti_ei_vie_saldoa_negatiiviseksi(self):
        kortti = Maksukortti(200)
        kortti.syo_edullisesti()

        self.assertEqual(kortti.saldo_euroina(), 2.0)

    def test_kortille_voi_ladata_rahaa(self):
        self.kortti.lataa_rahaa(2500)

        self.assertEqual(self.kortti.saldo_euroina(), 35.0)

    def test_kortin_saldo_ei_ylita_maksimiarvoa(self):
        self.kortti.lataa_rahaa(20000)

        self.assertEqual(self.kortti.saldo_euroina(), 150.0)

jolla päästään kattavuuteen:

Name                 Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------------
src/maksukortti.py      22      1      8      2    90%   15->exit, 20
----------------------------------------------------------------
TOTAL                   22      1      8      2    90%

Monimutkainen Maksukortti

Simuloidaan seuraavaksi (hieman keinotekoisesti) monimutkaisemman algoritmin testausta. Kuvitellaan, että syödessä maukkaasti halutaan tarkastaa jokin monimutkainen ehto, joka varmistaa, että syöjällä on tähän oikeudet. Kuvitellaan myös, että tämän ehdon implementoinissa tapahtuu virhe jonka seurauksena kortit jolla on 1337 senttiä eivät voi syödä maukkaasti.

Lisätään nämä metodit Maksukortti luokkaan:

def monimutkainen_ehto(self):
    return self.saldo != 1337
    
def syo_maukkaasti(self):
    if self.saldo >= MAUKAS and self.monimutkainen_ehto():
        self.saldo -= MAUKAS

Kokeillaan testejä:

(maksukortti-py3.9) jezberg@dhcp-85-175 maksukortti % coverage run --branch -m pytest src ;   coverage report -m 
====================================================================== test session starts ======================================================================
platform darwin -- Python 3.9.6, pytest-7.4.4, pluggy-1.3.0
rootdir: /Users/jezberg/Documents/teaching/tiralabra/maksukortti
collected 6 items                                                                                                                                               

src/tests/maksukortti_test.py ......                                                                                                                      [100%]

======================================================================= 6 passed in 0.01s =======================================================================
Name                 Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------------
src/maksukortti.py      24      1      8      2    91%   18->exit, 23
----------------------------------------------------------------
TOTAL                   24      1      8      2    91%
(maksukortti-py3.9) jezberg@dhcp-85-175 maksukortti % 

Huomaamme, että testien kattavuus on itse asiassa lisääntynyt. Syy tähän on, että lisäehtomme tuli haaraan jota aikaisemmassa osassa ei testattu. Toisin sanoen kattavuudesta voisimme (virheellisesti) päätellä, että testaamme luokan toiminnallisuutta kunnollisesti, vaikka nyt millään kortilla jolla on 13.37€ rahaa ei voi ostaa maukkaita aterioita.

Huom. Tässä keinotekoisessa esimerkissä on toki helppo huomata, että metodia monimutkainen_ehto() ei testata ja lisätä yksikkötesti joka alustaa kortin juuri 1337 sentillä. Tässä kuitenkin simuloidaan tilannetta, jossa funktion monimutkainen_ehto() totuusarvoa on mahdotonta todeta staattisesti etukäteen. Tämän seurauksena emme pysty määrittelemään syöteitä yksikkötestille joka varmasti saisi sen palauttamaan sekä true, että false. Jos tämä esimerkki tuntuu liian keinotekoiselta voit kuvitella esim. ohjelman joka tekee jotain erilailla jos jonkun neuroverkon virhe on enemmän kuin 15%, tai tekoälyn, joka toimii erilailla mikäli se toteaa voittomahdollisuuksiensa olevan yli 83%.

Yksittäisistä Syötteistä Invariantteihin

Nähtiin siis tapaus, jossa koodi toimii halutulla tavalla melkein kaikilla syötteillä. Voidaksemme kirjoittaa testin joka huomaa bugin, meidän täytyisi osata arvata syötteet jolla koodi ei toimi. Harjoitustyössä toteutettaville algoritmeille tämä voi olla parhaimillaankin erittäin haastavaa, yleensä mahdotonta. Yksittäisten syötteiden sijasta tälläisissä tapauksissa kannattaakin testata invariantteja joita metodien tulisi toteuttaa, ja luoda mahdolliset syötteet automaattisesti. Englanniksi tätä tekniikka kutsutaan usein nimellä invariant tai property testing, ja se liittyy myös läheisesti ns. fuzzaukseen.

Invarianttitestauksessa ideana on, että:

  1. Kuvaillaan kaikki mahdolliset syötteet mitä halutaan testata.
  2. Tehdään niille jotakin.
  3. Tarkastetaan tulos.

Vertaa tätä “normaaliin” yksikkötestaukseen, jossa:

  1. Valitaan yksi syöte.
  2. Tehdään sille jotakin.
  3. Tarkastetaan tulos.

Invariantteja testattaessa käytetään usein olemassa olevia kirjastoja, joille voidaan kuvata miten mahdollisia syötteitä luodaan, ja minkä testin jokaisen syötteen pitäisi läpäistä. Kirjasto luo sitten syötteitä sattumanvaraisesti ja ajaa niillä testejä läpi kunnes joko jokin raja saavutetaan, tai jokin syötteistä ei läpäise testiä. Jälkimmäisessä tapauksessa invariantti testaukseen tarkoitettu kirjasto usein myös pyrkii heuristisesti pienentämään syötettä joka ei läpäissyt testiä löytääkseen pienimmän mahdollisen jolla testi ei mene läpi. Tämän tarkoituksena on auttaa ihmiskoodaajaa ymmärtämään, miten korjata koodia.

Katsotaan seuraavaksi konkreettisesti invarianttitestien toteuttamista pythonin hypothesis kirjaston avulla. Lisätään se ensin maksukortti projektiin kehityksen aikaisesksi riippuvuudeksi:

jezberg@dhcp-85-175 maksukortti % poetry add hypothesis --group dev 

Lisätään testi luokan (maksukortti_test) alkuun.

import hypothesis.strategies as st
from hypothesis import given

ja kirjoitetaan testi

@given(arvo=st.integers(min_value=0, max_value=15000))
def test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis(self, arvo):
    kortti = Maksukortti(arvo)
    kortti.syo_maukkaasti()
    self.assertTrue(kortti.saldo_euroina() == ((arvo - 400) / 100) or arvo < 400)

Tässä @given kertoo, mitä parametrin “arvo” mahdollisia arvoja halutaan testata. Tässä tapauksessa käytetään hypothesis kirjaston omia valmiita strategioita kokonaislukujen (intgetereiden) luomiseen. Tässä normaali strategiaan lisätään, että halutaan testata kaikkia arvoja 0an ja 15000 (kortin maksimiarvon) välillä. Ts. @given määrittelee, että seuraavassa testissä parametri “arvo” on jokin kokonaisluku välillä 0 ja 15000. Tätä seuraava testi oleellisesti testaa invarianttia “jos kortilla on arvoa yli 400 senttiä, niin tällöin metodin syo_maukkaasti kutsuminen vähentää arvoa 400:lla.

Kokeillaan testejä (muista käynnistää virtuaaliympäristö):

(maksukortti-py3.9) jezberg@dhcp-85-175 maksukortti % pytest src                                                 
======================================================================================== test session starts =========================================================================================
platform darwin -- Python 3.9.6, pytest-7.4.4, pluggy-1.3.0
rootdir: /Users/jezberg/Documents/teaching/tiralabra/maksukortti
plugins: hypothesis-6.92.2
collected 7 items                                                                                                                                                                                    

src/tests/maksukortti_test.py .......                                                                                                                                                          [100%]

========================================================================================= 7 passed in 0.13s ==========================================================================================
(maksukortti-py3.9) jezberg@dhcp-85-175 maksukortti % 
               24      1      8      1    94%

Vaikuttaa siltä, että jokin olisi väärin. Pytest löytää uuden testin, mutta ne kaikki menevät läpi, vaikka tässsä (keinotekoisessa) tapauksessa tiedetäään, että kun arvo on 1337 testin pitäisi hylätä. Tutkitaan vielä coveragen raporttia samalla lailla kun aiemmin:

coverage run --branch -m pytest src; coverage html

Kun verrataan aiempaan reporttiin huomataan, että haarakattavuutemme on parempi. Eritysesti, nykyisen rivin 18 (aiemman rivin 15) haarasta testataan nyt molemmat tapaukset. Tämä johtuu siitä, että juuri kirjoittamamme testi kokeilee myös arvoja jotka ovat alle 4 euroa, kun aiemmat testit kutsuvat syo_maukkaasti metodia vain kun kortilla on 10 euroa.

Haarakattavuuden mielessä nämä testit ovat siis parempia ja testaavat suurempaa osaa koodista. Tämän lisäki toivottu invariantti tuntuu pitävän. Vielä tämäkään ei kuitenkaan riitä. Ongelmana on, että testimme hylkää täsmälleen yhden yhteensä 150000 mahdollisesta arvosta ja normaaleilla asetuksilla hypotheis kirjasto ajaa jokaisen testin vain 100 kertaa eri, sattumanvaraisesti valituilla, arvoilla. Jos ajaisimme testejä tarpeeksi monta kertaa, lopulta hypothesis voisi sattumalta valita arvon 1337 jolloin testit hylkäisivät. Meillä ei kuitenkaan ole mitään takuita siitä monta kertaa täytyy ajaa. Toisin sanoen invarianttitestit eivät vielä itsessään täysin takaa koodin toimivuutta. Moninmutkaisten algoritmien testaaminen vaatii siis edelleenkin hyvää ymmärrystä algoritmista ja sen toivotusta toiminnasta

Miten Parannetaan?

Katsotaan vielä, miten tätä yhtä testiä voisi parantaa saamaan kiinni tunnetun bugin. Yleisesti tähän on kolme tapaa:

  1. Testataan enemmän arvoja.
  2. Lisätään yksittäisiä arvoja pakollisiksi testeiksi.
  3. Testataan arvoja jotka oletamme vaikeiksi

Testataan Enemmän

Tämä on ehkä kaikkein luonnollisin ajatus parantaa testejä. Käsketään yksinkertaisesti hypothesisiä kokeilemaan enemmän arvoja. Tämä onnistuu lisäämällä importteihin “settings” käskyn ja lisäämällä sen testin eteen:

import hypothesis.strategies as st
from hypothesis import given, settings

ja lisätään testiin

@given(arvo=st.integers(min_value=0, max_value=15000))
@settings(max_examples=15000)
def test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis(self, arvo):
    kortti = Maksukortti(arvo)
    kortti.syo_maukkaasti()
    self.assertTrue(kortti.saldo_euroina() == ((arvo - 400) / 100) or arvo < 400)

Tässä siis @settings käsky käskee ajamaan seuraavan testin vähintään 15000 eri arvolla ennen kuin testi määritellän hyväksytyksi. Tässä keinotekoisessa esimerkissä tästä seuraa, että itse asiassa kokeillaan kaikki mahdolliset arvot.

Kokeillaan:

(maksukortti-py3.9) jezberg@dhcp-85-175 maksukortti % pytest src 
============================================== test session starts ==============================================
platform darwin -- Python 3.9.6, pytest-7.4.4, pluggy-1.3.0
rootdir: /Users/jezberg/Documents/teaching/tiralabra/maksukortti
plugins: hypothesis-6.92.2
collected 7 items                                                                                               

src/tests/maksukortti_test.py ......F                                                                     [100%]

=================================================== FAILURES ====================================================
_____________________ TestMaksukortti.test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis _____________________

self = <tests.maksukortti_test.TestMaksukortti testMethod=test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis>

    @given(arvo=st.integers(min_value=0, max_value=15000))
>   @settings(max_examples=15000)

src/tests/maksukortti_test.py:44: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
src/tests/maksukortti_test.py:48: in test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis
    self.assertTrue(kortti.saldo_euroina() == ((arvo - 400) / 100) or arvo < 400)
E   AssertionError: False is not true
E   Falsifying example: test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis(
E       self=<tests.maksukortti_test.TestMaksukortti testMethod=test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis>,
E       arvo=1337,
E   )
E   Explanation:
E       These lines were always and only run by failing examples:
E           /Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/unittest/case.py:681
============================================ short test summary info ============================================
FAILED src/tests/maksukortti_test.py::TestMaksukortti::test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis - AssertionError: False is not true
========================================== 1 failed, 6 passed in 3.22s ==========================================

Nyt saatiin mitä haluttiin, hypotheis kertoo, että yksi testi epäonnistui, ja myös että se epäonnistui silloin kun muuttuja arvo on 1337, eli aivan kuten odotimmekin.

Lisätään Yksikkötestejä

Jos ajoit edellisen testin itse, huomasit että siihen meni (verattaessa aiempaan) melko paljon aikaa. Tämä johtuu yksinkertaisesti siitä, että viimeinen testi ajettiin yhteensä 15000 kertaa. Tämä on useamman arvon testaamisen heikkous, vaikeita metodeja voi olla aivan liian hidasta testata näin perusteellisesti aina kun ajetaan testejä. Tilanne pahenee edelleen jos metodi jota testataan ei ole vakioaikainen.

Kuitenkin jos satuttaisiin jollain lailla tietämään, että arvo 1337 on hankala metodille (esim. ajamalla kerran perusteelliset testit), niin voimme pakottaa hypotheis kirjaston aina kokeilemaan ainakin sen arvon. Tämä onnistuu @example lisäyksellä:

import hypothesis.strategies as st
from hypothesis import given, settings, example

ja muokataan testiin

@given(arvo=st.integers(min_value=0, max_value=15000))
@example(1337)
def test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis(self, arvo):
    kortti = Maksukortti(arvo)
    kortti.syo_maukkaasti()
    self.assertTrue(kortti.saldo_euroina() == ((arvo - 400) / 100) or arvo < 400)

Huomaa, että poistimme aikaisemmin lisätyn setting käskyn. Täten hypothesis kokeilee vain 100 eri arvoa, jonka ei pitäisi kestää niin kauaa. Kuitenkin @example lisäyksen takia, yksi näistä arvoista on varmasti 1337. Kokeillaan:

(maksukortti-py3.9) jezberg@dhcp-85-175 maksukortti % pytest src
============================================== test session starts ==============================================
platform darwin -- Python 3.9.6, pytest-7.4.4, pluggy-1.3.0
rootdir: /Users/jezberg/Documents/teaching/tiralabra/maksukortti
plugins: hypothesis-6.92.2
collected 7 items                                                                                               

src/tests/maksukortti_test.py ......F                                                                     [100%]

=================================================== FAILURES ====================================================
_____________________ TestMaksukortti.test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis _____________________

self = <tests.maksukortti_test.TestMaksukortti testMethod=test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis>

    @given(arvo=st.integers(min_value=0, max_value=15000))
>   @example(1337)

src/tests/maksukortti_test.py:44: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
../../../../Library/Caches/pypoetry/virtualenvs/maksukortti-15_6LZF8-py3.9/lib/python3.9/site-packages/hypothesis/core.py:1230: in _raise_to_user
    raise the_error_hypothesis_found
src/tests/maksukortti_test.py:48: in test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis
    self.assertTrue(kortti.saldo_euroina() == ((arvo - 400) / 100) or arvo < 400)
E   AssertionError: False is not true
E   Falsifying explicit example: test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis(
E       self=<tests.maksukortti_test.TestMaksukortti testMethod=test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis>,
E       arvo=1337,
E   )
============================================ short test summary info ============================================
FAILED src/tests/maksukortti_test.py::TestMaksukortti::test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis - AssertionError: False is not true
========================================== 1 failed, 6 passed in 0.13s ==========================================

Eli testi ei mene läpi, eikä kestäkään niin kauaa enää.

Huom Yleisessä tapauksessa invariantti testejä ei siis kannata ajaa kaikille mahdollisille syötteille, tämä veisi aivan liian kauan. Sen sijaan niitä kannattaa ajaa tarpeeksi monelle arvolle usein. Ja vähän useammalle vähän harvemmin kuin perus yksikkkötestejä. Nämä, vähän hitaammat, testit voi sitten ajaa aina isompien muutosten jälkeen, ja tarpeen mukaan nostaa niistä löydettyjä hankalia syötteitä yksikkötesteihin jotka ajetaan useammin, esimerkiksi @example komennon kautta.

Testataan Tarkemmin

Lopuksi voimme myös todeta, että jos sattuisimme tietäämään, että kortin arvot 1300-1400 ovat haasteellisia niin voimme toki käskeä hypothesista testaamaan vain niitä:

@given(arvo=st.integers(min_value=1300, max_value=1400))
def test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis(self, arvo):
    kortti = Maksukortti(arvo)
    kortti.syo_maukkaasti()
    self.assertTrue(kortti.saldo_euroina() == ((arvo - 400) / 100) or arvo < 400)

joka epäonnistuu ilman parametrien säätöä. Tälläinen tapaus voisi sattua jos vaikka tietäisimme että monimutkainen_ehto metodimme Maksukortti luokassa tekee jotain (vaikka emme tietäisi mitä) vain jos arvo on tietyllä välillä.

Syötteiden Generoinnista

Lopuksi mainittakoon vielä, että hypothesiksen syötteen generointi ulottuu huomattavasti kokonaislukuja pidemmälle. Hypothetiksen valmiit strategiat määrittelevät kuinka perustyyppejä voidaan luoda, mutta myös miten voit määritellä oman datatyyppisi luojan tai yhdistellä eri tyyppien generoijia toisinsa. Täten voit käytännössä käyttää hypothesista minkälaisten metodien testaamiseen tahansa. Palataakseni aikaisempiin esimerkkeihin, voisimme esim testata metodeja jotka otavat koko neuroverkon syötteeksi (muista riippuvuuden injektointi) ja kertoa hypothesikselle, miten neuroverkkoja muodostetaan. Tällöin kirjasto voisi (teoriassa) testata sattumanvaraisesti muodostetuilla (ja treenatuilla) neuroverkoilla kunnes löytyy joku, jonka virhe on yli 15%, ilman että meidän täytyy itse osata määritellä, miten sellainen muodostetaan.

Yhteenveto

Invarianttitestaus automatisoi joitain osia testauksesta ja helpottaa bugien löytämistä monimutkaisista algoritmeista. Teoriassa voimme vain kuvailla minkälaisia syötteitä haluamme testata, ja mitä näiden syötteiden täytyy toteuttaa. Kuitenkin, kuten tässä huomasimme, myös invarianttitestien toteuttaminen vaatii hyvää algoritmin tuntemusta ja tarkkuutta testien toteuttamisessa. Suositelemme vahvasti käyttämään niitä yksikkötestien lisänä harjoitustyön kehityksessä.

Lisää Materiaalia

  • Hypothesis kirjaston oma dokumentaatio.
  • Jqwik on javalle tehty samanlainen kirjasto.
  • Junit-quickcheck on toinen Junit testikirjastoa muistuttava java kirjasto jolla tälläinen testaaminen onnistuu.
  • Haskelin Quickcheck kirjasto on inspiroinut paljon muita tälläisiä kirjastoja.
  • Tutoriaali hypothesiksen käytöstä Mickey Petersenin kirjoittamana.

Lisää mietittävää

Palataan testiin:

@given(arvo=st.integers(min_value=0, max_value=15000))
@settings(max_examples=15000)
def test_syo_maukkaasti_vahentaa_saldoa_oikein_hypothesis(self, arvo):
    kortti = Maksukortti(arvo)
    kortti.syo_maukkaasti()
    self.assertTrue(kortti.saldo_euroina() == ((arvo - 400) / 100) or arvo < 400)
  • Löydätkö tästä testistä bugin?
  • Mitä tapahtuu jos maksukorttimme vähentäisi 450 senttiä silloin kun kortilla on 410 senttiä?

Korjauksia tälle sivulle

Tee korjausehdotus editoimalla tätä tiedostoa GitHubissa.