3.1 Testfunksjoner og assert

Skrive testfunksjoner med `assert abs(expected - computed) < tol` — kravet som følger nesten hver kodeskriving-oppgave.

40 min
10 oppgaver
Testfunksjonerassert
Din fremgang i kapitlet
0 / 10 oppgaver

Forkunnskaper

Du trenger fire ting fra Del 1:

- Funksjoner fra kap. 1.6: hvordan du skriver def, hva return gjør, og at en funksjon uten return gir None. Hele dette kapitlet handler om å teste funksjoner du selv har skrevet.
- Flyttall fra kap. 1.1, særlig setningen om at flyttall er tilnærminger. Den setningen er hele grunnen til at dette kapitlet finnes.
- F-strenger fra kap. 1.4, fordi feilmeldingen i en test skal vise tallene.
- Løkker fra kap. 1.3, inkludert zip, når vi tester flere punkter i samme testfunksjon.

Kapitlet er delt i fire løkker med tidsanslag, og du kan lese det i én økt på tre kvarter.

Løkke 1 — å be programmet stoppe når noe er galt (~10 min)

Et laboratorium logger konsentrasjonen av et legemiddel i blodet. Programmet som behandler loggen forventer fire målinger per døgn. Kommer det bare tre, regner programmet videre og gir et svar som ser helt fornuftig ut — men som er feil.

Det er den farligste typen feil: den som ikke gir feilmelding. Python har en innebygd måte å si «her stoller jeg på at noe er sant, og hvis det ikke er det, skal du stoppe med en gang». Setningen heter assert, og ordet betyr «å påstå».

En assert er altså en påstand du plasserer i koden. Er påstanden sann, skjer ingenting, og programmet fortsetter som om linja ikke var der. Er den usann, stopper programmet umiddelbart.

`assert uttrykk`

Sjekker at uttrykk er sant. Er det sant, skjer ingenting — programmet går videre til neste linje. Er det usant, avbrytes programmet med en feil som heter AssertionError.

Uttrykket skal være noe som gir sant eller usant: en sammenligning som x > 0, en likhet som len(v) == 4, eller et boolsk uttrykk med and/or. Det er ingen parenteser rundt uttrykket, og ingen kolon etterpå — assert er en setning, ikke en funksjon.

alder = 25
assert alder > 0
print('Alderen er gyldig')

Utskrift:

Alderen er gyldig

`assert uttrykk, melding`

Samme sjekk, men med en tekst som blir vist hvis påstanden er usann. Meldingen står etter et komma og er en vanlig streng — gjerne en f-streng, slik at den kan vise tallene som ikke stemte.

Dette er formen boka og de offisielle løsningsforslagene bruker. Grunnen er praktisk: en test som feiler uten melding forteller deg bare at noe er galt, mens en test med melding forteller deg hva som var galt.

antall_maalinger = 3
assert antall_maalinger == 4, 'Vi ventet fire målinger'
print('Denne linja kjøres aldri')

Utskrift: ingen — programmet stopper på assert-linja, og siste linje i feilmeldingen er:

AssertionError: Vi ventet fire målinger

`AssertionError`

Navnet på feilen assert utløser når påstanden er usann. En exception er en feil som avbryter programmet; AssertionError er én av dem, på linje med ValueError og IndexError fra kap. 2.2.

To ting er verdt å merke seg. For det første kjøres ingenting av det som står etter en assert som feiler — heller ikke resten av linjene i funksjonen. For det andre er teksten du ga etter kommaet det eneste som skiller en nyttig feilmelding fra en ubrukelig: uten melding står det bare AssertionError og ingenting mer.

✏️Eksempel 1: en påstand som holder, og en som ikke gjør det

Et program leser inn antall dyr som er talt opp i to ruter i et feltforsøk, og krever at begge tallene er positive. Hva skjer når begge er positive, og hva skjer når det ene er null?


Først tilfellet der påstanden holder:

rute_a = 12
rute_b = 7
assert rute_a > 0 and rute_b > 0, 'begge rutene må ha minst ett dyr'
print('Sum:', rute_a + rute_b)

Utskrift:

Sum: 19

Den andre kjøringen er identisk, men med rute_b = 0. Da er rute_a > 0 and rute_b > 0 usant, assert avbryter, og print-linja nås aldri.

Utskrift: ingen — siste linje i feilmeldingen er:

AssertionError: begge rutene må ha minst ett dyr

Legg merke til at det ikke står noe om hvilken rute som var tom. Vil du vite det, må meldingen inneholde tallene, og da bruker du en f-streng: f'rute_a = {rute_a}, rute_b = {rute_b}'. Det er nøyaktig det testfunksjonene senere i kapitlet gjør.

📝Oppgave 1

(Innstegsoppgave, sjanger A — kodesporing, altså «hva skriver programmet ut?».) Hva skriver programmet ut?

def dobbel(x):
    return 2*x

def test_dobbel():
    expected = 8
    computed = dobbel(4)
    tol = 1e-10
    assert abs(expected - computed) < tol, 'feil i dobbel'

test_dobbel()
print('Ferdig')

📝Oppgave 2

(Midtveisnivå, sjanger A — kodesporing med assert.) Hva skriver programmet ut, og hva skjer helt til slutt?

verdier = [2, 4, 6]
s = 0
for v in verdier:
    s += v
    print(s, end=' ')
assert s == 10, 'summen ble ikke 10'
print('ferdig')

Løkke 2 — hvorfor du aldri sammenligner flyttall med likhetstegn (~10 min)

Nå kommer den ene faglige innsikten i kapitlet. Alt annet er håndverk.

Et flyttall i Python lagres med et endelig antall siffer i totallsystemet. De fleste desimaltall kan ikke skrives eksakt slik, på samme måte som en tredel ikke kan skrives eksakt som et desimaltall. Derfor er hvert flyttall en tilnærming, og små avrundingsfeil hoper seg opp gjennom en utregning.

Det gir et resultat som overrasker alle første gang:

print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)
print(abs(0.1 + 0.2 - 0.3) < 1e-10)

Utskrift:

0.30000000000000004
False
True

Summen er ikke 0,30{,}3, men 0,30{,}3 pluss omtrent 510175\cdot 10^{-17}. Det er en helt ubetydelig forskjell fysisk sett — og en helt avgjørende forskjell for ==, som krever at hvert eneste siffer stemmer.

Flyttall og likhetstegnet `==`

To flyttall som matematisk er like, er sjelden nøyaktig like i Python. == sammenligner bit for bit og gir False allerede ved et avvik i siste siffer. Derfor er == mellom to utregnede flyttall nesten alltid et feilsvar, og aldri noe du skal skrive i en test.

Det gjelder også uttrykk som matematisk er identiske: kvadratroten av 2, opphøyd i andre, blir ikke nøyaktig 2.

from math import sqrt
print(sqrt(2)**2)
print(sqrt(2)**2 == 2)
print(abs(sqrt(2)**2 - 2) < 1e-10)

Utskrift:

2.0000000000000004
False
True

Merk at == er helt greit mellom heltall: 4 == 4 er True og vil alltid være det. Regelen gjelder flyttall.

✏️Eksempel 2: to formler som er matematisk like, men ikke like i maskinen

Konsentrasjonen av et legemiddel i blodet halveres hver femte time og starter på 40 mg/l. Modellen kan skrives både som c(t)=40etln2/5c(t) = 40 e^{-t\ln 2/5} og som c(t)=400,5t/5c(t) = 40 \cdot 0{,}5^{t/5}. Er de to like i Python?


Matematisk er de identiske, siden 0,5t/5=e(t/5)ln0,5=etln2/50{,}5^{t/5} = e^{(t/5)\ln 0{,}5} = e^{-t \ln 2/5}. Vi lar funksjonen bruke den første formen og regner fasiten med den andre.

from math import exp, log

