Advanced

Node.js Microservices Testing and Continuous Integration

I'm an experienced Node.js developer with a passion for writing code that's easy to test, easy to maintain, and easy to scale.

Table of Contents

  1. Course Overview
  2. Introduction to the Globoticket API
  1. Implementing unit tests with Jest
  1. Module 4 — Implementing integration tests with Jest
  1. Module 5 — Implementing end-to-end tests with Jest
  1. Module 6 — Running tests in CI/CD pipelines with GitHub Actions

1. Course Overview

Hello everyone. My name is Tyler Griffiths and welcome to my course Node.js Microservices: Testing and Continuous Integration. I’m an experienced Node.js developer with a passion for writing code that’s easy to test, easy to maintain, and easy to scale.

Good test coverage is very important for continuous delivery of Node.js microservices. This course will teach you how to implement unit, integration, and end-to-end testing coverage using Jest in your Node.js microservices so you can deliver quality code with confidence.

Main topics covered include:

  • How to write fast and reliable unit tests with Jest
  • Apply separation of responsibilities to facilitate unit testing
  • Database Seeder for integration tests
  • Identify the most critical business functions to test
  • Building a continuous delivery process

By the end of the course, you will have the Node.js microservices testing skills needed to deliver high-quality Node.js code on-demand and at scale.


2. Introduction to the Globoticket API

2.1 Why good test coverage is important

I think it’s important to start with the “why”. So why is good test coverage important?

Good test coverage allows:

  1. More frequent code delivery: this is continuous delivery. For most web applications and APIs, frequent delivery should be the goal. More frequent delivery helps reduce risk. Because we deploy smaller changes, there’s less chance of something going wrong, and it’s easier to fix things if a problem does appear.

  2. More confidence with deployments: Studies have shown that a lack of test automation leads to a lack of confidence among developers and can prevent the use of continuous delivery. With continuous delivery, responsibility for deployments shifts from the QA or DevOps team to the developer.

  3. Removing much of the tedious manual testing: Manual regression testing is associated with waterfall deployment strategies where the QA team plays the role of gatekeeper of deployments. By automating regression testing, we free up the QA team’s time for exploratory testing, writing acceptance criteria for new features, and allow them to use their human creativity.

Continuous Delivery vs. Waterfall

Continuous Delivery (CD)Waterfall Delivery
The code is still deployableCode accumulates in releases or batches
Automated Deployment PipelineTime-consuming manual deployments
Developers deploy their own codeThe QA team is the gatekeeper of deployments
Frequent and small deploymentsLarge and infrequent deployments
In case of a bug: correct and move forward (roll forward)In case of bug: rollback of the entire release

The key difference between continuous delivery and waterfall delivery can be summed up in one word: trust.

Important: Achieving 100% test coverage does not mean having 0% risk of failures in production. Setting a test coverage percentage objective, although well-intentioned, is not always the best direction. Trust tends to be more qualitative than quantitative.


2.2 Practical test coverage strategy

Models give us a common language. Let’s look at some test coverage models and use their commonalities to establish a practical strategy.

The three main models

1. The Test Pyramid (Test Pyramid)

  • Popularized by Mike Cohen and Martin Fowler
  • Each level of the pyramid represents a type of test coverage and the relative volume of tests
  • Main idea: Unit tests are less expensive to write and maintain because they have fewer dependencies. The majority of tests should therefore be unit tests, followed by integration tests, and crowned by end-to-end tests.
  • Limitations: tends to emphasize speed and ease of development, but does not take enough into account trust and portability

2. The Diamond (or Honeycomb) test (Test Diamond / Honeycomb) – Introduced by Spotify Engineering Team

  • Illustrates that if we optimize not only for speed and ease of development, but also for portability and trust, we should prioritize integration testing
  • The majority of tests should be integration tests

3. The Test Trophy (Test Trophy)

  • Submitted by Kent Dodds
  • Builds on the diamond model by adding a static analysis base
  • Static analysis provides us with a sort of built-in testing layer before even writing unit tests

These models illustrate the key dimensions: speed, maintainability, portability and confidence.

Fundamentals of Good Test Coverage

Rather than strictly adhering to any of these models, it is better to focus on the most important principles:

  1. Testing must inspire great confidence, not only for developers, but for the entire organization
  2. Tests should establish clear boundaries and not exceed them
  3. Tests should be optimized to run quickly and reliably
  4. Tests should only fail for useful reasons

Boundary definitions for each test level

LevelObjectiveBorders
Unit testsGuide developmentTest the logic, not the wiring (wiring). Isolate with mocks
Integration testsCheck contracts between units and microservicesDo not cross multiple microservices
End-to-end testingTest user journeys through microservicesOnly the most critical user functions

2.3 Case study: Globoticket API

Company Introduction

Globalticket is what I like to call a realistically fictional company. It is a ticket sales and distribution company. It primarily serves musical artists and concert goers.

Main business functions:

  1. The application lists event tickets with date, artist, type and purchase price
  2. Customers can select an event and specify the quantity of tickets they want
  3. Customers can place an order with a credit card
  4. Order is fulfilled and digital tickets are sent by email
  5. At the concert, patrons present their digital tickets (signed with a special token) to an agent
  6. After successful validation, they are allowed to enter the concert

Technical architecture

Like many new companies, Globalticket’s architecture started as a monolith with very little test coverage. As they grew, they increasingly fell victim to the limitations of a waterfall deployment paradigm. So they decided to transition to a microservices architecture.

Primary microservice domains:

  • Microservice Inventory: responsible for ticket and event management
  • Microservice Order: manages orders and their processing

Each microservice has its own database. Asynchronous communication between the two is managed by a message queue. Each microservice also has HTTP clients to call third-party APIs (like a payment gateway).

┌──────────────────┐    Message Queue    ┌─────────────────┐
│  Inventory API   │ ◄──────────────────► │   Order API     │
│   (Port 3001)    │                      │  (Port 3002)    │
│                  │                      │                 │
│  ┌────────────┐  │                      │ ┌─────────────┐ │
│  │  SQLite DB │  │                      │ │  SQLite DB  │ │
│  └────────────┘  │                      │ └─────────────┘ │
└──────────────────┘                      └─────────────────┘

