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
sudoprivileges. - 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 usesnpm ci). - An app that listens on an HTTP port. The examples assume a small web app started with
npm starton port3000that 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:
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-cmdoptions. 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.concurrencycancels the previous run on the same branch when you push again, so you do not wait for results nobody will read.- The
CIenvironment 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:
- Go to Settings > Rules > Rulesets and click New ruleset > New branch ruleset.
- Give it a name such as
main protection, set Enforcement status to Active and addmain(or Include default branch) as the target. - Enable Require a pull request before merging.
- Enable Require status checks to pass, click Add checks and add
unit,integrationande2e. - 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.
TipIf 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.