def konsentrasjon(t):
    return 40.0*exp(-log(2)*t/5.0)

fasit = 40.0*0.5**(3.0/5.0)
print(konsentrasjon(3.0))
print(fasit)
print(konsentrasjon(3.0) == fasit)
print(abs(konsentrasjon(3.0) - fasit) < 1e-10)

Utskrift:

26.390158215457888
26.390158215457884
False
True

De to tallene er like i femten siffer og skiller seg i det sekstende. Avviket er om lag 410154 \cdot 10^{-15}, altså rundt en hundretusendel av en milliarddel av et milligram per liter. Ingen måleinstrument i verden kan skille dem.

Likevel svarer == at de er forskjellige. Det er nøyaktig denne situasjonen en testfunksjon havner i hver eneste gang: du regner fasiten på én måte og koden på en annen, og de to veiene gir ørsmå avvik.

Sensorpoenget: en test som krever eksakt likhet vil melde feil på riktig kode. En test som godtar et avvik på 101010^{-10} melder feil bare når koden faktisk er gal.

`abs(expected - computed) < tol`

Standardformen for å sammenligne to flyttall. expected er verdien du mener er riktig, computed er den funksjonen faktisk gir, og abs gjør differansen positiv uansett hvilken vei avviket går. Er avviket mindre enn toleransen tol, regner vi de to som like.

Navnene expected, computed og tol er de som brukes i de offisielle løsningsforslagene, og boka bruker dem gjennomgående. Bruk dem du også — sensor leser mange besvarelser, og gjenkjennelige navn gjør koden lettere å gi poeng for.

expected = 0.3
computed = 0.1 + 0.2
tol = 1e-10
print(abs(expected - computed) < tol)

Utskrift:

True

`tol` — toleransen

Den største forskjellen du godtar mellom fasit og beregnet verdi. Toleransen skrives i eksponentform: 1e-10 betyr 101010^{-10}, og 1e-4 betyr 10410^{-4}.

Valget styres av hvor nøyaktig metoden din er, ikke av hvor nøyaktig du ønsker at den skal være:

SituasjonFornuftig tol
Eksakt formel, bare avrundingsfeil (fasit fra math)1e-10 til 1e-14
Rekke summert med mange ledd1e-8 til 1e-10
Rekke summert med få ledd, eller en grov numerisk metode1e-4 til 1e-6

To ytterpunkter er begge gale. En toleranse på 0 er det samme som == og vil feile på riktig kode. En toleranse på 1e-2 slipper gjennom kode med reelle feil. Er du i tvil på eksamen, er 1e-10 det trygge valget for eksakte formler — og skriv én setning om hvorfor du valgte en løsere toleranse hvis metoden er grov. Den setningen gir uttelling.

📝Oppgave 3
Eksamensnivå, sjanger F

Funksjonen under regner ut arealet av en sirkel.

from math import pi

def areal(r):
    return pi*r**2

a) Skriv en testfunksjon som sjekker areal for r=2r = 2, med en fasit du kan regne ut for hånd.

b) Forklar i én setning hvorfor du ikke skriver assert areal(2.0) == 12.566 i stedet.

Løkke 3 — mønsteret sensor forventer (~12 min)

Nå har du alle bitene. Det som gjenstår er å sette dem sammen i den faste rekkefølgen, og å lære den utenat. Fem linjer, alltid de samme, uansett hvilken funksjon som skal testes.

At mønsteret er så fast er gode nyheter under tidspress: du kan skrive testfunksjonen først, før du har løst resten av oppgaven, og fylle inn tallene etterpå.

📜Testfunksjon-idiomet

Slik ser en testfunksjon ut i denne boka, og slik ser den ut i de offisielle løsningsforslagene:

def test_navn():
    expected = 2.0
    computed = 1.0 + 1.0
    tol = 1e-10
    msg = f'ventet {expected}, fikk {computed}'
    assert abs(expected - computed) < tol, msg

De fem linjene, i rekkefølge:

1. def test_navn(): — navnet er test_ etterfulgt av navnet på funksjonen som testes. Ingen argumenter.
2. expected = ... — fasiten, regnet ut på en annen måte enn koden gjør det: for hånd, fra en kjent eksakt verdi, eller fra math.
3. computed = ... — resultatet fra funksjonen du tester.
4. tol = ... — toleransen, valgt etter hvor nøyaktig metoden er.
5. assert abs(expected - computed) < tol, msg — selve sjekken, med en melding som viser begge tallene.

Tre ting testfunksjonen ikke gjør: den tar ingen argumenter, den returnerer ingenting, og den skriver ingenting ut. En testfunksjon som går gjennom, er helt taus. Skriver den 'Testen er OK', er det ikke feil, men det er heller ikke det sensor ser etter — og på flere av de offisielle løsningsforslagene står funksjonen uten en eneste print.

`def test_f():` — navn og signatur

En testfunksjon heter test_ pluss navnet på det som testes: test_areal tester areal, test_exp_rekke tester exp_rekke. Den har tomme parenteser — ingen argumenter — fordi alt den trenger står inne i den selv.

Grunnen til navnekonvensjonen er dobbel: den gjør det åpenbart for sensor hva funksjonen tester, og den er den samme konvensjonen profesjonelle testverktøy bruker. I dette emnet trenger du ikke noe testverktøy — en assert inne i en funksjon er alt som kreves, og det er også alt de offisielle løsningsforslagene bruker.

Skal testen sjekke flere verdier, legger du dem inn inne i funksjonen, ikke som argumenter. Gjør du def test_areal(r, expected):, har du skrevet en helt vanlig funksjon, og oppgaven ba om en testfunksjon.

✏️Eksempel 3: testfunksjonen i full bredde, og hva som skjer når den feiler

Skriv en testfunksjon for konsentrasjon(t) fra eksempel 2. Bruk at konsentrasjonen skal være halvert, altså 20 mg/l, etter fem timer. Vis også hva testen skriver hvis den blir kalt med feil tidspunkt.


Fasiten er kjent uten regning: halveringstiden er fem timer, og startverdien er 40, så etter fem timer skal svaret være 20.

from math import exp, log

def konsentrasjon(t):
    return 40.0*exp(-log(2)*t/5.0)

def test_konsentrasjon():
    expected = 20.0
    computed = konsentrasjon(5.0)
    tol = 1e-10
    msg = f'ventet {expected}, fikk {computed}'
    assert abs(expected - computed) < tol, msg

test_konsentrasjon()
print('Testen gikk gjennom')

Utskrift:

Testen gikk gjennom

Bytter vi konsentrasjon(5.0) med konsentrasjon(4.0) — altså tester mot feil tidspunkt — er avviket stort, og testen stopper programmet. Siste linje i feilmeldingen er:

AssertionError: ventet 20.0, fikk 22.9739670999407

Det er hele poenget med f-strengen i msg: du ser med én gang både hva du ventet og hva du fikk, og kan avgjøre om det er koden eller fasiten som er gal. Uten meldingen hadde det bare stått AssertionError.

Margnotat om uttelling: de tre tingene sensor krysser av for er (1) at funksjonen er en funksjon uten argumenter, (2) at fasiten er begrunnet og ikke bare gjentar koden, og (3) at sammenligningen bruker abs og en toleranse. Punkt 2 er verdt å dvele ved: skriver du expected = 40.0*exp(-log(2)*5.0/5.0), har du bare skrevet av koden, og testen ville gått gjennom selv om formelen var feil. En test som gjentar koden tester ingenting.

📝Oppgave 4
Eksamensnivå, sjanger O

Sjanger O er oppgavetypen «hva er galt i dette programmet?». Testfunksjonen under skal sjekke at snitt regner ut gjennomsnittet riktig, men den er skrevet på tre uheldige måter. Finn feilene, si hva hver av dem fører til, og skriv den rettede testfunksjonen.