Technologies used:

  • ExpressJS: HTTP server for RESTful endpoints
  • SQLite: light and simple database
  • ZeroMQ: decentralized message queue (no need for a dedicated message queue server)

Benefits desired by Globalticket:

  • Increased confidence during changes
  • More frequent merges and deployments
  • Faster feedback
  • Better detection of breaking changes

2.4 Demo: Getting started with the Globoticket application

Prerequisites:

  • Node 20 and NPM 10 installed
  • A code editor (VS Code recommended)
  • Course files downloaded

Steps to start the application:

# 1. Installer toutes les dépendances depuis la racine du projet
npm install

# 2. Démarrer le microservice Inventory (dans un terminal)
cd apps/inventory
npm start

# 3. Démarrer le microservice Order (dans un autre terminal)
cd apps/order
npm start

# 4. Vérifier que les services fonctionnent
curl http://localhost:3001/status   # Inventory service
curl http://localhost:3002/status   # Order service

The Inventory microservice is running on port 3001 and the Order microservice is running on port 3002.


2.5 Testing frameworks for Node.js microservices

Components required for a good testing framework

  1. Test runner: allows you to actually run the tests. Controls the complete test lifecycle
  2. Assertion library: allows you to assert that certain test conditions are met
  3. Duplicate testing framework: provides mocking, stubbing and spying capabilities to bypass the actual implementation of certain components

Comparison: Jest vs Mocha

CharacteristicJestMocha
Weekly NPM Downloads~17 million~6 million
Value proposition“JavaScript testing with batteries included”Flexible test runner, requires additional libraries
Test runner✅ Integrated✅ Main component
Assertions✅ Integrated❌ Requires Chai or similar
Double tests✅ Integrated❌ Requires Otherwise or Double Test
ConfigurationOut-of-the-boxCustomizable
Parallel execution✅ (recent versions)
Code coverage✅ Integrated❌ Requires Istanbul
Snapshots
Developed byFacebook (Meta)Community

Why Globalticket chose Jest:

  • They start from scratch and like everything included
  • Fewer decisions to make to get started
  • Proven at scale by Facebook

2.6 Demo: Install and configure Jest

Installing Jest

# Installer Jest en tant que dépendance de développement
npm install --save-dev jest

Configuring test script in package.json

{
  "scripts": {
    "test": "jest --coverage"
  }
}

Example jest.config.js file

module.exports = {
  testMatch: ['**/*.test.js'],
  collectCoverage: true,
  coverageDirectory: 'coverage'
};

Differentiated NPM scripts (unit vs integration)

{
  "scripts": {
    "test": "jest --coverage",
    "unit:test": "jest --testPathPattern=unit.test.js",
    "integration:test": "jest --testPathPattern=integration.test.js"
  }
}

Running tests

# Exécuter tous les tests
npm test

# Exécuter uniquement les tests unitaires
npm run unit:test

# Exécuter uniquement les tests d'intégration
npm run integration:test

The Jest Runner extension for VS Code also allows:

  • Run tests directly in the editor
  • Debug tests with breakpoints
  • Visualize code coverage with red (not covered) and yellow (partially covered) highlights

This repo uses NPM workspaces for a monorepo structure allowing code to be shared between microservices in the form of NPM packages.


3. Implementing unit tests with Jest

3.1 Anatomy of a Jest unit test

Jest aims to be an all-in-one solution for testing. It provides globals to use directly without additional import.

The most common Jest globals

GlobalDescription
describeDefines a test suite and groups test cases
it / testDefines an individual test case
expectAsserts test case results
beforeAll / afterAllLifecycle functions executed once before/after everything else
beforeEach / afterEachLifecycle functions executed before/after each test
jest.fn()Create a mock function
jest.mock()Mock an entire module
jest.spyOn()Observes and can override a real function

Structure of a typical Jest test

// Importation du code testé
const { calculateTaxes } = require('./calculations');

// Suite de tests
describe('calculateTaxes', () => {
  
  // Cas de test 1
  it('should calculate taxes for Utah', () => {
    // Arrange
    const state = 'UT';
    const price = 100;

    // Act
    const result = calculateTaxes(state, price);

    // Assert
    expect(result).toBe(10); // 10% pour l'Utah
  });

  // Cas de test 2
  it('should return 0 for unknown states', () => {
    // Arrange
    const state = 'XX';
    const expectedTax = 0;

    // Act
    const calculatedTax = calculateTaxes(state, 100);

    // Assert
    expect(calculatedTax).toBe(expectedTax);
  });
});

File naming convention

Test TypeSuffix
Unit Testing.unit.test.js
Integration testing.integration.test.js
End-to-end testing.e2e.test.js

The Arrange, Act, Assert (AAA) pattern

The three-part pattern we will follow for the test cases:

  1. Arrange: prepare and configure everything necessary for the test
  2. Act: act on the code tested by executing a function
  3. Assert: assert that the test case does what it should

3.2 Demo: Adding unit tests to Globoticket

Testing the Event service (first attempt with spy)

// apps/inventory/__tests__/event.service.unit.test.js
const { Event } = require('../db/models');
const eventService = require('../services/event.service');

describe('Event Service', () => {
  describe('listEvents', () => {
    it('should list events', async () => {
      // Arrange : spy sur la fonction findAll du modèle Event
      const findAllSpy = jest
        .spyOn(Event, 'findAll')
        .mockResolvedValue([
          { get: jest.fn().mockReturnValue({ id: 1, name: 'Concert A' }) }
        ]);

      // Act
      const events = await eventService.listEvents();

      // Assert
      expect(events).not.toBeEmpty();
    });

    it('should throw error if no events found', async () => {
      // Arrange
      jest.spyOn(Event, 'findAll').mockResolvedValue([]);

      // Assert
      await expect(eventService.listEvents()).rejects.toThrow(NotFoundError);
    });
  });
});

