Bliver dine Node-tests kun kørt, når du “lige husker det”?

Bliver dine Node-tests kun kørt, når du “lige husker det”?

• Du har et npm test-script, men kører det ikke hver gang du pusher.
• Din kollega siger “den kører fint hos mig”, men CI’en er tavs.
• Du har én Node-version lokalt og en helt anden på produktionsserveren.
• Dit første forsøg med GitHub Actions var et YAML-kaos, du slettede igen.

Hvis du nikkede til mindst et punkt, så er det her for dig.

Hvorfor din CI skal føles som at køre en enkelt kommando lokalt

Jeg starter altid med den her regel: alt det, din CI gør, skal du kunne trigge lokalt med én kommando.

Ikke fordi det er flot arkitektur. Men fordi alt andet ender med “det virker kun i CI” eller “det virker kun hos mig”.

Forestil dig et helt almindeligt Node-projekt med en package.json der ligner noget i den her stil:

{
  "scripts": {
    "lint": "eslint .",
    "test": "jest",
    "build": "tsc -p .",
    "ci": "npm run lint && npm test && npm run build"
  }
}

Det vigtigste script her er faktisk ci. Det er din kontrakt mellem “min maskine” og “GitHub Actions”. Din workflow-fil skal i princippet bare gøre to ting:

  • checkout kode
  • køre npm run ci

Alt andet er bare logistik: hvilken Node-version, cache, hvornår workflowet skal køre osv.

Hvis du vil se det her mindset brugt i lidt større sammenhæng, har jeg tidligere skrevet om struktur og automatisering i fx andre artikler om DevOps og debugging, hvor pointen er den samme: samme handling, samme resultat, uanset om du er lokalt eller i skyen.

Den lille, stabile GitHub Actions workflow til et Node-projekt

Lad os tage den konkrete fil. Ingen deploy, ingen sikkerhedsscanning, ingen magi. Bare test, lint og build.

Opret .github/workflows/ci.yml med noget i den her stil:

name: CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repo
        uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Lint, test og build
        run: npm run ci

Det her er den minimale pipeline, der gør tre ting rigtigt:

Den bruger actions/setup-node til at styre Node-versionen, så du ikke er afhængig af hvad end GitHub tilfældigvis kører. Den bruger npm ci i stedet for npm install, så dine installs er deterministiske. Og den kører den samme kommando som lokalt: npm run ci.

Hvorfor lige Node 20?

Du kan skrive det tal om, men node-version: 20 matcher den nuværende LTS. Det er ofte et godt udgangspunkt, medmindre du har krav om at understøtte ældre versioner.

Hvis du i forvejen bruger en .nvmrc, kan du i stedet skrive:

with:
  node-version-file: '.nvmrc'

Så læser GitHub Actions bare samme fil som din lokale nvm. Det fjerner en hel kategori af “hov, jeg kører Node 18, CI kører Node 20”-fejl.

Hvornår skal workflowet køre?

I eksemplet ovenfor kører det på:

  • alle pushes til main
  • alle pull requests der peger på main

Det er nok til de fleste mindre Node-projekter. Hvis du bliver træt af at vente på det for hver eneste lille WIP-commit på din feature-branch, kan du skære det ned til kun pull_request, så det kun kører, når du faktisk åbner en PR.

npm ci vs npm install i GitHub Actions

De fleste første CI-forsøg starter med:

- name: Install dependencies
  run: npm install

Og så virker det, indtil det ikke gør.

Problemet er, at npm install i CI kan finde på at opdatere din lockfile, ændre på versionsresolution, og i det hele taget ikke er garanteret deterministisk på samme måde som npm ci.

Hvad gør npm ci anderledes?

Kort fortalt:

  • den kræver en lockfile (package-lock.json eller npm-shrinkwrap.json)
  • den sletter node_modules inden install
  • den installerer præcis de versioner, der står i lockfilen
  • den ændrer ikke din lockfil

