Skip to main content

Express Continuous Integration

Introduction

Continuous Integration (CI) is a development practice where developers frequently merge their code changes into a central repository, after which automated builds and tests are run. For Express.js applications, implementing CI helps ensure that your application remains stable as new features are added and bugs are fixed.

In this guide, we'll explore how to set up continuous integration for your Express.js projects. We'll cover:

  • Understanding CI fundamentals for Express applications
  • Setting up CI pipelines with popular tools
  • Automating tests in your CI workflow
  • Best practices for Express.js CI

What is Continuous Integration?

Continuous Integration is the practice of merging all developer working copies to a shared mainline several times a day. Each integration is verified by an automated build (including test) to detect integration errors as quickly as possible.

For Express.js applications, CI offers several benefits:

  • Automatically detecting errors and bugs early
  • Ensuring code quality with consistent testing
  • Simplifying the deployment process
  • Allowing for more frequent releases

Setting Up Your Express Project for CI

Before implementing CI, your Express project should have:

  1. A consistent project structure
  2. Automated tests (unit, integration, end-to-end)
  3. A version control system (typically Git)

Project Structure Example

my-express-app/
├── node_modules/
├── public/
├── src/
│ ├── controllers/
│ ├── models/
│ ├── routes/
│ └── app.js
├── test/
│ ├── unit/
│ └── integration/
├── .env
├── .gitignore
├── package.json
└── README.md

Setting Up Testing

For effective CI, you need automated tests. Here's a simple example using Mocha and Chai:

First, install the dependencies:

bash
npm install --save-dev mocha chai supertest

Then create a test file for an API endpoint:

javascript
// test/integration/users.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../src/app');

describe('User API', () => {
it('should return users when GET /api/users is called', async () => {
const res = await request(app)
.get('/api/users')
.expect(200);

expect(res.body).to.be.an('array');
});
});

Update your package.json with test scripts:

json
{
"scripts": {
"start": "node src/app.js",
"test": "mocha test/**/*.test.js --exit",
"test:unit": "mocha test/unit/**/*.test.js --exit",
"test:integration": "mocha test/integration/**/*.test.js --exit"
}
}

GitHub Actions

GitHub Actions is a popular choice for CI/CD, especially if your code is hosted on GitHub.

Create a workflow file at .github/workflows/ci.yml:

yaml
name: Express CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
build-and-test:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [14.x, 16.x, 18.x]

steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run lint --if-present
- run: npm test

This workflow:

  1. Triggers on pushes to main branch or pull requests
  2. Tests across multiple Node.js versions
  3. Runs linting and test scripts

Travis CI

For Travis CI, create a .travis.yml file in your project root:

yaml
language: node_js
node_js:
- 14
- 16
- 18

cache: npm

before_script:
- npm install

script:
- npm run lint --if-present
- npm test

CircleCI

For CircleCI, create a .circleci/config.yml file:

yaml
version: 2.1
jobs:
build:
docker:
- image: cimg/node:16.13
steps:
- checkout
- restore_cache:
keys:
- v1-dependencies-{{ checksum "package.json" }}
- v1-dependencies-
- run: npm install
- save_cache:
paths:
- node_modules
key: v1-dependencies-{{ checksum "package.json" }}
- run: npm run lint --if-present
- run: npm test

Setting Up a Database for CI Testing

Many Express applications use databases, which requires special handling in CI environments.

Using In-memory MongoDB for Tests

javascript
// test/setup.js
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');

let mongoServer;

// Set up the database before tests
before(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();

await mongoose.connect(mongoUri);
});

// Clean up after tests
after(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});

// Clear collections between tests
afterEach(async () => {
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
});

Then in your test file:

javascript
// test/integration/users.test.js
require('../setup'); // Include the setup file
const User = require('../../src/models/user');

describe('User API', () => {
beforeEach(async () => {
// Create test data
await User.create({
name: 'Test User',
email: '[email protected]'
});
});

it('should return users', async () => {
// Test implementation
});
});

Environment Variables in CI

Express apps often use environment variables for configuration. Here's how to handle them in CI:

Setting Environment Variables

GitHub Actions

yaml
jobs:
build:
env:
NODE_ENV: test
PORT: 3000
JWT_SECRET: ${{ secrets.JWT_SECRET }}

Travis CI

yaml
env:
global:
- NODE_ENV=test
- PORT=3000

For sensitive data, use each CI platform's encrypted secrets feature.

Real-World CI Pipeline for an Express Application