Corridal sin of unit testing: not respecting unit boundaries. If your test attempts to actually query the database, you have violated unit boundaries. Unit tests should never initiate I/O.

Using a Sequelize ORM

In the case of Globalticket, the persistence layer is a Sequelize ORM. The simplest way to introduce a test double for Sequelize is with a Jest spy.


3.3 Write fast and reliable unit tests

Unit tests should run in seconds, not minutes. To do this, we need to keep testing focused and light.

Problem: spyware can cause side effects

Example: the TicketService internally imports an InventoryPublisher. When using a spy on ticketDoa.listTickets, the InventoryPublisher is still initialized and attempts to bind to a network port.

Test output:
Publisher bound to port 3006  <-- Indésirable dans un test unitaire !

Difference between Jest Spy and Jest Mock

Jest SpyJest Mock
Often observes and replaces real functionsCompletely replaces a module with a bogus implementation
The actual module is still built/initializedModule constructor is never executed
Can introduce I/O (network ports, files)No I/O overhead
Risk of open handlesNo open handles

What’s going on below with a spy

Test → import TicketService
     → Construit le VRAI TicketService
     → InventoryPublisher singleton créé (I/O overhead)
     → Jest substitue uniquement la fonction listTickets

What happens with a mock

Test → import ticketService
     → Jest remplace ENTIÈREMENT le module ticketService
     → Le constructeur n'est jamais exécuté
     → Aucune I/O overhead

Rules for Fast and Reliable Unit Testing

  1. Avoid using spies as test doubles in unit tests
  2. Mock as many dependencies as possible
  3. Be vigilant about dependencies and what they actually do
  4. Detect open handles with the Jest CLI option --detectOpenHandles

3.4 Demo: Mocking modules

Refactoring to module mocks

// apps/inventory/__tests__/ticket.service.unit.test.js
const ticketService = require('../services/ticket.service');
const ticketDoa = require('../doa/ticket.doa');

// Mock du module ticketDoa
jest.mock('../doa/ticket.doa', () => ({
  listTickets: jest.fn()
}));

// Mock de l'InventoryPublisher (bypass de l'initialisation)
jest.mock('../messaging/inventory.publisher', () => ({}));

describe('Ticket Service', () => {
  // Nettoyage après chaque test pour éviter les interférences
  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('listTickets', () => {
    it('should list tickets', async () => {
      // Arrange
      const mockTickets = [
        { id: 1, name: 'VIP', eventId: 1 },
        { id: 2, name: 'Standard', eventId: 1 }
      ];
      ticketDoa.listTickets.mockResolvedValue(mockTickets);

      // Act
      const result = await ticketService.listTickets();

      // Assert
      expect(result).toEqual(mockTickets);
      expect(ticketDoa.listTickets).toHaveBeenCalledTimes(1);
    });

    it('should throw NotFoundError if no tickets found', async () => {
      // Arrange
      ticketDoa.listTickets.mockResolvedValue([]);

      // Assert
      await expect(ticketService.listTickets()).rejects.toThrow(NotFoundError);
      expect(ticketDoa.listTickets).toHaveBeenCalledTimes(1);
    });
  });
});

Important: jest.clearAllMocks() in the afterEach hook allows emptying the mock.calls array of each mock function after each test, thus avoiding interference between tests.

Diagnostic Tools

# Détecter les open handles
jest --detectOpenHandles

3.5 Demo: Apply separation of responsibilities

Problem: deep nested mocking = encapsulation break

// Mauvaise approche : on doit savoir que findAll retourne des objets avec .get()
jest.mock('../db/models', () => ({
  findAll: jest.fn().mockResolvedValue([
    {
      get: jest.fn().mockReturnValue({ id: 1, name: 'Concert A' })
      //  ↑ Connaissance intime du module — rupture d'encapsulation !
    }
  ])
}));

Just as a red flag on the beach indicates unsafe conditions, deep nested mocking in unit tests likely indicates a breakdown in encapsulation and poor separation of responsibilities.

Solution: introduce a DOA (Data Access Object) layer

// apps/inventory/doa/event.doa.js
const { Event } = require('../db/models');

const listEvents = async () => {
  const events = await Event.findAll();
  return events.map(event => event.get()); // Encapsulation ici
};

module.exports = { listEvents };
// apps/inventory/services/event.service.js
const eventDoa = require('../doa/event.doa');
const { NotFoundError } = require('../errors');

const listEvents = async () => {
  const events = await eventDoa.listEvents();
  if (!events || events.length === 0) {
    throw new NotFoundError('No events found');
  }
  return events;
};

module.exports = { listEvents };
// Test avec mock simplifié grâce au DOA
jest.mock('../doa/event.doa'); // Toutes les fonctions exportées deviennent automatiquement des jest.fn()

describe('Event Service', () => {
  describe('listEvents', () => {
    it('should list events', async () => {
      // Arrange
      eventDoa.listEvents.mockResolvedValue([{ id: 1, name: 'Concert A' }]);

      // Act
      const events = await eventService.listEvents();

      // Assert
      expect(events).toHaveLength(1);
    });
  });
});

Principle: when using jest.mock without factory, all functions exported from the module are automatically replaced by jest.fn().


3.6 Test Driven Development (TDD) in Practice

Why TDD?

Having unit tests in place before writing code provides a type of scaffolding for building in:

  • Encouraging modularity and reusability through encapsulation and separation of responsibilities
  • Improving design by pushing us to think about architecture
  • Helping to avoid over-engineering of solutions
  • Increasing confidence with changes and additions
  • Providing an integrated form of living documentation

TDD in practice: what is most important is not necessarily writing the tests before writing the code, but rather writing the tests at the same time as the code. The real benefit is ensuring our code reaches its highest level of quality.

TDD example: adding the listOrders function

Step 1: Write the tests first

// apps/order/__tests__/order.service.unit.test.js
const orderService = require('../services/order.service');
const orderDoa = require('../doa/order.doa');

jest.mock('../doa/order.doa');
jest.mock('../messaging/order.publisher', () => ({}));