def snitt(a, b):
    return (a + b)/2

def test_snitt(a, b):
    computed = snitt(a, b)
    expected = (a + b)/2
    assert computed == expected

📝Oppgave 5
Eksamensnivå, sjanger F

En student tester en funksjon som summerer 6 ledd av en rekke og treffer den eksakte verdien med et avvik på om lag 31053 \cdot 10^{-5}.

a) Hva skjer med tol = 1e-10?

b) Hvilken toleranse er fornuftig, og hvordan begrunner du valget i én setning?

c) Hvorfor er tol = 1e-2 et dårlig valg selv om testen da går gjennom?

Løkke 4 — flere punkter i én test (~8 min)

En test som sjekker ett punkt kan være heldig. En funksjon som ganger med feil konstant kan tilfeldigvis treffe i akkurat det punktet du valgte — for eksempel gir enhver funksjon riktig svar i t=0t = 0 hvis begge sider er null der.

Derfor tester vi gjerne to eller tre punkter i samme testfunksjon. Det koster tre linjer ekstra og er noe sensor legger merke til.

Du kan skrive punktene rett etter hverandre, eller legge dem i to lister og gå gjennom dem med zip fra kap. 1.3. Den siste formen er den korteste når det er mer enn to punkter.

Fasit hentet fra `math`

Den vanligste kilden til expected er math-modulens egne funksjoner: exp, log, sin, cos, sinh, atanh og sqrt. Skal du teste din egen tilnærming til sinhx\sinh x, er sinh(0.5) fra math en uavhengig fasit, siden den er regnet ut på en helt annen måte enn din rekke.

Importen skrives alltid from math import sinh — altså modulen først. Formen import sinh from math finnes ikke i Python og er feil #10 — importsetningen skrevet i feil rekkefølge.

Den andre vanlige kilden er en verdi du kan regne eksakt for hånd: halveringstid, en pytagoreisk trekant, eller et punkt der funksjonen har en kjent verdi som 00, 11 eller π/6\pi/6.

from math import sinh
print(sinh(0.5))

Utskrift:

0.5210953054937474

✏️Eksempel 4: tre punkter i én testfunksjon

Test konsentrasjon(t) i tre punkter samtidig: ved start, etter én halveringstid og etter to. Bruk to lister og zip.


De tre fasitverdiene kan alle regnes i hodet: 40 ved start, halvparten etter fem timer, og halvparten igjen etter ti.

from math import exp, log

def konsentrasjon(t):
    return 40.0*exp(-log(2)*t/5.0)

def test_konsentrasjon():
    tider = [0.0, 5.0, 10.0]
    fasit = [40.0, 20.0, 10.0]
    tol = 1e-10
    for t, expected in zip(tider, fasit):
        computed = konsentrasjon(t)
        msg = f't = {t}: ventet {expected}, fikk {computed}'
        assert abs(expected - computed) < tol, msg

test_konsentrasjon()
print('Alle tre punktene stemmer')

Utskrift:

Alle tre punktene stemmer

Tre detaljer som gir uttelling. zip parer de to listene element for element og stopper ved den korteste, så listene må være like lange — er fasit kortere, testes de siste tidene aldri i stillhet. Meldingen inneholder t, slik at du ser hvilket punkt som feilet. Og assert står inne i løkka, så den første feilen stopper programmet.

Merk at punktet t=0t = 0 alene er en svak test: der er exp(0) lik 1 og svaret 40 uansett hva som står i eksponenten. Det er nettopp derfor vi tar med to punkter til.

📝Oppgave 6
Eksamensnivå, sjanger F

Funksjonen hypotenus(a, b) skal regne ut lengden av hypotenusen i en rettvinklet trekant.

a) Skriv funksjonen.

b) Skriv én testfunksjon som sjekker den mot tre pytagoreiske tripler, altså tre trekanter der alle sidene er hele tall.

c) Hvorfor er slike tripler en spesielt god fasit her?

📝Oppgave 7
Eksamensnivå, sjanger F

