Test først, så kode

Skriv hva koden skal gjøre som tester, og la AI-en jobbe til de er grønne. Det virker. Men en test kan bli grønn på to måter, og bare den ene er den du vil ha. Del 3 av 4 i Koding med AI.

Magnus Gribbestad 8. oktober 2026 8 min
Figur · testene låser oppgaven
Tester som går fra gult til grønt, og en lås Øverst en spesifikasjon på fire linjer. Under fem tester, alle gule. Et første utkast av koden får tre tester grønne. Et forsøk på å endre fasiten i en test fra 5:00 til 4:60 stopper mot en lås. Koden rettes i stedet, og alle fem tester blir grønne. SPESIFIKASJON Snittfart per km, skrevet som m:ss Tida skrives mm:ss eller t:mm:ss Runder opp til nytt minutt, aldri :60 Null distanse er en feil, ikke et svar hele minutter runder til sekund timer i tida 14:59 på 3 km → 5:00 0 km → feil tempo.py 5:00 → 4:60 testene er låst grønne tester 0 3 5 /5
  1. Du skriver hva koden skal gjøre, i fire korte regler på norsk.
  2. Det blir fem tester, alle gule. Ingen kode finnes ennå, så ingen av dem består.
  3. Et konstruert første utkast av koden får tre av fem grønne. To regler er brutt: minuttet ruller ikke over, og null distanse gir feil type feil.
  4. Den raskeste veien til grønt er å endre fasiten. Forsøket stopper mot en lås: testene er låst for Claudes egne verktøy.
  5. Koden rettes i stedet, og fem av fem består. Nå leser du koden. Grønne tester betyr at den gjør det du ba om, ikke at du ba om det riktige.

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.

Hvorfor testene først

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.

Fire regler, fem tester

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.

spesifikasjon.md
# 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`.
test_tempo.py, de fem testene
test_tempo.py
"""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:

Prompt · Test 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.
Den siste linja er den viktigste. Den ber om det testene ikke kan si selv.

Når koden er ferdig, ser det slik ut:

Utskrift · kjørt 9. oktober 2026
$ 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

Den raskeste veien til grønt

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:

Diff mot test_tempo.py · konstruert
-        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:

Utskrift · kjørt 9. oktober 2026
$ 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.

Fire måter å låse testene

Fra svakest til sterkest. Bruk flere, for ingen av dem dekker alt alene.

1

En regel i instruksen

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.

2

En avslagsregel i Claude Code

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.

3

En hook som sier hvorfor

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.

4

Et fingeravtrykk i CI

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.

Innstillingene, hooken og låsen i fulltekst

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.

eksempel-settings.json
{
  "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"
          }
        ]
      }
    ]
  }
}
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)
laas.py
"""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.

Grønne tester er ikke en spesifikasjon

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:

Utskrift · kjørt 9. oktober 2026
$ 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.

Grønt betyr

  • Koden gjør det testene spør om
  • Ingen av testene er endret, hvis låsen holder
  • Du kan endre koden og se med en gang om noe brekker

Grønt betyr ikke

  • At testene dekker hele spesifikasjonen
  • At spesifikasjonen er riktig
  • At koden er sikker eller lett å lese. Det er del 2

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.