describe('Order Service', () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('listOrders', () => {
    it('should list orders', async () => {
      // Arrange
      const mockOrders = [
        { id: 1, status: 'pending', tickets: [] }
      ];
      orderDoa.listOrders.mockResolvedValue(mockOrders);

      // Act
      const result = await orderService.listOrders();

      // Assert
      expect(result).toEqual(mockOrders);
      expect(orderDoa.listOrders).toHaveBeenCalledTimes(1);
    });

    it('should throw NotFoundError if no orders found', async () => {
      // Arrange
      orderDoa.listOrders.mockResolvedValue([]);

      // Assert
      await expect(orderService.listOrders()).rejects.toThrow(NotFoundError);
      expect(orderDoa.listOrders).toHaveBeenCalledTimes(1);
    });
  });
});

Step 2: Implement the code to pass the tests

// apps/order/services/order.service.js
const orderDoa = require('../doa/order.doa');
const { NotFoundError } = require('../errors');

const listOrders = async () => {
  const orders = await orderDoa.listOrders();
  if (!orders || orders.length === 0) {
    throw new NotFoundError('No orders found');
  }
  return orders;
};

module.exports = { listOrders };
// apps/order/doa/order.doa.js
const { Order } = require('../db/models');

const listOrders = async () => {
  const orders = await Order.findAll();
  return orders.map(order => order.get());
};

module.exports = { listOrders };

4. Implementing Integration Testing with Jest

4.1 Integration tests vs unit tests

When porting a monolith to a microservices architecture, integration testing is extremely valuable in ensuring that service boundaries and contracts continue to work as expected.

AppearanceUnit TestingIntegration testing
ObjectiveValidate the behavior of an individual module/functionCheck interactions between units and layers
DependenciesMockedReal (e.g. DOA with DB connection)
ValueGuide developmentVery valuable when refactoring monolith → microservices
FocusBusiness logic (Service layer)Cabling between layers (routing → controller → service → DOA → DB)
VolumeManyMajority of the test suite

Microservice Inventory Layered Architecture

HTTP Request
    ↓
Route Layer (/ticket endpoints)
    ↓
Controller Layer (routage des requêtes HTTP)
    ↓
Service Layer (logique métier)
    ↓
DOA Layer (formatage + communication DB)
    ↓
Database (SQLite)

Unit tests mainly cover the Service layer. The integration tests cover the remaining layers and the wiring between them.


4.2 Practical Strategy for Integration Testing

Strategy for Globalticket

  1. Use real dependencies from API to database
  2. Mock or stubb extrinsic dependencies (message queue, third-party APIs)
  3. One integration suite per API resource (e.g.: one for ticket routes, one for event routes)
  4. Test by contracts: manage tests with API requests to simulate real customer interactions

SuperTest

SuperTest is a popular JavaScript library for testing HTTP assertions and making HTTP requests in Node.js applications.

npm install --save-dev supertest

Advantages:

  • Works with Mocha, Jest and other frameworks
  • Easy to use API for simulating HTTP requests
  • Checks responses: status code, headers, body
  • Takes the place of the server in an Express application
  • Assign random port for testing

Best practice: separate the server from the Express app. SuperTest can take the place of the server, making injection easier.

// apps/inventory/app.js  ← Seulement l'application Express
const express = require('express');
const app = express();
// ... routes, middleware ...
module.exports = app;

// apps/inventory/server.js  ← Démarrage du serveur + init DB/queue
const app = require('./app');
const { sequelize } = require('./db');
// ... initialisation ...
app.listen(3001);

Rules to follow to avoid “journeys”

  • Avoid chaining several requests that go beyond a single obligation (this is the role of end-to-end tests)
  • Avoid wait/timeout in integration tests (sign of boundary crossing)

4.3 Demo: Implementing Integration Tests

First integration test for Event Routes

// apps/inventory/__tests__/event.routes.integration.test.js
const request = require('supertest');
const app = require('../app');
const { sequelize } = require('../db');

// Mock de l'InventoryPublisher pour maintenir les frontières de test
jest.mock('../messaging/inventory.publisher', () => ({
  sendTicketsReleasedEvent: jest.fn()
}));

describe('Event Routes', () => {
  // Initialiser la base de données avant tous les tests
  beforeAll(async () => {
    await sequelize.sync({ force: true });
  });

  // Fermer la base de données après tous les tests
  afterAll(async () => {
    await sequelize.close();
  });

  describe('POST /events', () => {
    it('should return 400 if required fields are missing', async () => {
      // Arrange
      const invalidBody = {}; // Champs requis manquants

      // Act & Assert
      await request(app)
        .post('/events')
        .send(invalidBody)
        .expect(400);
    });

    it('should create a new event', async () => {
      // Arrange
      const eventBody = {
        name: 'Rock Concert 2024',
        artist: 'The Rockstars',
        date: '2024-12-01',
        venue: 'Madison Square Garden'
      };

      // Act & Assert
      const response = await request(app)
        .post('/events')
        .send(eventBody)
        .expect(201);

      expect(response.body.id).toBeDefined();
      expect(response.body.name).toBe(eventBody.name);
    });
  });
});

Why does the first test fail? If we receive SQLite “table doesn’t exist” errors, it’s because the database is initialized in server.js. The app alone knows nothing about the database. You must initialize the DB in the beforeAll hook of the test.


4.4 Seeder databases for integration tests

Why seeders are needed

To test GET /events/:id or GET /events, we cannot simply do a POST request first because:

  1. This violates the journeys rule (single responsibility principle)
  2. We would need to wait for asynchronous endpoints, which makes testing unreliable

Sequelize seeders

Sequelize CLI provides convenient commands for generating and running seeders.

# Générer un seeder
npx sequelize-cli seed:generate --name event-seeder

# Le seeder généré aura un préfixe de date automatique
# Ex.: 20240101000000-event-seeder.js
# → Renommer en event.seeder.js

Structure of a seeder

