En test er en spesifikasjon som en maskin kan sjekke. Skrevet før koden, beskriver den det du vil ha. Skrevet etter, beskriver den ofte bare det koden tilfeldigvis gjør.
Dette er del 3 av 4 i serien Koding med AI. Del 1 handler om arbeidsflyten, del 2 om kodegjennomgang og del 4 om feilsøking. Her er et lite prosjekt i Python med fem tester. Alt er kjørt, og utskriftene på sida er de faktiske.
Anthropics veiledning for Claude Code starter med dette rådet: gi Claude en sjekk den kan kjøre. Uten den stopper den når arbeidet ser ferdig ut, og da er det du som må finne hver feil. Med en test som sier bestått eller feilet, kan den jobbe, kjøre testen, lese svaret og prøve igjen til det stemmer. For feil anbefaler dokumentasjonen å skrive en test som feiler først, og så rette koden.
Den samme veiledningen har et råd til som er lett å lese forbi: la noen andre sjekke enn den som gjorde jobben. Den foreslår at én økt skriver testene og en annen skriver koden. Grunnen ser du lenger ned.
Eksempelet regner ut snittfart per kilometer, fordi det er lite nok til å lese og har en klassisk felle i seg. Spesifikasjonen er fire korte regler på norsk, og den er skrevet av et menneske.
# Snittfart per kilometer Konstruert eksempel til gribben.no/tech/losninger/test-forst-med-ai.html. Dette er det mennesket skriver først. Testene i `test_tempo.py` er de samme fire reglene, skrevet slik at en maskin kan sjekke dem. 1. `tempo(km, tid)` gir snittfarta per kilometer som tekst på formen `m:ss`. 2. Tida skrives `mm:ss` eller `t:mm:ss`. 3. Sekundene rundes til nærmeste hele sekund. Runder de opp til 60, blir det et nytt minutt. Svaret skal aldri inneholde `:60`. 4. En distanse på null eller mindre er en feil (`ValueError`), ikke et svar. Testene er låst. Endrer du dem, må du ha en grunn du kan skrive ned, og låsen må lages på nytt av et menneske: `python laas.py lag`.
"""Testene for tempo(). Skrevet før koden, fra spesifikasjon.md.
Kjør fra denne mappa: python -m unittest -v
Hver test har navn etter regelen den sjekker, slik at en feilmelding sier
hva som er brutt, ikke bare hvor.
"""
import unittest
from tempo import tempo
class TestTempo(unittest.TestCase):
def test_hele_minutter(self):
self.assertEqual(tempo(5, "25:00"), "5:00")
def test_runder_til_naermeste_sekund(self):
self.assertEqual(tempo(10, "47:03"), "4:42")
def test_timer_i_tida(self):
self.assertEqual(tempo(10, "1:02:30"), "6:15")
def test_sekunder_runder_opp_til_nytt_minutt(self):
# 14:59 på 3 km er 4:59,67 per km. Det skal bli 5:00, ikke 4:60.
self.assertEqual(tempo(3, "14:59"), "5:00")
def test_null_distanse_gir_feil(self):
with self.assertRaises(ValueError):
tempo(0, "10:00")
if __name__ == "__main__":
unittest.main()
Så får AI-en oppgaven, med regelen om testene først:
Les spesifikasjon.md og test_tempo.py. Skriv tempo.py slik at alle testene består. Regler: - Du får ikke endre testfilene. Mener du at en test er feil, stopp og si hvorfor. - Kjør python -m unittest -v etter hver endring, og vis meg utskriften. - Ikke skriv kode som kjenner igjen testverdiene. Koden skal følge spesifikasjonen. - Når alt er grønt: vis diffen, og si hvilke deler av spesifikasjonen testene ikke dekker.
Når koden er ferdig, ser det slik ut:
$ python -m unittest -v test_hele_minutter (test_tempo.TestTempo.test_hele_minutter) ... ok test_null_distanse_gir_feil (test_tempo.TestTempo.test_null_distanse_gir_feil) ... ok test_runder_til_naermeste_sekund (test_tempo.TestTempo.test_runder_til_naermeste_sekund) ... ok test_sekunder_runder_opp_til_nytt_minutt (test_tempo.TestTempo.test_sekunder_runder_opp_til_nytt_minutt) ... ok test_timer_i_tida (test_tempo.TestTempo.test_timer_i_tida) ... ok ---------------------------------------------------------------------- Ran 5 tests in 0.002s OK
Et konstruert første utkast, med to feil som er lette å gjøre, besto tre av fem. Det runder sekundene
etter at minuttene er tatt ut, så 14:59 på 3 km blir «4:60». Og null distanse gir en divisjonsfeil i
stedet for den feilen spesifikasjonen ber om. Utkastet ligger i mappa som utkast_tempo.py.
En agent som har fått beskjed om å få testene grønne, har to veier. Den kan rette koden, eller den kan rette testen. Den andre er to linjer:
- self.assertEqual(tempo(3, "14:59"), "5:00") + self.assertEqual(tempo(3, "14:59"), "4:60") - with self.assertRaises(ValueError): + with self.assertRaises(ZeroDivisionError):
For deg er «4:60» åpenbart tull. For testen er det bare en tekst som stemmer med en annen tekst. Skriptet
demo_juks.py kjører begge variantene i en midlertidig mappe, sammenligner testfila med låsen,
og mater hooken med konstruert JSON slik Claude Code sender den:
$ python demo_juks.py ============================================================ 1. Første utkast mot de låste testene ============================================================ test_hele_minutter (test_tempo.TestTempo.test_hele_minutter) ... ok test_null_distanse_gir_feil (test_tempo.TestTempo.test_null_distanse_gir_feil) ... ERROR test_runder_til_naermeste_sekund (test_tempo.TestTempo.test_runder_til_naermeste_sekund) ... ok test_sekunder_runder_opp_til_nytt_minutt (test_tempo.TestTempo.test_sekunder_runder_opp_til_nytt_minutt) ... FAIL test_timer_i_tida (test_tempo.TestTempo.test_timer_i_tida) ... ok Ran 5 tests in 0.001s FAILED (failures=1, errors=1) ============================================================ 2. Samme utkast, etter at testene er «rettet» ============================================================ test_hele_minutter (test_tempo.TestTempo.test_hele_minutter) ... ok test_null_distanse_gir_feil (test_tempo.TestTempo.test_null_distanse_gir_feil) ... ok test_runder_til_naermeste_sekund (test_tempo.TestTempo.test_runder_til_naermeste_sekund) ... ok test_sekunder_runder_opp_til_nytt_minutt (test_tempo.TestTempo.test_sekunder_runder_opp_til_nytt_minutt) ... ok test_timer_i_tida (test_tempo.TestTempo.test_timer_i_tida) ... ok Ran 5 tests in 0.000s OK ============================================================ 3. Fingeravtrykket sammenlignet med testfila i 2 ============================================================ FEIL: test_tempo.py er endret siden testene ble låst Testene er låst. Endringer i dem må godkjennes av et menneske. ============================================================ 4. Hooken, matet med det Claude Code ville sendt ============================================================ Edit /prosjekt/test_tempo.py exit 2 Write C:\prosjekt\Tests\test_ny.py exit 2 Edit /prosjekt/tester.laas exit 2 Bash python laas.py lag exit 2 Edit /prosjekt/tempo.py exit 0 Meldingen Claude får tilbake ved exit 2, her fra første forsøk: Blokkert: test_tempo.py er låst. Rett koden, ikke testen. Mener du at testen er feil, si det til brukeren og stopp.
Det som ser ut som suksess i punkt 2, er det ikke. Fem av fem består, og koden er akkurat like feil som i punkt 1. Det er derfor Anthropic foreslår at den som gjør jobben, ikke er den som vurderer den. Demoen viser at det er mulig, ikke hvor ofte det skjer. Et tall for hvor ofte, finnes ikke i noen kilde denne sida kan stå inne for, så det står ikke her.
Punkt 4 viser hva hooken gjør med fem konstruerte forsøk. Testfila, en ny test i en mappe som heter
Tests, låsfila og kommandoen som lager låsen på nytt, stoppes alle med kode 2. Endringen i
tempo.py slipper gjennom, for det er den koden agenten skal rette.
Fra svakest til sterkest. Bruk flere, for ingen av dem dekker alt alene.
Linja i prompten over, eller i CLAUDE.md. Den virker som regel, men den er et råd, ikke en sperre. Hvorfor det skillet betyr så mye, står i del 1.
I innstillingene kan du skrive at testfilene og låsfila ikke får redigeres, for eksempel "deny": ["Edit(**/test_*.py)", "Edit(**/tester.laas)"]. Avslag gjelder i alle moduser. Dokumentasjonen sier at avslag for Edit gjelder Claudes egne filverktøy, filkommandoer Claude Code kjenner igjen i skallet, som sed og tee, og målet for omdirigering med >. Den sier også rett ut hva de ikke stopper: et Python-skript som åpner fila selv. Til det trengs sandkassa, som sperrer på operativsystemnivå.
Vil du se at regelen virker, be Claude kjøre sed -i 's/5:00/4:60/' test_tempo.py. Blir den avvist, virker den. Det har ikke denne sida prøvd i en ekte økt.
Et skript som kjøres før hver redigering og hver skallkommando. Treffer den en testfil eller låsfila, avslutter den med kode 2, og Claude får tilbake meldinga «Rett koden, ikke testen». Kode 2 må det være: med kode 1 regner Claude Code det som en feil som ikke blokkerer, og går videre. Det er punkt 4 i utskriften over. Hooken er kjørt med konstruert inndata, ikke koblet inn i en ekte økt her.
laas.py lagrer et fingeravtrykk av hver testfil i tester.laas og feiler hvis en av dem er endret. Kjør den i CI, så fanger den også en endring som gikk rundt avslagene og hooken.
Men låsen er bare så sterk som den som kan endre tester.laas. Fila ligger i samme repo som testene. En agent med tilgang til skallet kan skrive et lite skript som lager låsen på nytt, og committe den sammen med den endrede testen. Da går CI grønt. Det som faktisk holder, er at endringer i testene og i tester.laas må godkjennes av en bestemt person før de kommer inn, for eksempel med CODEOWNERS og kravet om godkjenning fra kodeeier i GitHub. Uten det fanger låsen uhell og snarveier, ikke en som er bestemt på å gå rundt.
Avslagene og hooken stopper alle økter, også den som skal skrive testene. Derfor ligger de ikke i
.claude/settings.json, men i en egen fil. Lagre eksempelet under som
.claude/vern-tester.json, og start økta som skriver koden med
claude --settings .claude/vern-tester.json. Økta som skriver testene, starter uten. Det er
det skillet Anthropic anbefaler: én økt skriver testene, en annen koden.
Filrettigheter er et femte lag: gjør testfila skrivebeskyttet med chmod a-w på Mac og Linux
eller attrib +R på Windows. Det er en fartsdump, ikke en mur, for en agent med tilgang til
skallet kan endre rettighetene tilbake. Og uansett lag: se etter testfiler i git diff --stat
før du committer. Står de der, vil du vite hvorfor.
Legg hooken i .claude/hooks/ og innstillingene i .claude/vern-tester.json. Syntaksen er sjekket mot dokumentasjonen 9. oktober 2026. På Windows heter Python-kommandoen ofte py, ikke python. Da må kommandoen i innstillingene endres. Det er ikke prøvd her.
{
"permissions": {
"deny": [
"Edit(**/test_*.py)",
"Edit(tests/**)",
"Edit(**/tester.laas)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|Bash",
"hooks": [
{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/hook_vern_tester.py"
}
]
}
]
}
}
"""PreToolUse-hook for Claude Code: stopper endringer i testene og i låsfila.
Claude Code sender en JSON på standard inn før verktøyet kjøres. Avslutter
skriptet med kode 2, blokkeres kallet, og teksten på standard feil går
tilbake til Claude som forklaring. Kode 1 ville IKKE stoppet noe:
dokumentasjonen sier at Claude Code regner alt annet enn 2 som en feil som
ikke blokkerer, og går videre. Slik står det i code.claude.com/docs/en/hooks,
lest 9. oktober 2026.
Legg fila i .claude/hooks/ og registrer den i en egen innstillingsfil, se
eksempel-settings.json i samme mappe. Den egne fila er et valg: økta som
skal skrive testene, må kunne skrive dem, og starter derfor uten den.
Hva den dekker:
- Edit og Write mot test_*.py, alt under en mappe som heter tests
(uansett store og små bokstaver), og låsfila tester.laas
- Bash-kommandoer som nevner tester.laas eller kjører «laas.py lag»
Hva den ikke dekker: Bash-sjekken er ren tekstsammenligning, og et skript
agenten skriver og kjører selv, går rett forbi. Den er en fartsdump mot
snarveien, ikke en mur. Det som holder, er at endringer i testene og i
tester.laas må godkjennes av et menneske før de kommer inn, se
test-forst-med-ai.html.
"""
import json
import pathlib
import sys
sys.stderr.reconfigure(encoding="utf-8")
data = json.load(sys.stdin)
verktoy = data.get("tool_name", "")
inn = data.get("tool_input") or {}
def er_vernet(sti: str) -> bool:
# Windows sender stien med omvendt skråstrek. Normaliser før sammenligning,
# og se bort fra store og små bokstaver, ellers slipper Tests\ forbi.
sti = sti.replace("\\", "/").lower()
navn = pathlib.PurePosixPath(sti).name
deler = pathlib.PurePosixPath(sti).parts[:-1]
return ((navn.startswith("test_") and navn.endswith(".py"))
or navn == "tester.laas"
or "tests" in deler)
def stopp(grunn: str) -> None:
print(f"Blokkert: {grunn} Rett koden, ikke testen. "
"Mener du at testen er feil, si det til brukeren og stopp.", file=sys.stderr)
sys.exit(2)
if verktoy in ("Edit", "Write"):
sti = inn.get("file_path", "")
if er_vernet(sti):
navn = sti.replace("\\", "/").rsplit("/", 1)[-1]
stopp(f"{navn} er låst.")
if verktoy == "Bash":
kommando = inn.get("command", "").lower()
if "tester.laas" in kommando or ("laas.py" in kommando and " lag" in kommando):
stopp("Låsen på testene lages av et menneske, ikke av deg.")
sys.exit(0)
"""Låser testfilene med et fingeravtrykk (SHA-256).
python laas.py lag [mappe] skriver tester.laas, gjøres av et menneske
python laas.py sjekk [mappe] feiler (exit 1) hvis en testfil er endret
Mappa er der testene ligger. Uten mappe brukes mappa laas.py ligger i.
Alle test_*.py under mappa tas med, også i undermapper, og tester.laas
skrives i mappa.
Hvorfor et fingeravtrykk og ikke bare en instruks: en agent som skal få
testene grønne, har to veier. Den kan rette koden, eller den kan rette
testen. En setning i en prompt ber den la være. Denne sjekken merker det
hvis den ikke gjorde det.
Hva den ikke beskytter mot: tester.laas ligger i samme repo som testene.
Den som kan endre testene, kan også kjøre «laas.py lag» og committe en ny
lås sammen med den endrede testen, og da går sjekken grønn. Låsen er bare
så sterk som kravet om at et menneske godkjenner endringer i tester.laas,
for eksempel med CODEOWNERS og grenbeskyttelse i GitHub. Uten det fanger
den uhell og snarveier, ikke en som er bestemt på å gå rundt.
Linjeskift normaliseres før hashing. Ellers ville git på Windows, som
gjerne bytter LF til CRLF, se ut som en endring av testene.
"""
import hashlib
import pathlib
import sys
MAPPE = pathlib.Path(__file__).parent
def fingeravtrykk(fil: pathlib.Path) -> str:
innhold = fil.read_bytes().replace(b"\r\n", b"\n")
return hashlib.sha256(innhold).hexdigest()
def testfiler(mappe: pathlib.Path) -> dict:
# Relativ sti med skråstrek, så låsfila blir lik på Windows og Linux.
return {f.relative_to(mappe).as_posix(): f for f in sorted(mappe.rglob("test_*.py"))}
def lag(mappe: pathlib.Path = MAPPE) -> None:
filer = testfiler(mappe)
linjer = [f"{fingeravtrykk(f)} {navn}" for navn, f in filer.items()]
(mappe / "tester.laas").write_text("\n".join(linjer) + "\n", encoding="utf-8", newline="\n")
print(f"Låste {len(linjer)} testfil(er) i tester.laas")
def sjekk(mappe: pathlib.Path = MAPPE, laasfil: pathlib.Path | None = None) -> int:
laasfil = laasfil or mappe / "tester.laas"
if not laasfil.exists():
print(f"FEIL: finner ikke {laasfil}. Lag låsen først: python laas.py lag")
return 1
laast = {}
for linje in laasfil.read_text(encoding="utf-8").splitlines():
if linje.strip():
hash_, navn = linje.split(maxsplit=1)
laast[navn] = hash_
naa = {navn: fingeravtrykk(f) for navn, f in testfiler(mappe).items()}
feil = []
for navn, hash_ in laast.items():
if navn not in naa:
feil.append(f"{navn} er slettet")
elif naa[navn] != hash_:
feil.append(f"{navn} er endret siden testene ble låst")
for navn in sorted(naa.keys() - laast.keys()):
feil.append(f"{navn} er ny og ikke låst")
if feil:
for f in feil:
print("FEIL:", f)
print("Testene er låst. Endringer i dem må godkjennes av et menneske.")
return 1
print(f"OK: {len(laast)} testfil(er) uendret")
return 0
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
kommando = sys.argv[1] if len(sys.argv) > 1 else ""
mappe = pathlib.Path(sys.argv[2]) if len(sys.argv) > 2 else MAPPE
if kommando == "lag":
lag(mappe)
elif kommando == "sjekk":
sys.exit(sjekk(mappe))
else:
print(__doc__)
sys.exit(2)
Alle filene ligger i tech/losninger/kode/test_forst/: spesifikasjon.md, test_tempo.py, tempo.py, utkast_tempo.py, laas.py, hook_vern_tester.py, demo_juks.py, eksempel-settings.json, variant_bare_null.py og vis_hullet.py. Bare standardbiblioteket, kjørt med Python 3.13.
Testene sjekker det du skrev, ikke det du mente. Les spesifikasjonen og testene over en gang til, så ser
du et hull som står der med vilje. Regel 4 sier null eller mindre. Testen sjekker bare null. En variant av
koden som bare sjekker for null, variant_bare_null.py, består alle fem testene og svarer
«-5:00» for minus 5 km:
$ python vis_hullet.py Testene mot varianten: Ran 5 tests · OK tempo(-5, "25:00") = '-5:00'
Det er den siste linja i prompten som fanger sånt: hvilke deler av spesifikasjonen dekker ikke testene? Svaret er en liste du leser selv. Noen av punktene blir nye tester. Andre er ting du bestemmer at ikke betyr noe. Begge deler er beslutninger, og de er dine.
Er du leder, står det du bør spørre om, samlet i del 2. For testene er det ett spørsmål: kan den som skriver koden, også endre testene uten at noen andre ser det?
Oppdatert oktober 2026.
Claude Code-dokumentasjonen, lest 8. oktober 2026: Best practices («give Claude a way to verify its work», skriv en test som feiler først, én økt skriver tester og en annen koden, Stop-hook som port), Permissions (avslagsregler for Edit, hva de dekker og ikke dekker, stimønstre; lest på nytt 9. oktober 2026), Hooks reference (kode 2 blokkerer, kode 1 gjør det ikke, tool_input.file_path; lest 9. oktober 2026), CLI reference (--settings; lest 9. oktober 2026), Permission modes (avslag gjelder i alle moduser) og Hooks (PreToolUse, kode 2 stopper redigeringen, eksempelet med beskyttede filer). Funksjoner og syntaks endrer seg, så sjekk dokumentasjonen før du kopierer innstillingene.
GitHub Docs, About code owners (godkjenning fra kodeeier før sammenslåing), lest 9. oktober 2026.
Alt i eksempelet er konstruert. Utskriftene er kjørt med Python 3.13 på Windows 9. oktober 2026; bare tidene i «Ran 5 tests» blir annerledes hos deg.