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:

LayerWhat it checksExternal dependenciesTypical duration
UnitSingle functions and modulesNone (mocked)Seconds
IntegrationYour code against a real databasePostgreSQLTens of seconds
End-to-endThe running app through a browserFull app + databaseMinutes

Create the directories:

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:

npm install --save-dev jest jest-junit
npm install pg

Open package.json:

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:

{
  "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:

nano jest.config.js

Add the following configuration:

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:

nano tests/unit/price.test.js
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:

npm run test:unit

When the tests pass and coverage is above the threshold, the summary looks like this:

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:

nano compose.test.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:

nano tests/integration/db.test.js
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:

docker compose -f compose.test.yaml up -d --wait

Run the integration tests, then remove the container and its data:

DATABASE_URL=postgres://app:[email protected]:5432/app_test npm run test:integration
docker compose -f compose.test.yaml down -v
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:

npm init playwright@latest

Install the browsers and their system libraries on Ubuntu:

npx playwright install --with-deps chromium

Open the generated configuration:

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/:

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:

nano e2e/home.spec.js
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:

DATABASE_URL=postgres://app:[email protected]:5432/app_test npm run test:e2e
Running 1 test using 1 worker
  1 passed (2.1s)

Add the generated output directories to .gitignore so reports are never committed:

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:

mkdir -p .github/workflows
nano .github/workflows/tests.yml
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:

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.

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.