// apps/inventory/seeders/event.seeder.js
module.exports = {
  up: async (queryInterface) => {
    await queryInterface.bulkInsert('Events', [
      {
        id: 1,
        name: 'Rock Concert 2024',
        artist: 'The Rockstars',
        date: '2024-12-01',
        venue: 'Madison Square Garden',
        createdAt: new Date(),
        updatedAt: new Date()
      },
      {
        id: 2,
        name: 'Jazz Festival',
        artist: 'The Jazzers',
        date: '2024-11-15',
        venue: 'Central Park',
        createdAt: new Date(),
        updatedAt: new Date()
      }
    ]);
  },

  down: async (queryInterface) => {
    await queryInterface.bulkDelete('Events', null, {});
  }
};

The three rules of seeders

  1. Only include absolutely necessary seeders for the current test. Do not seed the entire application for each test suite
  2. Keep seeders light and fresh (refresh before each suite or before each test case if necessary)
  3. Use seeders to avoid journeys in integration tests

4.5 Demo: Using Seeders for Integration Testing

Complete integration test with seeders

// apps/inventory/__tests__/event.routes.integration.test.js
const request = require('supertest');
const app = require('../app');
const { sequelize } = require('../db');
const eventSeeder = require('../seeders/event.seeder');

jest.mock('../messaging/inventory.publisher', () => ({
  sendTicketsReleasedEvent: jest.fn()
}));

describe('Event Routes', () => {
  beforeAll(async () => {
    await sequelize.sync({ force: true });
    // Exécuter le seeder
    await eventSeeder.up(sequelize.getQueryInterface());
  });

  afterAll(async () => {
    // Nettoyage
    await eventSeeder.down(sequelize.getQueryInterface());
    await sequelize.close();
  });

  // ... tests POST /events ...

  describe('GET /events/:id', () => {
    it('should return an event by id', async () => {
      // Act & Assert
      const response = await request(app)
        .get('/events/1') // ID du seeder
        .expect(200);

      expect(response.body.id).toBe(1);
    });

    it('should return 404 if event not found', async () => {
      await request(app)
        .get('/events/9999') // ID inexistant dans le seeder
        .expect(404);
    });
  });

  describe('GET /events', () => {
    it('should list all events', async () => {
      const response = await request(app)
        .get('/events')
        .expect(200);

      expect(response.body).not.toHaveLength(0);
    });
  });
});

Handling foreign key constraints with beforeEach/afterEach

To avoid timing problems when tests share IDs (e.g.: a delete test and a get test use the same ID):

describe('Ticket Routes', () => {
  // Utiliser beforeEach/afterEach pour des seeders frais à chaque test
  beforeEach(async () => {
    await sequelize.sync({ force: true });
    await eventSeeder.up(sequelize.getQueryInterface());
    await ticketSeeder.up(sequelize.getQueryInterface());
  });

  afterEach(async () => {
    await ticketSeeder.down(sequelize.getQueryInterface());
    await eventSeeder.down(sequelize.getQueryInterface());
    // Note: ordre inversé à cause des contraintes de clés étrangères
  });

  describe('DELETE /tickets/:id', () => {
    it('should delete a ticket and release reserved tickets', async () => {
      await request(app)
        .delete(`/tickets/${testTicket.id}`)
        .expect(200);

      expect(inventoryPublisher.sendTicketsReleasedEvent).toHaveBeenCalledTimes(1);
    });
  });

  describe('GET /tickets/:id', () => {
    it('should return a ticket by id', async () => {
      const response = await request(app)
        .get(`/tickets/${testTicket.id}`)
        .expect(200);

      expect(response.body.id).toBe(testTicket.id);
    });
  });
});

4.6 Improve the reliability of integration tests

Principles for Reliable Integration Testing

  1. Establish clear test boundaries: define a specific focus for each test, guided by the API endpoint
  2. Follow the single responsibility principle: avoid doing more than the test promises in its description
  3. Avoid wait and timeout: they are generally linked to doing several things at once

Special case: integration via the message queue

Example at Globalticket:

  1. Customer places an order → Order Service publishes an order.created message
  2. The Ticket Service receives order.created → calls reserveTickets()
  3. reserveTickets() reserves tickets in DB → publishes an inventory.reserved message
  4. Order Service receives inventory.reserved → updates order status

The reserveTickets function does not serve as an API endpoint, which presents unique challenges for testing.


4.7 Demo: Test the reservation path via the message queue

Use orderPublisher as test client

Just as we use SuperTest as a test client for HTTP requests, we will use the orderPublisher to send messages to the orderSubscriber.

// apps/inventory/__tests__/order.subscriber.integration.test.js
const ticketService = require('../services/ticket.service');
const orderPublisher = require('../../order/messaging/order.publisher');
const orderSubscriber = require('../messaging/order.subscriber');
const { sequelize } = require('../db');
const eventSeeder = require('../seeders/event.seeder');
const ticketSeeder = require('../seeders/ticket.seeder');

jest.mock('../messaging/inventory.publisher', () => ({
  sendInventoryReservedEvent: jest.fn()
}));

describe('Order Subscriber', () => {
  beforeAll(async () => {
    await sequelize.sync({ force: true });
    await eventSeeder.up(sequelize.getQueryInterface());
    await ticketSeeder.up(sequelize.getQueryInterface());
    // Initialiser le subscriber et le publisher
    await orderSubscriber.init();
    await orderPublisher.init();
  });

  afterAll(async () => {
    await orderPublisher.close();
    await orderSubscriber.close();
    await ticketSeeder.down(sequelize.getQueryInterface());
    await eventSeeder.down(sequelize.getQueryInterface());
    await sequelize.close();
  });

  it('should call reserveTickets when order.created is received', (done) => {
    // Arrange
    const reserveTicketsSpy = jest.spyOn(ticketService, 'reserveTickets');
    
    // Écouter la fin de la réservation
    orderSubscriber.on('ticketsReserved', () => {
      try {
        // Assert
        expect(reserveTicketsSpy).toHaveBeenCalledTimes(1);
        done();
      } catch (error) {
        done(error);
      }
    });

    // Act
    orderPublisher.sendOrderCreatedEvent(
      { orderId: 1, items: [{ ticketId: 1, quantity: 2 }] },
      (err) => {
        expect(err).toBeNull();
      }
    );
  });
});