Let's look at a comprehensive CI workflow for an Express application:

yaml
name: Express CI/CD Pipeline

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '16.x'
cache: 'npm'
- run: npm ci
- run: npm run lint

test:
needs: lint
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x, 16.x, 18.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test

build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '16.x'
cache: 'npm'
- run: npm ci
- run: npm run build --if-present
- name: Archive build artifact
uses: actions/upload-artifact@v3
with:
name: build-artifact
path: dist

deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v3
with:
name: build-artifact
path: dist
- name: Deploy to staging
run: |
echo "Deploying to staging environment"
# Add deployment steps here

This workflow:

  1. Runs linting checks
  2. Proceeds to testing if linting passes
  3. Builds the application after tests pass
  4. Deploys only when code is pushed to the main branch

Best Practices for Express CI

1. Include Different Types of Tests

javascript
// Unit test example
// test/unit/utils.test.js
const { expect } = require('chai');
const { formatDate } = require('../../src/utils/dateFormatter');

describe('Date Formatter', () => {
it('should format date correctly', () => {
const date = new Date('2023-01-01');
expect(formatDate(date)).to.equal('01/01/2023');
});
});

// Integration test example
// test/integration/auth.test.js
const request = require('supertest');
const { expect } = require('chai');
const app = require('../../src/app');

describe('Auth API', () => {
it('should return JWT token on successful login', async () => {
await request(app)
.post('/api/login')
.send({ email: '[email protected]', password: 'password123' })
.expect(200)
.expect(res => {
expect(res.body).to.have.property('token');
});
});
});

2. Fail Fast

Configure your CI to exit early if any step fails:

yaml
# GitHub Actions example
jobs:
build:
steps:
- run: npm run lint || exit 1
- run: npm test

3. Cache Dependencies

yaml
# GitHub Actions
steps:
- uses: actions/setup-node@v3
with:
node-version: '16.x'
cache: 'npm'

# CircleCI
steps:
- restore_cache:
keys:
- v1-dependencies-{{ checksum "package.json" }}
- v1-dependencies-

4. Parallel Testing

Split your tests to run in parallel when possible:

yaml
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3]
steps:
- run: npm test -- --shard=${{ matrix.shard }}/3

Monitoring and Notifications

Set up notifications to alert your team about build status:

Slack Notifications in GitHub Actions

yaml
jobs:
notify:
needs: [test, build]
runs-on: ubuntu-latest
steps:
- uses: rtCamp/action-slack-notify@v2
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_CHANNEL: ci-notifications
SLACK_COLOR: ${{ job.status }}
SLACK_TITLE: Build Result
SLACK_MESSAGE: 'Build ${{ github.run_number }} completed'

Common CI Issues and Solutions

1. Flaky Tests

Problem: Tests that sometimes pass and sometimes fail with no code changes.

Solution:

javascript
// Add retry logic for flaky tests
describe('Occasionally flaky feature', function() {
this.retries(3); // Retry up to 3 times

it('should perform reliably', async () => {
// Test implementation
});
});

2. Long-Running Tests

Problem: Tests taking too long and slowing down CI.

Solution: Identify and optimize slow tests, or split test runs:

json
{
"scripts": {
"test:fast": "mocha test/unit/**/*.test.js --exit",
"test:slow": "mocha test/integration/**/*.test.js --exit"
}
}

Then in CI:

yaml
jobs:
fast-tests:
runs-on: ubuntu-latest
steps:
- run: npm run test:fast

slow-tests:
runs-on: ubuntu-latest
steps:
- run: npm run test:slow

Summary

Continuous Integration is an essential practice for modern Express.js application development. By automatically building and testing your code on every change, you can:

  • Catch bugs early in the development process
  • Ensure consistent code quality
  • Enable faster release cycles
  • Build confidence in your codebase

In this guide, we covered setting up CI pipelines with popular tools like GitHub Actions, Travis CI, and CircleCI. We explored best practices for testing Express applications, handling databases, and configuring environment variables in CI environments.

Remember that CI is not just about tools but about adopting a development culture that values frequent integration and automated testing.

Additional Resources

Exercises

  1. Set up a basic GitHub Actions workflow for an existing Express.js project.
  2. Add unit tests for at least one utility function in your Express application.
  3. Create an integration test for one of your API endpoints.
  4. Configure your CI pipeline to run both unit and integration tests.
  5. Add a notification system to your CI pipeline that alerts you when builds fail.
  6. Extend your CI pipeline to deploy your application to a staging environment when tests pass.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)