En bakteriekultur vokser etter modellen B(t)=B0ertB(t) = B_0 e^{rt}, der B0B_0 er startbestanden og rr er veksttakten per time. Funksjonen er skrevet slik:

from math import exp

def bestand(t, B0, r):
    return B0*exp(r*t)

a) Skriv en testfunksjon som sjekker bestanden i to punkter: ett der du kjenner svaret uten å regne, og ett der du henter fasiten fra math.

b) Begge sjekkene ville gått gjennom selv om exp(r*t) var byttet ut med exp(r*t*t). Forklar hvorfor, og si hva det lærer deg om valg av testpunkter.

API- og konstruksjonsliste

Begrepsbank

Begrepsbanken er flashcard-/repetisjonsstoff — den gjentar det du nettopp har lest. Hopp trygt over ved førstegangslesing; tidsanslaget for kapitlet gjelder kjernestoffet.

Testfunksjon

En funksjon uten argumenter som sjekker at en annen funksjon gir riktig svar. Den regner ut en fasit på en uavhengig måte, henter det beregnede svaret fra funksjonen, og bruker assert med en toleranse. Den returnerer ingenting og skriver ingenting ut — den enten tier eller stopper programmet.

Forventet og beregnet verdi
expected er fasiten: verdien du på forhånd vet er riktig, funnet ved håndregning, fra en kjent eksakt verdi eller fra math. computed er det funksjonen din faktisk returnerer. Kommer de to fra samme utregning, tester ikke testen noe som helst.
Absolutt feil

Tallet abs(expected - computed) — hvor langt det beregnede svaret ligger fra fasiten, uten hensyn til fortegn. Dette er størrelsen testen sammenligner med toleransen. Uten abs slipper ethvert negativt avvik gjennom, uansett hvor stort det er.

Hva en testfunksjon ikke gjør

Den tar ikke argumenter, den returnerer ikke noe, og den skriver ikke ut noe når alt er i orden. Testverdiene velges inne i funksjonen. En «testfunksjon» med parametere er bare en vanlig funksjon, og oppgaven er ikke besvart.

Feilmeldingen `msg`

Teksten etter kommaet i assert. Skrives som f-streng med begge tallene, for eksempel f'ventet {expected}, fikk {computed}'. Uten den står det bare AssertionError når testen feiler, og du vet ikke om det er koden eller fasiten som er gal.

Flere punkter i én testfunksjon

Legg testverdiene i lister og gå gjennom dem med zip, eller skriv assert-linjene rett etter hverandre. Begge deler gir full uttelling. Minst to punkter anbefales, og minst ett av dem skal være et punkt der ingen faktor i uttrykket blir null eller én.

Hvorfor toleransen aldri er null

Fordi flyttall er tilnærminger. To matematisk like uttrykk regnet langs ulike veier gir avvik i siste siffer, typisk av størrelsen 101610^{-16}. En toleranse på 0 er det samme som == og vil melde feil på helt riktig kode.

Testfunksjonen som del-b på eksamen

På den avsluttende eksamenen står «skriv en testfunksjon» nesten alltid som andre deloppgave på en oppgave i sjanger F, altså rekke- og sumoppgaven. Den kan besvares selv om du ikke fikk til del-a: skriv testen for funksjonen slik den skulle ha vært, så gir den full uttelling for seg selv.

Repetisjonsoppgaver
Din fremgang
0 / 3 oppgaver

Dette kapitlet er skrevet av Anthropics toppmodeller (Claude Opus og Claude Fable) og er foreløpig ikke manuelt gjennomgått — kvalitetskontrollen gjøres av uavhengige KI-agenter, og innmeldte feil rettes fortløpende. Funnet en feil? Meld fra, så retter vi den. Les mer om hvordan innholdet lages.

Skolesaga er en uavhengig læringsressurs og er ikke tilknyttet eller godkjent av Universitetet i Oslo. Dette er ikke offisielt studiemateriell. Les mer.