Det er præcis det, du ønsker i CI. Ingen “nå, npm valgte lige en nyere minor-version, som tilfældigvis havde en breaking change”.

Typisk fejl: “npm ci” fejler i CI men virker lokalt

Et mønster jeg ser tit:

  1. Du kører npm install lokalt.
  2. Det opdaterer din package-lock.json.
  3. Du glemmer at committe lockfilen.
  4. CI kører npm ci og fejler, fordi lockfilen ikke matcher package.json, eller fordi den helt mangler.

Løsningen er kedelig, men effektiv: commit altid din lockfile, og lad være med at rode med dependencies uden at pushe ændringerne med.

Hvis du vil læse op på detaljerne, har npm en fin beskrivelse af forskellen i deres egen dokumentation. Men til hverdagsbrug: npm ci i CI, npm install lokalt når du aktivt ændrer dependencies.

Caching i GitHub Actions uden at skyde dig selv i foden

Caching er der, mange tutorials begynder at blive unødigt komplekse. Og jo, du kan selv bruge actions/cache og finde på egne nøgler, men du behøver det faktisk sjældent til Node-projekter.

actions/setup-node kan selv cache den mest tidskrævende del for dig: node_modules eller npm cache, afhængigt af hvad du vælger.

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm

Den her linje gør to ting:

  • Installerer den ønskede Node-version.
  • Bruger din lockfile som en del af cache-nøglen.

Det vil sige: når din package-lock.json ikke har ændret sig, genbruger Actions cachen. Når du ændrer dependencies, invalidere den automatisk. Du skal ikke selv designe en hash.

Hvad skal du ikke cache?

Der er især to ting, jeg fraråder at cache i simple CI-setups:

Byg-artifakter: ting der bliver genereret i hver build, fx dist/ fra TypeScript eller bundleren. Det er fristende “for at spare tid”, men du risikerer at ende i en tilstand, hvor du fejler at bygge, men stadig bruger en gammel cachet build. Så er du reelt stoppet med at teste dine builds.

Globale npm installs: hvis du installerer CLI-værktøjer globalt, og cacher dem, kan du få meget mærkelige version-mix problemer. Hold det i projektet som devDependencies i stedet.

Hvis du vil nørde mere i dybden med caching og pipelines i Node, er der beslægtede pointer i flere artikler i vores DevOps-hjørne, blandt andet om simple CI-mønstre til JavaScript og om at gøre builds deterministiske på tværs af miljøer.

Skal du køre tests på flere Node-versioner?

Det næste mange snubler over i GitHub Actions-dokumentationen er ordet “matrix”.

Et matrix-job betyder i praksis “kør det her job flere gange med forskellige værdier”. For Node-projekter er det typisk Node-versionen, du varierer.

Et eksempel:

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm

      - run: npm ci
      - run: npm run ci

Nu kører de samme trin to gange: én gang på Node 18, én gang på Node 20.

Hvornår giver det mening?

Jeg plejer at bruge den her tommelfingerregel:

Kører du en CLI, et bibliotek eller en pakke, hvor andre kan have ældre Node-versioner? Så giver matrix mening.

Er det “bare” en intern backend eller en frontend-build-pipeline, hvor du selv styrer Node-versionen på serveren? Så er én Node-version nok. Du får mest ro og færrest flaky fejl ved at holde det simpelt.

Typiske CI-fejl i Node-projekter (og hvad du gør ved dem)

Lad mig tage nogle af de fejl, jeg selv har brugt for længe på.

Fejl 1: Testsene hænger i CI men ikke lokalt

Symptom: GitHub Actions hænger på “npm test” indtil timeout. Lokalt er alt grønt.

Typiske årsager:

  • Du har en test, der venter på noget eksternt (API, database, port), som aldrig lukker ned.
  • Du forventer interaktiv input (fx via prompt eller readline).
  • Din test-runner kører i watch-mode, fordi et flag ikke er sat rigtigt.