Problem with timeouts: once the message is sent, everything happens asynchronously. We cannot know when reserveTickets has finished. Using a timeout of 500ms works but is unreliable. The solution is to add a listener to the orderSubscriber to be notified when the reservation is completed.

More flexible approach: mock of the ticketService + direct unit test

For more flexibility (testing error cases), we can separate the concerns:

// 1. Test du subscriber (message → appel correct)
jest.mock('../services/ticket.service');
// Teste que le subscriber appelle bien ticketService.reserveTickets

// 2. Test direct du service (logique métier complète)
// apps/inventory/__tests__/ticket.service.integration.test.js
describe('Ticket Service Integration', () => {
  it('should reserve tickets', async () => {
    // Arrange : utiliser de vraies DOAs et la vraie DB
    const orderItems = [{ ticketId: testTicket.id, quantity: 1 }];

    // Act
    await ticketService.reserveTickets(orderItems);

    // Assert : vérifier en DB
    const reservedTickets = await reservedTicketDoa.getByTicketId(testTicket.id);
    expect(reservedTickets).toHaveLength(1);
  });

  it('should throw error if no ticket availability', async () => {
    const orderItems = [{ ticketId: testTicket.id, quantity: 9999 }]; // Quantité excessive
    
    await expect(ticketService.reserveTickets(orderItems))
      .rejects.toThrow('Insufficient ticket availability');
  });
});

5. Implementing end to end testing with Jest

5.1 Practical value of end-to-end testing

Full Test Strategy Summary

LevelFocusEnvironment
Unit testsSingle unit/layer (Service layer)Mocks, no I/O
Integration testsMultiple layers, path API → DBDB in memory or test
End-to-end testingComplete user journeyReal deployed environment

End-to-end testing characteristics

  • Running against a real and functional environment (staging or beta)
  • Use real persisted data (different cleanup strategy needed)
  • May be fragile because we use a real system with real latency
  • Focused on the most important paths (leave the rest to other types of tests)

Place in CI/CD pipeline

Feature Branch
    ↓
Pull Request créée
    ↓
CI Pipeline (unit + integration tests)
    ↓
Merge dans main
    ↓
CD Pipeline:
  1. Build step
  2. Déploiement en staging/beta
  3. End-to-end tests en staging
  4. (Si OK) Déploiement en production

5.2 Identify the most critical business functions

Framework as/when/then

To capture user journeys, the as/when/then framework is extremely useful.

Good examples:

  • As a concert goer, when I place an order for event tickets, then my payment is processed and tickets are reserved
  • As a concert goer, when I look at my reserved tickets, then I am able to scan them for entry into an event
  • As a concert goer, when I cancel my reserved tickets, then I am refunded, and my reserved tickets are released

Bad example (more suitable for integration testing):

  • As an artist, when I create an event, then a new event ID is returned — This test looks more like a contract than a user journey

Criteria for including end-to-end testing

  1. If it prevents me from generating income, it must be included
  2. Do I want to be woken up at 3 a.m. to correct this? — If so, that’s a good indicator

End-to-end tests selected for Globoticket

Test 1: As a concert goer, when I place an order for event tickets, then my payment is processed, and tickets are reserved

  • Directly related to Globalticket’s ability to collect revenue

Test 2: As a concert goer, when I look at my reserved tickets, then I am able to scan them for entry into an event

  • If a spectator at the door cannot scan their ticket, you absolutely must be alerted

5.3 Demo: Setting up and starting the test environment

Separate dev and production environments

// apps/inventory/package.json
{
  "scripts": {
    "dev": "NODE_ENV=development node server.js",
    "start": "NODE_ENV=production node server.js",
    "seed": "sequelize-cli db:seed:all"
  }
}
// apps/order/package.json
{
  "scripts": {
    "dev": "NODE_ENV=development node server.js",
    "start": "NODE_ENV=production node server.js"
  }
}

Starting the environment for end-to-end testing

# Terminal 1 : Démarrer le microservice Inventory
cd apps/inventory
npm start
npm run seed  # Seeder la base de données de production avec des données de test

# Terminal 2 : Démarrer le microservice Order
cd apps/order
npm start

5.4 Demo: Configure end-to-end testing

Structure of the end-to-end application

apps/
├── inventory/          # Microservice Inventory
├── order/              # Microservice Order
└── e2e-tests/          # Application de tests end-to-end standalone
    ├── package.json
    ├── utils/
    │   ├── order-api-client.js
    │   └── inventory-api-client.js
    └── tests/
        └── concertgoer-order.e2e.test.js

End-to-end tests belong in their own standalone application, unlike unit and integration tests which live alongside the code they test. The advantage is being able to write tests that traverse any number of microservices.

API clients with Axios

// apps/e2e-tests/utils/order-api-client.js
const axios = require('axios');

const BASE_URL = 'http://localhost:3002';

const createOrder = async (orderBody) => {
  const response = await axios.post(`${BASE_URL}/orders`, orderBody);
  return response;
};

const getOrder = async (orderId) => {
  const response = await axios.get(`${BASE_URL}/orders/${orderId}`);
  return response;
};

const getStatus = async () => {
  const response = await axios.get(`${BASE_URL}/status`);
  return response;
};

module.exports = { createOrder, getOrder, getStatus };
// apps/e2e-tests/utils/inventory-api-client.js
const axios = require('axios');

const BASE_URL = 'http://localhost:3001';

const getReservedTicket = async (reservedTicketId) => {
  const response = await axios.get(`${BASE_URL}/reserved-tickets/${reservedTicketId}`);
  return response;
};

const validateTicket = async (token) => {
  const response = await axios.get(`${BASE_URL}/tickets/validate/${token}`);
  return response;
};

module.exports = { getReservedTicket, validateTicket };

Installing Axios

