3.1 Testfunksjoner og assert
Skrive testfunksjoner med `assert abs(expected - computed) < tol` — kravet som følger nesten hver kodeskriving-oppgave.
I IN1900 er det to eksamener. Midtveiseksamen i oktober er en digital flervalgseksamen som teller 25 prosent, og den avsluttende eksamenen i desember er fire timer, teller 75 prosent, og består for det meste av fritekst-koding: du skriver Python-kode rett inn i et tekstfelt, uten hjelpemidler og uten å kunne kjøre den.
På den avsluttende eksamenen ser bildet slik ut:
- I 7 av de 8 nyere settene (88 prosent) er «skriv en testfunksjon» et selvstendig delkrav som gir poeng for seg selv.
- Den står nesten alltid som del-b — altså andre deloppgave — på en oppgave i sjanger F. Sjanger F er bokas kode for oppgaven «skriv en funksjon som regner ut en sum eller en rekke, og skriv en testfunksjon til den». Selve rekkedelen tar vi i kap. 3.2; testdelen tar vi her.
- Sjanger F er i hvert eneste sett (100 prosent) og er verdt 4–6 poeng.
Prioriteten er derfor den høyeste boka opererer med. Boka bruker tre nivåer: må sitte (nivå 1), kunne (nivå 2) og kjenne til (nivå 3). Testfunksjoner er nivå 1, og de er samtidig det billigste nivå 1-stoffet som finnes: mønsteret er fem linjer langt og endrer seg aldri.
En ting til, om delpoeng: sensor gir poeng for deler av en løsning. Klarer du ikke rekken i del-a, kan du fortsatt få full uttelling på testfunksjonen i del-b — du skriver bare testen for funksjonen slik den skulle ha vært. De offisielle løsningsforslagene er tydelige på at deloppgaver kan løses uavhengig.
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.
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 gyldigSamme 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ålingerNavnet 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.
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: 19Den 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 dyrLegg 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.
(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')(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
TrueSummen er ikke , men pluss omtrent . Det er en helt ubetydelig forskjell fysisk sett — og en helt avgjørende forskjell for ==, som krever at hvert eneste siffer stemmer.
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
TrueMerk at == er helt greit mellom heltall: 4 == 4 er True og vil alltid være det. Regelen gjelder flyttall.
Dette er feil #11 i feilkatalogen — å bruke == på flyttall i en testfunksjon i stedet for abs(...) < tol. Feilkatalogen er bokas nummererte register over de feilene som går igjen; hver feil har et nummer du kan slå opp.
Feilen er lumsk fordi den ofte virker på små eksempler. Regner du ut som flyttall, får du nøyaktig , og == gir True. Så snart det er en kvadratrot, en eksponentialfunksjon eller en sum av mange ledd inne i bildet, slår den til.
På eksamen er den lett å oppdage for sensor og koster hele testpoenget, fordi den viser at kandidaten ikke har forstått hvorfor testen skrives med toleranse. De offisielle løsningsforslagene skriver alltid assert abs(expected - computed) < tol.
To tilleggsfeller i samme familie:
- Å glemme abs. expected - computed < tol er sant for et hvilket som helst negativt avvik, uansett hvor stort. Testen slipper da gjennom kode som er grovt feil.
- Å snu ulikheten. abs(expected - computed) > tol tester at svaret er galt.
Konsentrasjonen av et legemiddel i blodet halveres hver femte time og starter på 40 mg/l. Modellen kan skrives både som og som . Er de to like i Python?
Matematisk er de identiske, siden . 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
TrueDe to tallene er like i femten siffer og skiller seg i det sekstende. Avviket er om lag , 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å melder feil bare når koden faktisk er gal.
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:
TrueDen største forskjellen du godtar mellom fasit og beregnet verdi. Toleransen skrives i eksponentform: 1e-10 betyr , og 1e-4 betyr .
Valget styres av hvor nøyaktig metoden din er, ikke av hvor nøyaktig du ønsker at den skal være:
| Situasjon | Fornuftig tol |
|---|---|
Eksakt formel, bare avrundingsfeil (fasit fra math) | 1e-10 til 1e-14 |
| Rekke summert med mange ledd | 1e-8 til 1e-10 |
| Rekke summert med få ledd, eller en grov numerisk metode | 1e-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.Funksjonen under regner ut arealet av en sirkel.
from math import pi
def areal(r):
return pi*r**2a) Skriv en testfunksjon som sjekker areal for , 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å.
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, msgDe 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.
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.
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 gjennomBytter 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.9739670999407Det 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.
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 == expectedEn student tester en funksjon som summerer 6 ledd av en rekke og treffer den eksakte verdien med et avvik på om lag .
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 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.
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 , 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 , eller .
from math import sinh
print(sinh(0.5))Utskrift:
0.5210953054937474Test 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 stemmerTre 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 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.
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?
En bakteriekultur vokser etter modellen , der er startbestanden og 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.
Fem feil, i den rekkefølgen de oftest dukker opp i besvarelser:
1. #11 — == på flyttall. Skriv alltid assert abs(expected - computed) < tol. Dette er den ene feilen som koster hele testpoenget.
2. Glemt abs. expected - computed < tol er sant for alle negative avvik, uansett størrelse.
3. Testfunksjonen tar argumenter eller returnerer noe. Den skal kunne kalles som test_f() og skal verken returnere eller skrive ut.
4. Fasiten gjentar koden. Er expected det samme uttrykket som funksjonen bruker, tester du ingenting. Hent fasiten fra håndregning, fra en kjent eksakt verdi, eller fra math.
5. For stram toleranse for en grov metode. En rekke med få ledd treffer ikke på ti desimaler. Velg toleransen etter metoden, og skriv én kommentar om hvorfor.
Og en huskeregel som er verdt et poeng: når oppgaven sier «skriv en testfunksjon», er det funksjonen som skal skrives. Løse assert-linjer utenfor en funksjon er ikke en testfunksjon.
Begrepsbank
Begrepsbanken er flashcard-/repetisjonsstoff — den gjentar det du nettopp har lest. Hopp trygt over ved førstegangslesing; tidsanslaget for kapitlet gjelder kjernestoffet.
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.
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.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.
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.
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.
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.
Fordi flyttall er tilnærminger. To matematisk like uttrykk regnet langs ulike veier gir avvik i siste siffer, typisk av størrelsen . En toleranse på 0 er det samme som == og vil melde feil på helt riktig kode.
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.
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.