Løsning: sørg for at din testkommando i package.json er non-interaktiv. Hvis du bruger Jest, så noget i retning af:

"test": "jest --runInBand --detectOpenHandles"

--detectOpenHandles kan hjælpe dig med at finde det, der holder processen åben. Kør samme kommando lokalt, som CI kører, og se outputtet igennem.

Fejl 2: Tests fejler kun i CI, fordi der mangler env-vars

Symptom: Lokalt kører du fint, men i CI får du fx “DATABASE_URL is not set”.

I GitHub Actions har du miljøvariabler via env: på job- eller step-niveau. Fx:

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    env:
      NODE_ENV: test
      DATABASE_URL: postgres://test-user:... 

Sekretere ting kan du sætte under “Settings → Secrets and variables → Actions” i GitHub repoet, og så bruge dem sådan her:

env:
  DATABASE_URL: ${{ secrets.DATABASE_URL }}

Hvis du er i tvivl om hvad forskellen er på config og secrets, har vi en hel artikel om det på Coding Class, hvor vi går igennem patterns med .env-filer og miljøer. Pointen her i CI-land er: alt, hvad du ville ligge i en lokal .env, skal specifikt tilføjes i GitHub Actions også.

Fejl 3: “Works on my machine” pga. globale CLI’er

Hvis du lokalt har installeret noget globalt, som du så bruger i scripts, vil CI’en ikke kende det.

Fx:

"build": "tsc"

Lokalt virker det, fordi du har tsc globalt. I CI fejler det. Løsningen er at have det som devDependency og kalde det via npx:

"devDependencies": {
  "typescript": "^5.4.0"
},
"scripts": {
  "build": "npx tsc -p ."
}

Nu ved både din maskine og CI præcis, hvilken version der bliver brugt.

Variant: pnpm eller yarn med Corepack

Hvis du ikke bruger npm, men fx pnpm eller yarn, kan du stadig holde det simpelt i GitHub Actions. Node har et værktøj der hedder Corepack, som følger med nyere Node-versioner. Det gør det muligt at “låse” package manager-versionen på tværs af maskiner.

Ideen er:

  • angiv hvilken package manager og version du vil bruge
  • brug Corepack til at aktivere den i CI

Eksempel med pnpm:

// package.json
{
  "packageManager": "pnpm@9.1.0",
  "scripts": {
    "ci": "pnpm lint && pnpm test && pnpm build"
  }
}

Og din workflow:

steps:
  - uses: actions/checkout@v4

  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: 'pnpm'

  - name: Enable Corepack
    run: corepack enable

  - name: Install dependencies
    run: pnpm install --frozen-lockfile

  - name: Lint, test og build
    run: pnpm ci

Samme mønster gælder for yarn. Sæt packageManager i package.json, brug corepack enable, og kør yarn install --immutable i stedet for npm ci.

Fordelen ved Corepack er, at du undgår “jeg har pnpm 8, CI har pnpm 9”-situationer, hvor lockfilen bliver læst forskelligt.

Sådan får du en Node-CI der ikke flager rødt tilfældigt

Den sidste ting jeg vil runde, er flaky CI. Altså workflows der fejler hver 4. gang uden at du har ændret noget.

Der er ikke én magisk indstilling, der fikser det, men der er nogle mønstre, jeg bevidst går efter i Node-projekter:

Gør tests deterministic

Hvis du i tests bruger “rigtige” eksterne systemer (API, database, filsystem), så overvej at mocke dem ud. Ikke for renhedens skyld, men for stabilitetens. Et netværkshiccup eller en rate limit bør ikke være grunden til, at din PR ser rød ud.

Hvis du er nødt til at køre integrationstests mod noget eksternt, så isoler dem i et separat script, fx:

"scripts": {
  "test": "jest --runInBand",
  "test:integration": "jest --runInBand --config jest.integration.config.cjs",
  "ci": "npm run lint && npm test && npm run build"
}

Så kan du vælge bevidst, om CI skal køre integrationstests, eller om de kun skal køre på en nightly workflow.

Hold din CI hurtig nok til at du gider at bruge den

Hvis din CI tager 15 minutter, begynder du automatisk at ignorere den. Så finder du først fejl, når ting er merged.

De største tidsrøvere i Node-CI er typisk:

  • store dependency-installs uden cache
  • byg af frontend-bundles med store toolchains hver gang
  • integrationstests mod langsomme services

Du har allerede løst en del ved at bruge cache: npm og npm ci. Hvis tiden stadig stikker af, så split det op i to jobs: et hurtigt “lint + unit tests”-job, og et langsommere “build + integrationstests”-job, som ikke behøver køre på hver eneste push.

Og så er der den helt lavpraktiske ting: kør samme kommandoer lokalt en gang imellem. Hvis npm run ci lokalt tager 8 minutter, hjælper ingen YAML dig.

Brug værktøjet act til at køre workflows i Docker på din maskine (fx act -j build-and-test). Vær opmærksom på at act ikke er 100% identisk med GitHub-hosted runners - især når det gælder preinstallerede værktøjer, secrets og runner-image, så brug det til hurtig iteration, men kør altid en endelig test i GitHub Actions.
Hvis du understøtter flere Node-versioner, brug en matrix i jobdefinitionen: strategy: { matrix: { node-version: [18,20] } } og referer til node-version: ${{ matrix.node-version }} i setup-node. Det giver parallelle jobs, så du opdager kompatibilitetsfejl tidligt.
Enten kør et job per pakke med en matrix over packages eller sæt working-directory i steps så npm ci og npm run ci kører i den relevante pakke. Husk at bruge npm 7+ eller pnpm/yarn workspaces korrekt, og cache per workspace eller per lockfile for at undgå forvirring mellem packages.
Caching sparer tid, men kan give stale installs hvis lockfilen ikke inkluderes i cache-nøglen. Brug npm ci for deterministiske installs og sørg for at cache-nøglen invalideres ved ændring af package-lock.json eller tilsvarende, eller slå cache fra midlertidigt når du fejlsøger afhængighedsproblemer.

Lasse Falkenberg er typen, der begyndte at rode med HTML og CSS for at lave en simpel bandside – og opdagede, at det var langt sjovere at få knapperne til at virke end at stå på scenen. Siden har han kastet sig over alt fra små JavaScript-snippets til Python-scripts, der kan spare ham for kedeligt, manuelt arbejde i hverdagen.

Han har lært det meste ved at bygge ting, der lige præcis løser hans egne problemer: en lille webapp til at holde styr på brætspilsaftener, et script til at rydde op i rodede mapper, eller en enkel side til at dele noter med venner. Undervejs har han kæmpet sig gennem alle de klassiske fejl – semikolon, forkerte indrykninger og variabler, der hedder noget helt andet end man tror – og det er præcis den rejse, han deler på Coding Class.

På Coding Class skriver Lasse praktiske, jordnære guides, der tager udgangspunkt i små, konkrete opgaver: noget du kan se, teste og bygge videre på med det samme. Han elsker at bryde en opgave ned i små bidder, vise den fulde kode og forklare linje for linje, hvad der sker – inklusive de typiske bugs, du med stor sandsynlighed også støder på.

For Lasse handler kodning ikke om flotte titler eller store ord, men om følelsen af at få noget til at virke – og om at du som læser kan gå derfra med noget, du selv har bygget. Hvis du kan kende glæden ved at få en fejl til endelig at forsvinde, er du lige på bølgelængde med hans måde at lære fra sig på.

1 kommentar

comments user
Signe

Ej ja, den én-kommando-regel giver SÅ meget ro i hovedet!

Send kommentar

You May Have Missed