--- title: "How to Run Automated Tests in a GitHub Actions CI Pipeline" description: "Set up unit, integration and end-to-end tests for a Node.js app in GitHub Actions, enforce a coverage threshold and block merges when any test stage fails." category: "CI/CD DevOps" language: en source: https://cubepath.com/docs/ci-cd-devops/automated-testing-in-ci-cd-pipelines updated: 2026-09-25 --- # How to Run Automated Tests in a GitHub Actions CI Pipeline Automated tests only protect you if they run on every change and a failure actually stops the merge. In this tutorial you will organize a Node.js project into three test layers (unit, integration and end-to-end), run each one locally, and then wire them into a GitHub Actions workflow that runs them in order, publishes the reports and blocks pull requests that fail. The same structure works with any language: only the test commands change. ## Prerequisites To follow this tutorial you need: - A development machine or server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user with `sudo` privileges. - Node.js 22 LTS and npm installed. - Docker Engine with the Compose plugin, used to start PostgreSQL for the integration tests. - A GitHub repository for your project, with a committed `package-lock.json` (the workflow uses `npm ci`). - An app that listens on an HTTP port. The examples assume a small web app started with `npm start` on port `3000` that uses PostgreSQL. ## Step 1 - Organizing the test layers Each layer answers a different question and has a different cost, so keep them in separate directories and run them with separate commands: | Layer | What it checks | External dependencies | Typical duration | |-------|----------------|-----------------------|------------------| | Unit | Single functions and modules | None (mocked) | Seconds | | Integration | Your code against a real database | PostgreSQL | Tens of seconds | | End-to-end | The running app through a browser | Full app + database | Minutes | Create the directories: ```bash mkdir -p tests/unit tests/integration e2e ``` Install Jest for unit and integration tests, `jest-junit` to produce reports that CI tools can read, and the `pg` client used by the integration tests: ```bash npm install --save-dev jest jest-junit npm install pg ``` Open `package.json`: ```bash nano package.json ``` Add one script per layer to the `scripts` section. The positional argument after `jest` is a path pattern, so each script only picks up the tests in its own directory: ```json { "scripts": { "start": "node src/server.js", "test:unit": "jest tests/unit --coverage", "test:integration": "jest tests/integration --runInBand", "test:e2e": "playwright test" } } ``` `--runInBand` runs the integration test files one after another so they do not fight over the same database tables. ## Step 2 - Enforcing a coverage threshold on unit tests A coverage number that nobody checks drifts downward. Jest can fail the run when coverage drops below a threshold, which turns coverage into a real quality gate. Create the Jest configuration file: ```bash nano jest.config.js ``` Add the following configuration: ```javascript module.exports = { testEnvironment: 'node', collectCoverageFrom: ['src/**/*.js'], coverageReporters: ['text-summary', 'lcov', 'json-summary'], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80, }, }, reporters: ['default', 'jest-junit'], }; ``` The threshold is only evaluated when coverage is collected, which is why only the `test:unit` script passes `--coverage`. The `jest-junit` reporter writes `junit.xml` in the project root; you will point it at a per-layer directory in CI. Add a small unit test so there is something to run. This example assumes `src/price.js` exports a `totalWithTax(amount, rate)` function: ```bash nano tests/unit/price.test.js ``` ```javascript const { totalWithTax } = require('../../src/price'); test('adds tax to the amount', () => { expect(totalWithTax(100, 0.21)).toBeCloseTo(121); }); test('rejects negative amounts', () => { expect(() => totalWithTax(-1, 0.21)).toThrow(); }); ``` Run the unit tests: ```bash npm run test:unit ``` When the tests pass and coverage is above the threshold, the summary looks like this: ```output PASS tests/unit/price.test.js ✓ adds tax to the amount (2 ms) ✓ rejects negative amounts (1 ms) =============================== Coverage summary =============================== Statements : 91.3% ( 21/23 ) Branches : 83.33% ( 5/6 ) Functions : 100% ( 4/4 ) Lines : 91.3% ( 21/23 ) ================================================================================ ``` If coverage falls below 80%, Jest prints a message like `Jest: "global" coverage threshold for lines (80%) not met: 72.5%` and exits with a non-zero code, which is exactly what makes the CI job fail. ## Step 3 - Running integration tests against PostgreSQL Integration tests are only useful against the real dependency, so start a disposable PostgreSQL container instead of mocking the database. Create a Compose file used only for tests: ```bash nano compose.test.yaml ``` ```yaml services: postgres: image: postgres:16 environment: POSTGRES_USER: app POSTGRES_PASSWORD: test_password POSTGRES_DB: app_test ports: - "127.0.0.1:5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U app -d app_test"] interval: 5s timeout: 5s retries: 10 ``` The port is bound to `127.0.0.1` so the test database is never exposed on the server's public interface. The password is only for this throwaway container. Write an integration test that reads the connection string from `DATABASE_URL`, the same variable your app uses: ```bash nano tests/integration/db.test.js ``` ```javascript const { Client } = require('pg'); let client; beforeAll(async () => { client = new Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); await client.query('CREATE TABLE IF NOT EXISTS orders (id serial PRIMARY KEY, total numeric NOT NULL)'); }); afterAll(async () => { await client.query('DROP TABLE IF EXISTS orders'); await client.end(); }); test('stores and reads an order', async () => { await client.query('INSERT INTO orders (total) VALUES ($1)', [121]); const { rows } = await client.query('SELECT total FROM orders'); expect(Number(rows[0].total)).toBe(121); }); ``` Start PostgreSQL. The `--wait` flag makes Compose return only once the health check passes: ```bash docker compose -f compose.test.yaml up -d --wait ``` Run the integration tests, then remove the container and its data: ```bash DATABASE_URL=postgres://app:test_password@127.0.0.1:5432/app_test npm run test:integration docker compose -f compose.test.yaml down -v ``` ```output PASS tests/integration/db.test.js ✓ stores and reads an order (14 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total ``` ## Step 4 - Adding end-to-end tests with Playwright End-to-end tests drive a real browser against the running app and catch problems that unit tests cannot, such as broken routing or a missing environment variable. Initialize Playwright. The installer asks a few questions: choose JavaScript or TypeScript, use `e2e` as the tests folder and answer **No** to adding a GitHub Actions workflow, since you will write your own: ```bash npm init playwright@latest ``` Install the browsers and their system libraries on Ubuntu: ```bash npx playwright install --with-deps chromium ``` Open the generated configuration: ```bash nano playwright.config.js ``` Make sure it contains at least the following settings. The `webServer` block starts your app before the tests and stops it afterwards, and the HTML reporter writes to `playwright-report/`: ```javascript const { defineConfig, devices } = require('@playwright/test'); module.exports = defineConfig({ testDir: './e2e', retries: process.env.CI ? 2 : 0, reporter: [['html', { open: 'never' }], ['junit', { outputFile: 'reports/e2e/junit.xml' }]], use: { baseURL: 'http://127.0.0.1:3000', trace: 'on-first-retry', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, ], webServer: { command: 'npm start', url: 'http://127.0.0.1:3000', reuseExistingServer: !process.env.CI, }, }); ``` Replace the example test that Playwright generated with one that checks your own app: ```bash nano e2e/home.spec.js ``` ```javascript const { test, expect } = require('@playwright/test'); test('home page loads', async ({ page }) => { await page.goto('/'); await expect(page).toHaveTitle(/My App/); }); ``` With the test database from Step 3 running, run the end-to-end tests: ```bash DATABASE_URL=postgres://app:test_password@127.0.0.1:5432/app_test npm run test:e2e ``` ```output Running 1 test using 1 worker 1 passed (2.1s) ``` Add the generated output directories to `.gitignore` so reports are never committed: ```bash printf '%s\n' coverage/ reports/ junit.xml playwright-report/ test-results/ >> .gitignore ``` ## Step 5 - Creating the GitHub Actions workflow Now run the three layers in CI. The unit tests run first because they are the fastest; the integration and end-to-end jobs only start when the unit job succeeds (`needs`), so a broken build fails in seconds instead of minutes. Create the workflow file: ```bash mkdir -p .github/workflows nano .github/workflows/tests.yml ``` ```yaml name: tests on: push: branches: [main] pull_request: permissions: contents: read concurrency: group: tests-${{ github.ref }} cancel-in-progress: true jobs: unit: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run test:unit env: JEST_JUNIT_OUTPUT_DIR: reports/unit - uses: actions/upload-artifact@v4 if: always() with: name: unit-reports path: | reports/unit coverage retention-days: 14 integration: needs: unit runs-on: ubuntu-24.04 services: postgres: image: postgres:16 env: POSTGRES_USER: app POSTGRES_PASSWORD: test_password POSTGRES_DB: app_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U app -d app_test" --health-interval 5s --health-timeout 5s --health-retries 10 env: DATABASE_URL: postgres://app:test_password@localhost:5432/app_test steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run test:integration env: JEST_JUNIT_OUTPUT_DIR: reports/integration - uses: actions/upload-artifact@v4 if: always() with: name: integration-reports path: reports/integration retention-days: 14 e2e: needs: unit runs-on: ubuntu-24.04 services: postgres: image: postgres:16 env: POSTGRES_USER: app POSTGRES_PASSWORD: test_password POSTGRES_DB: app_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U app -d app_test" --health-interval 5s --health-timeout 5s --health-retries 10 env: DATABASE_URL: postgres://app:test_password@localhost:5432/app_test steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npx playwright install --with-deps chromium - run: npm run test:e2e - uses: actions/upload-artifact@v4 if: always() with: name: e2e-reports path: | playwright-report reports/e2e retention-days: 14 ``` A few details matter here: - **Service containers** give each job its own PostgreSQL, which GitHub starts before the steps and waits for using the `--health-cmd` options. The database is gone when the job ends, so tests never share state between runs. - **`if: always()`** on the upload steps keeps the reports when tests fail, which is when you need them most. - **`concurrency`** cancels the previous run on the same branch when you push again, so you do not wait for results nobody will read. - The `CI` environment variable is set automatically on GitHub-hosted runners, so Playwright enables retries and never reuses a local server. Commit and push the changes on a new branch: ```bash git checkout -b add-ci-tests git add . git commit -m "Run unit, integration and e2e tests in CI" git push -u origin add-ci-tests ``` Open a pull request for the branch. On the **Checks** tab you will see the `unit` job run first and then `integration` and `e2e` in parallel. When a run finishes, the reports are listed under **Artifacts** on the workflow run summary page. ## Step 6 - Blocking merges when tests fail A red check that can still be merged is only a suggestion. Make the three jobs required so GitHub refuses to merge a pull request until they all pass. In your repository on GitHub: 1. Go to **Settings > Rules > Rulesets** and click **New ruleset > New branch ruleset**. 2. Give it a name such as `main protection`, set **Enforcement status** to **Active** and add `main` (or **Include default branch**) as the target. 3. Enable **Require a pull request before merging**. 4. Enable **Require status checks to pass**, click **Add checks** and add `unit`, `integration` and `e2e`. 5. Save the ruleset. The check names only appear in the search box after the workflow has run at least once, which is why you pushed the branch first. To verify the gate, push a commit that breaks a unit test, for example by changing the expected value in `tests/unit/price.test.js`. The `unit` job fails, `integration` and `e2e` are skipped, and the pull request shows **Merging is blocked** until you fix the test. > Tip: If you also use a static analysis service such as SonarQube or CodeQL, add its check to the same ruleset. The workflow stays the same: every gate becomes one required status check. ## Troubleshooting **`ECONNREFUSED 127.0.0.1:5432` in the integration job.** The job's steps run directly on the runner, so they reach the service container through the mapped port on `localhost`. Check that the `ports` mapping is present and that `DATABASE_URL` uses `localhost`, not `postgres`. The service name only resolves when the job itself runs inside a container. **`npm ci` fails with "The `npm ci` command can only install with an existing package-lock.json".** Run `npm install` locally, commit `package-lock.json` and push again. **Playwright fails with "Timed out waiting 60000ms from config.webServer".** The app did not answer on the configured URL. Make sure `npm start` listens on port `3000` and that it has every environment variable it needs in the job's `env` block. **Coverage passes locally but fails in CI.** Local runs may include files that CI does not, or the other way round. Keep `collectCoverageFrom` explicit and run `npm run test:unit` from a clean clone to reproduce the CI result. ## Conclusion Your project now runs unit tests with an enforced coverage threshold, integration tests against a throwaway PostgreSQL and end-to-end tests in a real browser, and GitHub will not merge a pull request unless all three pass. From here you can publish the test results as annotations on the pull request, add a deploy job that `needs` all three test jobs, or run the end-to-end suite against a staging environment after each deployment.