npm install axios

5.5 Demo: Write end-to-end tests for Globoticket

Test 1: Place an order and reserve tickets

// apps/e2e-tests/tests/concertgoer-order.e2e.test.js
const orderApiClient = require('../utils/order-api-client');
const inventoryApiClient = require('../utils/inventory-api-client');

// Utilitaire sleep pour les tests end-to-end (acceptable ici contrairement aux tests unitaires/intégration)
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

describe('as concertgoer', () => {
  describe('when I place an order for event tickets', () => {
    it('should then create order and reserve tickets', async () => {
      // Arrange : générer un nouveau billet pour chaque commande afin de ne pas manquer de stock
      const newTicketResponse = await inventoryApiClient.createTicket({
        eventId: 1,    // Événement pré-seedé dans la DB
        type: 'VIP',
        quantity: 50,
        price: 150.00
      });
      const ticketId = newTicketResponse.data.id;

      const orderBody = {
        items: [
          { ticketId, quantity: 20 },
          { ticketId, quantity: 20 }
        ],
        paymentInfo: {
          cardNumber: '4111111111111111',
          expiry: '12/25',
          cvv: '123'
        }
      };

      // Act : créer la commande
      const createResponse = await orderApiClient.createOrder(orderBody);

      // Assert : vérifier la création
      expect(createResponse.status).toBe(200);
      const orderId = createResponse.data.id;
      expect(orderId).toBeDefined();
      expect(createResponse.data.status).toBe('pending'); // Opération async

      // Attendre que les billets soient réservés (acceptable en end-to-end)
      await sleep(1000); // 1 seconde — peut nécessiter un ajustement selon l'environnement

      // Act : récupérer les détails de la commande
      const orderResponse = await orderApiClient.getOrder(orderId);

      // Assert : vérifier les billets réservés
      expect(orderResponse.status).toBe(200);
      expect(orderResponse.data.reservedTickets).toHaveLength(40); // 20 + 20 = 40 billets
    });
  });
});

Note on sleeps/waits: Although sleeps/waits are a red flag in unit and integration tests, they are perfectly acceptable in end-to-end testing. They are virtually impossible to avoid due to the asynchronous nature of microservices architectures and the actual I/O latency of deployed environments. Insufficient wait time can be the cause of false failures in end-to-end tests.

Test 2: Scan reserved tickets to enter an event

describe('when I look at my reserved tickets', () => {
  it('should then be able to scan them for entry into an event', async () => {
    // Arrange : créer une commande (répétition nécessaire pour ce test)
    const newTicketResponse = await inventoryApiClient.createTicket({
      eventId: 1,
      type: 'Standard',
      quantity: 50,
      price: 75.00
    });

    const orderBody = {
      items: [
        { ticketId: newTicketResponse.data.id, quantity: 20 },
        { ticketId: newTicketResponse.data.id, quantity: 20 }
      ],
      paymentInfo: { cardNumber: '4111111111111111', expiry: '12/25', cvv: '123' }
    };

    const createResponse = await orderApiClient.createOrder(orderBody);
    await sleep(1000);

    const orderResponse = await orderApiClient.getOrder(createResponse.data.id);
    const reservedTickets = orderResponse.data.reservedTickets;

    // Act : récupérer tous les billets réservés en parallèle
    const ticketRequests = reservedTickets.map(rt =>
      inventoryApiClient.getReservedTicket(rt.id)
    );
    const ticketResponses = await Promise.all(ticketRequests);

    // Assert : vérifier les billets
    expect(ticketResponses).toHaveLength(40);
    ticketResponses.forEach(response => {
      expect(response.status).toBe(200);
    });

    // Act : valider tous les billets en parallèle
    const validationRequests = ticketResponses.map(tr =>
      inventoryApiClient.validateTicket(tr.data.token)
    );
    const validationResponses = await Promise.all(validationRequests);

    // Assert : vérifier les validations
    validationResponses.forEach(response => {
      expect(response.status).toBe(200);
      expect(response.data.isValid).toBe(true);
    });
  });
});

Using Promise.all: Parallel queries allow simulating higher load. It is important to simulate larger volumes in end-to-end testing to uncover potential scaling issues.


5.6 NPM Scripts and Test Data Cleanup

Configuring the package.json of the e2e app

// apps/e2e-tests/package.json
{
  "name": "e2e-tests",
  "version": "1.0.0",
  "scripts": {
    "test": "jest"
  },
  "dependencies": {
    "axios": "^1.0.0"
  },
  "devDependencies": {
    "jest": "^29.0.0"
  }
}

Run end-to-end tests autonomously

# Depuis le répertoire de l'app e2e-tests
cd apps/e2e-tests
npm test

Strategies for cleaning test data

StrategyWhen to use it
CronJob scheduled to clean regularlyStaging environment with progressive accumulation
Cleaning before/after test runsParticularly useful when testing the production DB
Periodic manual deletion of the staging DBWhen the DB is regularly recreated anyway

6. Running Tests in CI/CD Pipelines with GitHub Actions

6.1 Practical CI/CD strategy for Globoticket

Differences between CI and CD

Continuous Integration (CI)Continuous Delivery (CD)
Allows frequent changes from multiple contributorsExtends CI by automating post-merge deployments
Important for Git workflowsReduces manual intervention in the deployment process
Early detection of integration issuesAllows publishing to multiple environments
Automates installs, builds and testsThe end goal is to deliver value more frequently

CI/CD tool categories

  1. Integrated with code managers (GitHub Actions, GitLab CI): easy integration with existing repos
  2. General CI/CD tools (Circle CI, Jenkins): flexibility, can be hosted internally
  3. Provided by cloud providers (Azure DevOps, AWS CodePipeline): tight integration with cloud infrastructure

Globalticket Choice: GitHub Actions

  • Already GitHub users
  • Do not wish to host a CI/CD tool themselves
  • Familiar interface for onboarding new developers

Globalticket CI Pipeline

