CI cho dự án Node.js với GitHub Actions

1. Tổng quan

Mỗi lần ai đó push code hay mở pull request, bạn cần biết ngay code có còn build được và test có còn pass không. Việc này phải tự động, không phụ thuộc vào máy của từng người. Bài này hướng dẫn bạn dựng một pipeline CI cho dự án Node.js bằng GitHub Actions. Đọc xong, bạn sẽ có một workflow cài dependency có cache, chạy lint, chạy test trên nhiều phiên bản Node, xuất báo cáo coverage và tự hủy các lần chạy đã lỗi thời.

2. Chuẩn bị dự án mẫu

Chúng ta dùng một module nhỏ tính tổng tiền đơn hàng. Đầu tiên là package.json:

{
  "name": "order-service",
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "lint": "eslint src test",
    "test": "node --test",
    "test:coverage": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info"
  }
}

Dự án dùng test runner có sẵn của Node (node:test), nên không cần cài Jest hay Mocha. "type": "module" cho phép dùng cú pháp import/export. Script test:coverage dùng hai reporter cùng lúc. Một reporter in kết quả ra console. Reporter còn lại ghi file lcov.info để lưu lại về sau.

Logic nghiệp vụ nằm trong src/order.js:

export function calculateTotal(items, discountPercent = 0) {
  if (discountPercent < 0 || discountPercent > 100) {
    throw new RangeError('discountPercent phải nằm trong khoảng 0–100');
  }
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  return Math.round(subtotal * (100 - discountPercent)) / 100;
}

Hàm này cộng price * quantity của từng món, rồi trừ phần giảm giá. Nếu phần trăm giảm giá nằm ngoài khoảng 0–100, hàm ném RangeError.

Test tương ứng đặt trong test/order.test.js:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { calculateTotal } from '../src/order.js';

test('tính tổng đơn hàng có giảm giá', () => {
  const items = [
    { price: 100000, quantity: 2 },
    { price: 50000, quantity: 1 },
  ];
  assert.equal(calculateTotal(items, 10), 225000);
});

test('ném lỗi khi giảm giá không hợp lệ', () => {
  assert.throws(() => calculateTotal([], 120), RangeError);
});

Lệnh node --test tự tìm các file có đuôi .test.js. Chạy thử ở máy local:

npm install --save-dev eslint @eslint/js
npm test
✔ tính tổng đơn hàng có giảm giá (0.9ms)
✔ ném lỗi khi giảm giá không hợp lệ (0.3ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0

ESLint 9 dùng flat config. Bạn tạo file eslint.config.js ở thư mục gốc:

import js from '@eslint/js';

export default [js.configs.recommended];

Cuối cùng, hãy commit cả file package-lock.json. Như bạn sẽ thấy ở phần sau, CI cần file này.

3. Workflow CI đầu tiên

GitHub Actions đọc các file YAML trong thư mục .github/workflows/. Bạn tạo file .github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci
      - run: npm test

Từng phần của file làm việc sau:

  • on: workflow chạy khi có push lên main và khi có pull request vào bất kỳ nhánh nào.
  • runs-on: ubuntu-latest: job chạy trên một máy ảo Linux mới tinh, do GitHub cung cấp.
  • actions/checkout@v4: clone source code vào máy ảo.
  • actions/setup-node@v4: cài Node 22. Tùy chọn cache: npm lưu thư mục cache của npm, dùng hash của package-lock.json làm key. Ở những lần chạy sau, các package sẽ được lấy từ cache thay vì tải lại từ registry.
  • npm ci: cài dependency đúng y như trong lockfile.
  • npm test: chạy test. Nếu lệnh trả về exit code khác 0, job sẽ fail và pull request bị đánh dấu đỏ.

Sau khi push file này, bạn vào tab Actions của repository để xem log. Ở lần chạy thứ hai, bước setup-node sẽ in ra dòng tương tự:

Cache restored successfully
Cache restored from key: node-cache-Linux-x64-npm-3f9a1c...

Lưu ý: Đừng dùng npm install trong CI. npm install có thể cập nhật lockfile và cài phiên bản khác với máy bạn. npm ci thì xóa node_modules, cài chính xác theo lockfile, và báo lỗi ngay nếu package.json với package-lock.json không khớp nhau. Chính nhờ hành vi này mà bạn phát hiện sớm việc ai đó quên commit lockfile.

4. Chạy test trên nhiều phiên bản Node

Thư viện hoặc service của bạn có thể phải chạy trên nhiều phiên bản Node. Strategy matrix giúp bạn nhân bản job cho từng phiên bản:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [22, 24]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test

GitHub sẽ tạo hai job song song, tên là test (22) và test (24). Biểu thức ${{ matrix.node-version }} được thay bằng giá trị tương ứng trong từng job.

Mặc định fail-fast là true: chỉ cần một job fail, các job còn lại bị hủy ngay. Đặt fail-fast: false cho bạn thấy đầy đủ bức tranh. Ví dụ, bạn biết được lỗi chỉ xảy ra trên Node 24, còn Node 22 vẫn pass.

Bạn có thể mở rộng matrix sang nhiều hệ điều hành:

    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node-version: [22, 24]
    runs-on: ${{ matrix.os }}

Cấu hình này tạo ra 2 × 2 = 4 job.

Lưu ý: Mỗi chiều bạn thêm vào matrix sẽ nhân số job lên. Với một service chỉ deploy lên Linux và Node 24, matrix lớn chỉ làm tốn phút chạy CI mà không đem lại lợi ích. Hãy dùng matrix khi bạn thật sự hỗ trợ nhiều môi trường, ví dụ khi bạn phát hành một package npm.

Nếu dự án chỉ dùng một phiên bản Node, hãy để CI và máy dev cùng đọc từ một nguồn. Bạn tạo file .nvmrc chứa 24, rồi khai báo:

      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm

5. Tách job lint và phụ thuộc giữa các job

Lint nhanh hơn test nhiều, và không cần chạy lại trên từng phiên bản Node. Vì vậy, nên tách lint thành một job riêng và cho job test chờ lint pass:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint

  test:
    needs: lint
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [22, 24]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test

needs: lint khiến job test chỉ bắt đầu khi lint thành công. Giả sử có người để lại một biến không dùng tới:

const unusedTax = 0.1;

Khi đó job lint fail với thông báo:

/home/runner/work/order-service/src/order.js
  1:7  error  'unusedTax' is assigned a value but never used  no-unused-vars

✖ 1 problem (1 error, 0 warnings)

Job test sẽ không chạy, nên bạn tiết kiệm được thời gian của cả matrix.

Bạn cần cân nhắc đánh đổi ở đây. Dùng needs làm tổng thời gian dài hơn khi mọi thứ đều pass, vì hai job chạy tuần tự. Nếu bạn ưu tiên tốc độ phản hồi, hãy bỏ needs để lint và test chạy song song.

6. Báo cáo coverage và lưu artifact

Mọi file sinh ra trên máy ảo đều mất khi job kết thúc. Để giữ lại báo cáo coverage, bạn dùng actions/upload-artifact:

  coverage:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run test:coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: lcov.info
          retention-days: 7

Script test:coverage in bảng coverage ngay trong log của job:

ℹ start of coverage report
ℹ file           | line % | branch % | funcs %
ℹ src/order.js   | 100.00 |   100.00 |  100.00
ℹ all files      | 100.00 |   100.00 |  100.00
ℹ end of coverage report

Điều kiện if: always() đảm bảo artifact vẫn được upload kể cả khi test fail. Đây chính là lúc bạn cần báo cáo nhất. File lcov.info xuất hiện ở cuối trang tóm tắt của workflow run, và sẽ bị xóa sau 7 ngày.

Nếu muốn CI fail khi coverage xuống thấp, từ Node 22.8 bạn có thể thêm cờ ngưỡng vào script:

node --test --experimental-test-coverage --test-coverage-lines=80

Lệnh này trả về exit code 1 khi tỉ lệ dòng được cover dưới 80%.

7. Tối ưu workflow

Ba thiết lập dưới đây nên có trong hầu hết các workflow CI. Bạn đặt chúng ở cấp cao nhất của file:

name: CI

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'
  pull_request:
    paths-ignore:
      - '**.md'
      - 'docs/**'

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Ý nghĩa của từng thiết lập:

  • paths-ignore: commit chỉ sửa README hay tài liệu sẽ không kích hoạt CI.
  • permissions: contents: read: thu hẹp quyền của GITHUB_TOKEN xuống mức chỉ đọc. Nếu một dependency độc hại chạy script trong CI, nó cũng không thể push code hay sửa release.
  • concurrency: khi bạn push liên tiếp ba commit lên cùng một pull request, chỉ lần chạy mới nhất được giữ lại. Các lần chạy cũ hơn bị hủy, vì kết quả của chúng không còn giá trị.

Ngoài ra, bạn nên thêm timeout-minutes cho từng job:

  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10

Mặc định một job có thể chạy tới 6 giờ. Chỉ một test bị treo, ví dụ do quên đóng kết nối database, cũng có thể đốt hết quota phút của bạn.

Lưu ý: paths-ignore có một cạm bẫy khi kết hợp với branch protection. Giả sử bạn bắt buộc check test phải pass mới được merge. Một pull request chỉ sửa file .md sẽ không chạy workflow, nên check đó mãi ở trạng thái “Expected — Waiting”. Lúc này bạn có hai lựa chọn: bỏ paths-ignore, hoặc bỏ yêu cầu bắt buộc cho check đó.

8. Lỗi hay gặp

Thiếu lockfile. Nếu package-lock.json không được commit, hoặc bị liệt kê trong .gitignore, bước setup-node sẽ báo lỗi:

Error: Dependencies lock file is not found in /home/runner/work/order-service/order-service.
Supported file patterns: package-lock.json,npm-shrinkwrap.json,yarn.lock

Cách sửa là commit lockfile. Nếu dự án dùng pnpm hoặc yarn, hãy đổi giá trị cache cho đúng trình quản lý package.

Test chạy ở chế độ watch. Một số công cụ, như Vitest hay Jest khi được cấu hình watch, sẽ chờ thay đổi file mãi mãi. GitHub Actions tự đặt biến môi trường CI=true, và phần lớn công cụ dựa vào biến này để tắt watch. Dù vậy, bạn nên gọi lệnh chạy một lần một cách tường minh, ví dụ vitest run.

Secrets không có trong pull request từ fork. Với sự kiện pull_request từ một repository fork, các secrets sẽ không được truyền vào workflow. Đây là cơ chế bảo mật có chủ đích. Test cần API key thật sẽ fail trong trường hợp này. Hãy mock các dịch vụ bên ngoài trong unit test. Đừng chuyển sang pull_request_target để “chữa cháy”, vì sự kiện này chạy với quyền đầy đủ trên code chưa được review.

Khác biệt hệ điều hành. Test pass trên macOS nhưng fail trên Ubuntu thường do phân biệt chữ hoa, chữ thường trong đường dẫn. Ví dụ, import './Order.js' trong khi file thật tên là order.js. Hệ thống file của Linux phân biệt chữ hoa, chữ thường, nên CI phát hiện được lỗi này trước khi nó lên production.

Khi nào không cần toàn bộ pipeline này. Với một script nội bộ chỉ vài chục dòng, một job duy nhất npm ci && npm test là đủ. Bạn chỉ nên thêm matrix, coverage và job tách rời khi dự án lớn dần, hoặc khi có nhiều người cùng đóng góp.

9. Kết luận

Trong bài này, bạn đã dựng một pipeline CI hoàn chỉnh cho dự án Node.js với GitHub Actions. Pipeline dùng npm ci cùng cache của setup-node để cài dependency nhanh và ổn định. Strategy matrix giúp kiểm tra trên nhiều phiên bản Node. Job lint được tách riêng và liên kết với job test bằng needs. Báo cáo coverage được lưu lại bằng artifact. Cuối cùng, workflow được tối ưu bằng concurrency, permissions và timeout-minutes.

Nguyên tắc cốt lõi là giữ CI nhanh và đáng tin cậy. Một pipeline chạy mười lăm phút, hay thỉnh thoảng fail ngẫu nhiên, sẽ khiến cả nhóm dần bỏ qua kết quả của nó. Hãy bắt đầu với workflow tối giản ở mục 3, rồi bổ sung từng phần khi dự án thực sự cần.

github-actionsnodejscitestingdevops