Unit tests, integration tests, code coverage, quality gates — write real tests for your Node.js app, add a coverage threshold, and gate your CI pipeline on quality. Broken code never reaches production.
// src/utils.js — function under test function add(a, b) { return a + b; } function divide(a, b) { if (b === 0) throw new Error('Division by zero'); return a / b; } module.exports = { add, divide }; // tests/utils.test.js — test file const { add, divide } = require('../src/utils'); // describe() groups related tests describe('add function', () => { test('adds two positive numbers', () => { expect(add(2, 3)).toBe(5); // exact equality }); test('handles negative numbers', () => { expect(add(-1, 1)).toBe(0); }); }); describe('divide function', () => { test('divides correctly', () => { expect(divide(10, 2)).toBe(5); }); test('throws on division by zero', () => { expect(() => divide(5, 0)).toThrow('Division by zero'); }); }); // === Data-driven tests with test.each === test.each([ [2, 3, 5], [0, 0, 0], [-1, 1, 0], ])('add(%i, %i) = %i', (a, b, expected) => { expect(add(a, b)).toBe(expected); });
| .toBe(val) | Exact equality (===) |
| .toEqual(obj) | Deep equality (for objects) |
| .toBeTruthy() | Value is truthy |
| .toContain(item) | Array/string contains |
| .toThrow('msg') | Function throws error |
| .toBeNull() | Value is null |
| .toBeGreaterThan(n) | > n |
| .toHaveBeenCalled() | Mock was called |
| .not.toBe(val) | Negate any matcher |
npx jest # Run all tests npx jest --watch # Watch mode (re-run on save) npx jest --coverage # With coverage report npx jest utils.test.js # Run specific file npx jest --verbose # Detailed output npx jest -t "add function" # Run tests matching name
// === Mock a module === jest.mock('../src/db'); // Replace entire module const db = require('../src/db'); // === Mock a function's return value === db.getUser = jest.fn().mockResolvedValue({ id: 1, name: 'Alice', email: 'alice@example.com' }); test('getUserById returns correct user', async () => { const user = await db.getUser(1); expect(user.name).toBe('Alice'); expect(db.getUser).toHaveBeenCalledWith(1); // verify call expect(db.getUser).toHaveBeenCalledTimes(1); }); // === Mock a module with factory === jest.mock('axios', () => ({ get: jest.fn().mockResolvedValue({ data: { userId: 1, title: 'Test post' } }) })); // === Spy on an existing method === const consoleSpy = jest.spyOn(console, 'log') .mockImplementation(() => {}); // silence console // === Reset between tests === beforeEach(() => jest.clearAllMocks()); afterEach(() => jest.restoreAllMocks());
beforeEach(() => {}) — runs before each test. Use to reset state.afterAll(() => {}) — runs once after all tests. Use to close DB connections.
module.exports = {
// What to collect coverage from
collectCoverageFrom: [
'src/**/*.js', // all .js in src/
'!src/**/*.test.js', // exclude test files
'!src/index.js', // exclude entry point
],
// Coverage provider (v8 is faster than babel)
coverageProvider: 'v8',
// Output formats
coverageReporters: [
'text', // console table
'lcov', // HTML report (coverage/lcov-report/)
'junit', // for Jenkins test trending
'cobertura', // for GitHub Actions
],
// QUALITY GATE: fail if below these thresholds
coverageThreshold: {
global: {
lines: 70, // at least 70% lines tested
branches: 70, // at least 70% if/else branches
functions: 70, // at least 70% functions called
statements: 70, // at least 70% statements executed
},
// Per-file thresholds (stricter for critical files)
'./src/auth.js': {
lines: 90, // auth must be 90%+ tested
},
},
};
Branch coverage is the most valuable — 100% line coverage with 50% branch coverage means untested else branches.
$ npx jest --coverage PASS tests/utils.test.js add function ✓ adds two positive numbers (3ms) ✓ handles negative numbers (1ms) divide function ✓ divides correctly (1ms) ✓ throws on division by zero (2ms) ---------|---------|----------|---------|---------| File | % Stmts | % Branch | % Funcs | % Lines | ---------|---------|----------|---------|---------| utils.js | 100 | 100 | 100 | 100 | auth.js | 45 | 30 | 50 | 45 | ← UNTESTED ---------|---------|----------|---------|---------| All files| 62 | 58 | 71 | 62 | Jest: "global" coverage threshold for lines (70) not met: 62% # Pipeline exits with code 1 → CI build FAILS ✓ # This is the quality gate working correctly! # To see which lines are not covered: # open coverage/lcov-report/index.html in browser # Red lines = not covered by any test # Green lines = covered
After npx jest --coverage:
coverage/lcov-report/index.html — open in browserIn CI: upload the coverage directory as a GitHub Actions artifact or publish to Codecov/Coveralls.
name: CI with Tests on: push: branches: [ main, 'feat/**' ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20', cache: 'npm' } - run: npm ci - name: Run tests with coverage run: npm test -- --coverage # Exits 1 if coverage threshold not met ← gate - name: Upload coverage report uses: actions/upload-artifact@v4 if: always() # upload even if tests fail with: name: coverage-report path: coverage/ # Publish test summary to Actions tab - name: Test summary uses: dorny/test-reporter@v1 if: always() with: name: Jest Tests path: coverage/junit.xml reporter: jest-junit
// Jenkinsfile test stage with reporting stage('Test') { steps { sh 'npm ci && npm test -- --coverage' } post { always { // Publish JUnit XML test results junit 'coverage/junit.xml' // Publish HTML coverage report publishHTML(target: [ allowMissing: false, alwaysLinkToLastBuild: true, keepAll: true, reportDir: 'coverage/lcov-report', reportFiles: 'index.html', reportName: 'Coverage Report' ]) } failure { echo '❌ Tests failed or coverage threshold not met' } } }
npm install --save-dev jest-junitreporters: ['default', 'jest-junit']coverage/junit.xml — consumed by Jenkins junit plugin and dorny/test-reporter.
Install Jest → write 4 tests → 70% coverage threshold → watch CI fail when coverage drops → fix it
npm install --save-dev jest jest-junit. Add "test": "jest --coverage" to package.json scripts.src/utils.js with add() and divide(). This is the business logic you'll test.tests/utils.test.js with test cases for add (2 tests) and divide (2 tests, including the throw case). Run locally — all green..github/workflows/ci.yml: add test step with npm test -- --coverage. Upload coverage artifact.# === Install === npm install --save-dev jest jest-junit # === package.json scripts === # "scripts": { "test": "jest --coverage" } # === src/utils.js === mkdir -p src cat > src/utils.js << 'EOF' function add(a, b) { return a + b; } function divide(a, b) { if (b === 0) throw new Error('Division by zero'); return a / b; } function multiply(a, b) { return a * b; } function isEven(n) { return n % 2 === 0; } module.exports = { add, divide, multiply, isEven }; EOF # === tests/utils.test.js === mkdir -p tests cat > tests/utils.test.js << 'EOF' const { add, divide, multiply, isEven } = require('../src/utils'); describe('add', () => { test('adds two numbers', () => expect(add(2, 3)).toBe(5)); test('handles negatives', () => expect(add(-1, 1)).toBe(0)); }); describe('divide', () => { test('divides correctly', () => expect(divide(10, 2)).toBe(5)); test('throws on zero', () => { expect(() => divide(5, 0)).toThrow('Division by zero'); }); }); describe('multiply', () => { test('multiplies', () => expect(multiply(3, 4)).toBe(12)); }); describe('isEven', () => { test('even number', () => expect(isEven(4)).toBe(true)); test('odd number', () => expect(isEven(3)).toBe(false)); }); EOF # === Run tests locally === npm test
# === jest.config.js === cat > jest.config.js << 'EOF' module.exports = { collectCoverageFrom: ['src/**/*.js', '!src/index.js'], coverageProvider: 'v8', coverageReporters: ['text', 'lcov', 'junit', 'cobertura'], reporters: ['default', 'jest-junit'], coverageThreshold: { global: { lines: 70, branches: 70, functions: 70 } } }; EOF # === .github/workflows/test.yml === cat > .github/workflows/test.yml << 'EOF' name: Tests on: push: branches: [ main, 'feat/**' ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20', cache: 'npm' } - run: npm ci - name: Run tests with coverage run: npm test -- --coverage - name: Upload coverage uses: actions/upload-artifact@v4 if: always() with: name: coverage-\${{ github.sha }} path: coverage/ EOF # === Commit === git add . git commit -m "test: add jest unit tests with 70% coverage gate" git push origin main
function secret() { return 'untested'; }3 questions · 5 minutes · testing pyramid, branch coverage, quality gates
test: add unit tests with coverage gate ✓// === Structure === describe('group name', () => { beforeAll(() => { /* once before all */ }); afterAll(() => { /* once after all */ }); beforeEach(() => { /* before each test */ }); afterEach(() => { /* after each test */ }); test('description', () => { expect(value).toBe(expected); }); it('alternative syntax', () => { /* same as test */ }); }); // === Skip / focus === test.skip('skip this test', () => {}); test.only('only run this', () => {}); // use with caution // === Async tests === test('async with async/await', async () => { const result = await fetchUser(1); expect(result.name).toBe('Alice'); }); test('async with promise', () => { return fetchUser(1).then(user => { expect(user.name).toBe('Alice'); }); }); // === Error testing === expect(() => riskyFunction()).toThrow(); expect(() => riskyFunction()).toThrow('specific message'); await expect(asyncFunc()).rejects.toThrow('error');
// === Equality === .toBe(val) // === strict equality .toEqual(obj) // deep equality (objects/arrays) .toStrictEqual(obj) // like toEqual + checks undefined .not.toBe(val) // negation // === Truthiness === .toBeTruthy() .toBeFalsy() .toBeNull() .toBeUndefined() .toBeDefined() // === Numbers === .toBeGreaterThan(n) .toBeLessThan(n) .toBeGreaterThanOrEqual(n) .toBeCloseTo(n, digits) // for floats // === Strings === .toMatch(/regex/) .toMatch('substring') // === Arrays / Iterables === .toContain(item) .toHaveLength(n) .toEqual(expect.arrayContaining([a, b])) // === Objects === .toHaveProperty('key') .toHaveProperty('key', value) .toMatchObject({ partial: 'match' }) // === Mocks === .toHaveBeenCalled() .toHaveBeenCalledTimes(n) .toHaveBeenCalledWith(arg1, arg2) .toHaveBeenLastCalledWith(arg) .toHaveReturnedWith(value)
// === AAA Pattern: Arrange → Act → Assert === test('createUser stores and returns new user', async () => { // ARRANGE — set up inputs and mocks const input = { name: 'Alice', email: 'alice@test.com' }; db.save = jest.fn().mockResolvedValue({ id: 42, ...input }); // ACT — call the function under test const result = await createUser(input); // ASSERT — verify the outcome expect(result.id).toBe(42); expect(result.name).toBe('Alice'); expect(db.save).toHaveBeenCalledWith(input); }); // === Test edge cases, not just happy path === describe('calculateDiscount', () => { test('applies 10% for orders over £100', () => { expect(calculateDiscount(120)).toBe(12); }); test('applies no discount for orders under £100', () => { expect(calculateDiscount(80)).toBe(0); }); test('handles exactly £100 (boundary)', () => { expect(calculateDiscount(100)).toBe(10); // boundary value }); test('throws for negative amounts', () => { expect(() => calculateDiscount(-10)).toThrow(); }); test('handles zero amount', () => { expect(calculateDiscount(0)).toBe(0); }); });
Format: [function] [scenario] [expected result]
"divide(a, 0) throws Division by zero""createUser returns 400 when email is missing""test 1""it works""adds numbers" (too vague — which numbers, what expected?)SonarQube analyses code and enforces quality on multiple dimensions:
Quality Gate: configurable policy — if any metric fails, the CI pipeline fails and the PR is blocked.
- name: SonarCloud Analysis uses: SonarSource/sonarcloud-github-action@master env: GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: \${{ secrets.SONAR_TOKEN }} with: args: | -Dsonar.projectKey=my-devops-app -Dsonar.coverage.exclusions=**/*.test.js
| Day | Topic | Lab Output | Status |
|---|---|---|---|
| Day 11 | CI/CD Concepts | ci.yml live in Actions tab | ✅ |
| Day 12 | Pipeline Deep Dive | Multi-job matrix + Jenkinsfile parallel | ✅ |
| Day 13 ← TODAY | Testing in CI | Jest + 70% coverage gate in CI | ✅ |
| Day 14 | Artifacts & Docker | Docker image pushed to GHCR on merge | Tomorrow |
| Day 15 | Continuous Deployment | Staging auto + prod approval gate | Friday |
my-devops-app/ ├── src/ │ └── utils.js ← function under test ├── tests/ │ └── utils.test.js ← Jest tests ├── jest.config.js ← 70% threshold ├── .github/workflows/ │ ├── ci.yml ← Day 11 │ ├── ci-advanced.yml ← Day 12 │ └── test.yml ← Day 13 ← new └── Jenkinsfile
| Problem | Cause | Fix |
|---|---|---|
| Jest: "Cannot find module" | Wrong relative path in require() | Check path: test in tests/ → require('../src/utils') |
| Coverage threshold not met | Untested code in collectCoverageFrom | Add tests for uncovered lines (shown red in HTML report). Or lower threshold temporarily. |
| CI: "npm test not found" | No "test" script in package.json | Add "test": "jest --coverage" to scripts in package.json |
| Tests pass locally, fail in CI | Environment-specific dependency or absolute path | Use relative paths. Mock environment variables. Ensure test is not time-dependent. |
| Async test always passes | Missing async/await or return | Always await async calls or return the promise in the test. |
| Tests leak state between runs | Mocks not reset between tests | Add beforeEach(() => jest.clearAllMocks()) |
| coverage/junit.xml not found | jest-junit not installed or not in reporters | npm install --save-dev jest-junit. Add reporters: ['default', 'jest-junit'] to jest.config.js |
| Open handles warning | DB connection or timer not closed | Add afterAll(() => server.close()). Use jest --detectOpenHandles |
module.exports = {
// Files to test
testMatch: ['**/__tests__/**/*.js', '**/*.test.js'],
testPathIgnorePatterns: ['/node_modules/'],
// Coverage settings
collectCoverageFrom: ['src/**/*.js', '!src/index.js'],
coverageProvider: 'v8', // faster than babel
coverageReporters: ['text', 'lcov', 'junit'],
coverageDirectory: 'coverage',
// QUALITY GATE
coverageThreshold: {
global: { lines: 70, branches: 70, functions: 70 }
},
// For Jenkins JUnit XML output
reporters: ['default', 'jest-junit'],
// Auto-clear mocks between tests
clearMocks: true, // clear mock.calls
resetMocks: true, // reset return values
restoreMocks: true, // restore spied methods
// Timeout per test (ms)
testTimeout: 10000,
};
name: Tests on: push: branches: [ main, 'feat/**' ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20', cache: 'npm' } - run: npm ci - name: Test + coverage gate run: npm test -- --coverage - uses: actions/upload-artifact@v4 if: always() with: name: coverage path: coverage/
test: add Jest unit tests for utils moduleci: add coverage gate threshold to 70%test: add missing tests to pass coverage gate