Pull Request créée
         ↓
    Event déclenché
         ↓
  Workflow CI lancé
         ↓
  ┌──────────────┐
  │ Job: Install  │
  │  npm install  │
  └──────┬───────┘
         ↓
  ┌──────────────┐
  │ Job: Unit    │
  │ npm run      │
  │ unit:test    │
  └──────┬───────┘
         ↓
  ┌──────────────┐
  │ Job: Int.    │
  │ npm run      │
  │ integration: │
  │     test     │
  └──────────────┘

6.2 Anatomy of a GitHub Actions workflow

Main components

ComponentDescription
EventActivity triggering the workflow (e.g. pull request, push on a branch)
WorkflowAutomated process orchestrating one or more jobs
JobSet of steps to execute in the workflow
StepEither an action or a script executed in order on the same runner
ActionCustom app for GitHub Actions platform (reusable)
RunnerServer running workflow jobs

Runners available:

  • Ubuntu Linux
  • Microsoft Windows
  • macOS
  • Possibility of hosting your own runners

Structure of a GitHub Actions workflow file

# .github/workflows/globalticket-ci.yml

name: Globalticket CI

# Déclencheur : sur les pull requests vers main
on:
  pull_request:
    branches: [ main ]
  push:
    branches: [ main ]

jobs:
  # Job unique : build et test
  build:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [20.x]

    steps:
      # Étape 1 : Checkout du code
      - name: Checkout repository
        uses: actions/checkout@v3

      # Étape 2 : Setup de Node.js
      - name: Set up Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      # Étape 3 : Installation des dépendances
      - name: Install dependencies
        run: npm install

Workflow file properties:

PropertyDescription
nameWorkflow name as it appears in the Actions tab of the GitHub repo
onSpecifies the workflow trigger
jobsGroups all workflow jobs
runs-onSpecifies the type of runner (e.g. ubuntu-latest)
stepsBrings together all the stages of a job
usesExecute an action (e.g. actions/checkout@v3)
withSpecifies parameters for an action
runRuns a script or shell command

6.3 Demo: Configure GitHub Actions

Prerequisites:

  • A GitHub account
  • A new repository with permissions for workflows
  • module6-start code copied to main branch

Steps:

  1. In the GitHub repo, go to the Actions tab
  2. Scroll down to the Continuous integration section and select Configure on the Node.js template
  3. The template will be stored in .github/workflows/ at the root of the monorepo
  4. Rename the file to globalticket-ci.yml
  5. Name the workflow Globalticket CI
  6. Configure node-version: 20.x
  7. Commit to the main branch

6.4 Demo: Add test jobs in GitHub Actions

Complete workflow with tests

# .github/workflows/globalticket-ci.yml

name: Globalticket CI

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

jobs:
  install:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20.x'
          cache: 'npm'
      - name: Install NPM dependencies
        run: npm install

  unit-tests:
    runs-on: ubuntu-latest
    needs: install  # Dépend du job install
    steps:
      - uses: actions/checkout@v3
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20.x'
          cache: 'npm'
      - name: Install NPM dependencies
        run: npm install
      - name: Run unit tests
        run: npm run unit:test

  integration-tests:
    runs-on: ubuntu-latest
    needs: install  # Dépend du job install
    steps:
      - uses: actions/checkout@v3
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20.x'
          cache: 'npm'
      - name: Install NPM dependencies
        run: npm install
      - name: Run integration tests
        run: npm run integration:test

Workflow with the CI pipeline

# 1. Créer une nouvelle branche
git checkout -b feature/new-feature

# 2. Faire des changements dans le code

# 3. Committer et pousser
git add .
git commit -m "feat: add new feature"
git push origin feature/new-feature

# 4. Créer une Pull Request sur GitHub
# → Le workflow CI se déclenche automatiquement
# → Si les tests échouent, la PR ne peut pas être fusionnée
# → Si les tests passent, la PR peut être revue et fusionnée

Key benefit: If the CI workflow fails due to a failed test or build, the broken code will not be merged into the main branch. This keeps the main branch in an always deployable state.


6.5 The new and improved Globoticket process

Summary of objectives achieved

Globalticket had four main purposes. Here is how they were achieved:

1. Increased confidence during changes

  • Each pull request requires unit and integration tests to be run
  • Developers can add new features with much higher confidence

2. More frequent mergers and deployments

  • Unit and integration testing in the CI process gives reviewers more confidence during merges
  • End-to-end testing removes the need for tedious manual testing before deployments
  • Release velocity is significantly increased

3. Faster feedback

  • By incorporating TDD into the normal development process, we embed a feedback loop that serves the code throughout its useful life

4. Automated detection of breaking changes

  • By testing API contracts with integration tests we will know if we have broken contracts that clients depend on
  • By running the integration tests in the CI process we will know if something is broken before it is deployed

7. Course Summary

This course covered:

  1. Why test coverage matters: It enables more frequent code delivery, gives greater confidence with deployments, and removes much of the tedious manual testing

  2. Key principles of good test coverage: – Provides high confidence

  • Establishes clear boundaries
  • Runs quickly and reliably
  • Only fails for useful reasons
  1. Unit tests: focused on their use to guide development through adherence to TDD practices

  2. Integration testing: using API contracts to guide integration test suites

  3. End-to-end testing: methods to identify the most critical user journeys in order to implement high-value end-to-end testing

  4. Continuous Delivery Process: Building a Continuous Delivery Process with GitHub Actions to Run Unit and Integration Tests on Every Pull Request


8. References and additional resources

ResourceDescription
Jest DocumentationOfficial Jest documentation
SuperTestHTTP testing library for Node.js
Sequelize CLICLI for Sequelize migrations and seeders
GitHub ActionsGitHub Documentation Actions
ZeroMQDecentralized message queue used in the project
AxiosHTTP client used for end-to-end testing


Search Terms

node.js · microservices · testing · continuous · integration · apis · backend · full-stack · web · test · tests · jest · end-to-end · globoticket · unit · actions · github · seeders · strategy · ci/cd · practical · workflow · api · configure

Interested in this course?

Contact us to book it or get a custom training plan for your team.