DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    CI/CD PipelinesLesson 5 of 12
    Your progress · 0%
    On this page
    1. Fastest checks first
    2. Linting: the cheapest check
    3. Unit tests and coverage
    4. Integration tests: the real thing
    5. Required status checks: the gate
    ↖ Course roadmap
    Lesson 5 · Testing in pipelines

    Testing in pipelines: lint, unit, integration and the gate

    The checks that decide what may merge: a linter, fast unit tests with a coverage threshold, integration tests against the real app and a database, and required status checks.

    40 min read · intermediate · hands-on

    By the end of this lesson you'll be able to

    0 of 6 complete

    Fastest checks first

    A pipeline's job is to say no as early and cheaply as possible. Order checks by how fast they fail:

    Lint and unit tests in parallel, integration only after both pass. All three are required, so red can't merge.© Diagram: DevOps Academy

    Lint and unit tests in parallel, integration only after both pass. All three are required, so red can't merge.© Diagram: DevOps Academy
    CheckTakesCatches
    Lintsecondstypos, undefined names, unused code, risky patterns
    Unit testssecondswrong logic in a function
    Integration testsminutesparts that don't work together: HTTP, database, config
    End-to-end testsmany minutesa real user flow through the whole system

    Many fast unit tests, fewer integration tests, a handful of end-to-end tests: the test pyramid. Upside down, the pipeline gets slow and flaky.

    Linting: the cheapest check

    A linter reads code without running it. For JavaScript that's ESLint:

    $ npm install --save-dev eslint@9 @eslint/js@9 globals
    
    // eslint.config.js
    import js from '@eslint/js'
    import globals from 'globals'
    
    export default [
      js.configs.recommended,
      { languageOptions: { globals: globals.node } },
    ]
    

    On a file with a typo in a variable name, it fails with exactly what's wrong:

    $ npx eslint .
    src/bad.js
      3:26  error  'sum' is assigned a value but never used  no-unused-vars
      4:10  error  'totl' is not defined                     no-undef
    
    ✖ 2 problems (2 errors, 0 warnings)
    $ echo $?
    1
    
    npx eslint .
    • .this folder
    echo $?
    • echoprint text

    That bug (return totl instead of sum) would have crashed the first request in production. Every language has its linter: Ruff for Python, golangci-lint for Go, ShellCheck for shell, hadolint for Dockerfiles, actionlint for workflows (lesson 2). Run formatters (Prettier, Black) in check mode too, so style never shows up in review.

    Unit tests and coverage

    Unit tests call one function or module directly and check the result: fast, precise, no network. Coverage measures which lines your tests executed. A threshold makes the build fail if it drops:

    $ node --test --experimental-test-coverage --test-coverage-lines=80 'test/*.test.js'
    ℹ Error: 69.23% line coverage does not meet threshold of 80%.
    ℹ start of coverage report
    ℹ  app.js   |  69.23 |    87.50 |  100.00 | 12-19
    

    The last column lists uncovered lines: 12 to 19, the /price route. One more unit test covers it:

    test('GET /price answers with the total, or 400 for a bad amount', async () => {
      const server = createApp().listen(0)
      const { port } = server.address()
      const ok = await fetch(`http://localhost:${port}/price?amount=100`)
      assert.deepEqual(await ok.json(), { total: 118 })
      const bad = await fetch(`http://localhost:${port}/price?amount=-1`)
      assert.equal(bad.status, 400)
      server.close()
    })
    
    $ npm run test:coverage
    ℹ pass 4
    ℹ fail 0
    ℹ  app.js   |  96.15 |    90.91 |  100.00 | 19
    

    Coverage says what ran, not whether it was checked properly: a test with no assertions still counts. Use the threshold to stop coverage sliding down, and code review to judge the tests themselves.

    Integration tests: the real thing

    Integration tests run the app the way production does and talk to it from outside. This one starts the real server as a separate process and waits for /health before testing:

    // test/integration/api.test.js
    import { test, before, after } from 'node:test'
    import assert from 'node:assert/strict'
    import { spawn } from 'node:child_process'
    
    const PORT = 4567
    const base = `http://localhost:${PORT}`
    let server
    
    before(async () => {
      server = spawn(process.execPath, ['src/server.js'], { env: { ...process.env, PORT: String(PORT) } })
      for (let i = 0; i < 50; i++) {
        try {
          if ((await fetch(`${base}/health`)).ok) return
        } catch {
          // not listening yet: wait and retry
        }
        await new Promise((r) => setTimeout(r, 100))
      }
      throw new Error('server did not start within 5 seconds')
    })
    
    after(() => server.kill())
    
    test('GET /price returns the total with tax', async () => {
      const res = await fetch(`${base}/price?amount=100`)
      assert.equal(res.status, 200)
      assert.deepEqual(await res.json(), { total: 118 })
    })
    
    test('unknown paths return 404', async () => {
      assert.equal((await fetch(`${base}/nope`)).status, 404)
    })
    
    $ node --test 'test/integration/*.test.js'
    ✔ GET /price returns the total with tax (310.217185ms)
    ✔ GET /price rejects a bad amount with 400 (31.433313ms)
    ✔ unknown paths return 404 (15.854698ms)
    

    Note the quotes and the pattern: node --test test/integration/ (a folder) finds nothing and fails. Keep unit and integration tests in separate scripts so each job runs only its own:

    $ npm pkg set scripts.test="node --test 'test/*.test.js'" \
        scripts.test:integration="node --test 'test/integration/*.test.js'" \
        scripts.test:coverage="node --test --experimental-test-coverage --test-coverage-lines=80 'test/*.test.js'"
    

    Service containers: a real database in CI

    When the app needs a database, don't mock it in integration tests: start a real one next to the job with a service container:

    integration:
      needs: [lint, unit]
      runs-on: ubuntu-latest
      services:
        postgres:
          image: postgres:17
          env:
            POSTGRES_PASSWORD: test
          ports: ['5432:5432']
          options: >-
            --health-cmd "pg_isready -U postgres"
            --health-interval 5s
            --health-timeout 5s
            --health-retries 10
      env:
        DATABASE_URL: postgres://postgres:test@localhost:5432/postgres
      steps:
        - uses: actions/checkout@v7
        - uses: actions/setup-node@v7
          with:
            node-version: 22
            cache: npm
        - run: npm ci
        - run: npm run test:integration
    

    The health check makes GitHub wait until Postgres accepts connections before the first step runs. The database exists only for this job, so every run starts empty and tests can't depend on leftovers. The password is a throwaway for a container that lives a few minutes, which is why it can sit in the file.

    Required status checks: the gate

    Checks only protect main if they're required. In Settings → Rules → Rulesets, edit the rule for the default branch, enable Require status checks to pass, and add each job by name: lint, unit, integration. Turn on Require branches to be up to date too, so a pull request is tested against the current main, not the one it started from.

    Now a pull request with a failing test shows Merging is blocked, and the merge button stays disabled until every required check is green. Jobs skipped because a needs failed count as not passed.

    Flaky tests are bugs

    A test that fails one run in ten and passes on re-run trains the whole team to ignore red, and then a real failure gets merged. Common causes: timing (sleep instead of waiting for a condition, like the health loop above), tests sharing state or depending on order, real time and time zones, and the network. Fix the cause; if you can't today, quarantine the test with a linked issue, never a silent retry.

    ⌘
    Mini mission

    Put a real gate in front of main

    Give shop-api a full test pipeline: ESLint, unit tests with an 80% coverage threshold, and integration tests that start the real server, in that order of cost. Make all three required on main, then prove a pull request with a failing test can't be merged.

    Input: The shop-api repository from the earlier lessonsOutput: A blocked pull request, then a green one that merges
    Reveal one safe solution
    $ npm install --save-dev eslint@9 @eslint/js@9 globals
    $ nano eslint.config.js                          # the config from this lesson
    $ mkdir -p test/integration && nano test/integration/api.test.js
    $ npm pkg set scripts.lint="eslint ." \
        scripts.test="node --test 'test/*.test.js'" \
        scripts.test:integration="node --test 'test/integration/*.test.js'" \
        scripts.test:coverage="node --test --experimental-test-coverage --test-coverage-lines=80 'test/*.test.js'"
    $ npm run lint && npm run test:coverage && npm run test:integration
    $ nano .github/workflows/ci.yml                  # lint and unit in parallel, integration after both
    $ actionlint && git add . && git commit -m "ci: lint, unit with coverage, integration" && git push
    $ git switch -c test/gate && sed -i 's/118)/119)/' test/app.test.js
    $ git commit -am "test: break a test on purpose" && git push -u origin test/gate && gh pr create --fill
    $ gh pr checks
    
    mkdir -p test/integration && nano test/integration/api.test.js
    • mkdirmake a directory
    • -pcreate missing parents, no error if it exists
    • &&run the next command only if this one worked
    actionlint && git add . && git commit -m "ci: lint, unit with coverage, integration" && git push
    • gitversion control
    • addstage changes
    • .this folder
    • commitsave staged changes
    • pushupload commits

    After adding the three jobs as required checks, the pull request shows unit failing and Merging is blocked; integration is skipped because it needs unit. Revert the change, push, and the merge button turns green.

    Try it yourself

    0 of 6 steps done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 5

    Why run the linter before the integration tests?

    Go deeper

    Official reading for this lesson

    • DocsESLint: getting startedESLint ↗
    • DocsNode.js test runnerNode.js ↗
    • DocsGitHub Docs: about service containersGitHub Docs ↗
    • DocsGitHub Docs: about status checksGitHub Docs ↗
    • ArticleMartin Fowler: the practical test pyramidmartinfowler.com ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousSecrets, environments and OIDC: deploy without leaking keysNext lesson →Build and publish: images you can trust and find again