Skip to main content

CI/CD Unit Testing

Introduction

Unit testing is a foundational practice in modern software development that involves testing individual components or "units" of code in isolation. When integrated into a Continuous Integration and Continuous Deployment (CI/CD) pipeline, unit testing becomes an automated, systematic approach to ensuring code quality at every stage of development.

In this guide, we'll explore how unit testing fits into the CI/CD process, why it's crucial for maintaining reliable software, and how to implement it effectively in your projects.

What is Unit Testing?

A unit test is a piece of code that verifies the behavior of a specific "unit" of your application code. A unit is typically the smallest testable part of an application, such as:

  • A single function or method
  • A class or module
  • A small group of closely related functions

Unit tests validate that these components work correctly in isolation, independent of other parts of the system.

Key Characteristics of Unit Tests

  • Fast execution: Unit tests should run quickly (milliseconds to seconds)
  • Independent: Each test should not depend on other tests or external systems
  • Repeatable: Tests should produce the same results every time they run
  • Self-validating: Tests should automatically determine if they pass or fail
  • Thorough: Tests should cover both normal cases and edge cases

Why Integrate Unit Testing into CI/CD?

CI/CD pipelines automate the building, testing, and deployment of software. Here's why unit testing is essential in this process:

  1. Early bug detection: Finding bugs early in development reduces fixing costs
  2. Regression prevention: Ensures new changes don't break existing functionality
  3. Documentation: Tests serve as executable documentation of how code should behave
  4. Refactoring confidence: Allows developers to improve code without fear of breaking it
  5. Code quality metrics: Test coverage data helps evaluate code quality

Setting Up Unit Testing in Your CI/CD Pipeline

Let's walk through implementing unit testing in a CI/CD pipeline:

Step 1: Choose a Testing Framework

Select a testing framework appropriate for your programming language:

  • JavaScript/Node.js: Jest, Mocha
  • Python: pytest, unittest
  • Java: JUnit, TestNG
  • C#: NUnit, MSTest

For this guide, we'll use Jest for JavaScript examples.

Step 2: Write Your First Unit Test

Let's create a simple function and a test for it:

javascript
// math.js
function add(a, b) {
return a + b;
}

function subtract(a, b) {
return a - b;
}

module.exports = { add, subtract };

Now, let's write a test file:

javascript
// math.test.js
const { add, subtract } = require('./math');

test('add function correctly adds two numbers', () => {
expect(add(2, 3)).toBe(5);
expect(add(0, 0)).toBe(0);
expect(add(-1, 1)).toBe(0);
});

test('subtract function correctly subtracts second number from first', () => {
expect(subtract(5, 3)).toBe(2);
expect(subtract(0, 0)).toBe(0);
expect(subtract(-1, -1)).toBe(0);
});

Step 3: Configure Your CI/CD Pipeline

Here's how to set up unit testing in GitHub Actions (a popular CI/CD platform):

yaml
# .github/workflows/ci.yml
name: CI

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

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16'

- name: Install dependencies
run: npm ci

- name: Run unit tests
run: npm test

- name: Generate test coverage report
run: npm run test:coverage

Step 4: Configure Your Package.json

For a Node.js project using Jest, your package.json might include:

json
{
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage"
},
"devDependencies": {
"jest": "^29.5.0"
}
}

Best Practices for CI/CD Unit Testing

1. Follow the Testing Pyramid

Unit tests should form the broad base of your testing strategy, with fewer integration and end-to-end tests.

2. Aim for High Test Coverage

Test coverage measures how much of your code is exercised by tests. While 100% coverage isn't always necessary, aim for high coverage of critical paths:

javascript
// Example of configuring coverage thresholds in Jest
// jest.config.js
module.exports = {
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};

3. Keep Tests Fast

Slow tests defeat the purpose of CI/CD by delaying feedback. If a test requires slow external resources, consider using mocks:

javascript
// Example of mocking an API call in Jest
jest.mock('./api');
const api = require('./api');

test('fetches user data', async () => {
// Set up the mock implementation
api.fetchUser.mockResolvedValue({ id: 1, name: 'John' });

// Call the function that uses the API
const user = await getUserData(1);

// Verify results
expect(api.fetchUser).toHaveBeenCalledWith(1);
expect(user.name).toBe('John');
});

4. Implement Test-Driven Development (TDD)

TDD follows this cycle:

  1. Write a failing test for new functionality
  2. Implement the minimal code to make the test pass
  3. Refactor the code while keeping tests passing

This approach ensures all code is testable and tested.

5. Set Up Automated Test Reports

Configure your CI/CD pipeline to generate and store test reports:

yaml
# GitHub Actions example with test report artifact
- name: Generate test report
run: npm run test:coverage

- name: Upload test report
uses: actions/upload-artifact@v3
with:
name: test-report
path: coverage/

Real-World Example: A User Authentication Service

Let's look at a more complex example of unit testing a user authentication service:

javascript
// auth.js
class AuthService {
constructor(userRepository, tokenGenerator) {
this.userRepository = userRepository;
this.tokenGenerator = tokenGenerator;
}

async login(username, password) {
const user = await this.userRepository.findByUsername(username);

if (!user) {
throw new Error('User not found');
}

const isPasswordValid = await this.verifyPassword(password, user.passwordHash);

if (!isPasswordValid) {
throw new Error('Invalid password');
}

return {
token: this.tokenGenerator.generate(user),
user: {
id: user.id,
username: user.username,
email: user.email
}
};
}

async verifyPassword(password, passwordHash) {
// Password verification logic
return password === passwordHash; // Simplified for example
}
}

module.exports = AuthService;

And here's how we would test it:

javascript
// auth.test.js
const AuthService = require('./auth');

describe('AuthService', () => {
// Setup mock dependencies
const mockUserRepo = {
findByUsername: jest.fn()
};

const mockTokenGenerator = {
generate: jest.fn()
};

let authService;

beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
authService = new AuthService(mockUserRepo, mockTokenGenerator);
});

describe('login', () => {
test('should return token and user when credentials are valid', async () => {
// Arrange
const mockUser = {
id: 1,
username: 'testuser',
email: '[email protected]',
passwordHash: 'password123'
};

mockUserRepo.findByUsername.mockResolvedValue(mockUser);
mockTokenGenerator.generate.mockReturnValue('fake-jwt-token');

// Mock the password verification method
authService.verifyPassword = jest.fn().mockResolvedValue(true);

// Act
const result = await authService.login('testuser', 'password123');

// Assert
expect(mockUserRepo.findByUsername).toHaveBeenCalledWith('testuser');
expect(authService.verifyPassword).toHaveBeenCalledWith('password123', 'password123');
expect(mockTokenGenerator.generate).toHaveBeenCalledWith(mockUser);

expect(result).toEqual({
token: 'fake-jwt-token',
user: {
id: 1,
username: 'testuser',
email: '[email protected]'
}
});
});

test('should throw error when user does not exist', async () => {
// Arrange
mockUserRepo.findByUsername.mockResolvedValue(null);

// Act & Assert
await expect(authService.login('nonexistent', 'password'))
.rejects.toThrow('User not found');

expect(mockUserRepo.findByUsername).toHaveBeenCalledWith('nonexistent');
expect(mockTokenGenerator.generate).not.toHaveBeenCalled();
});

test('should throw error when password is invalid', async () => {
// Arrange
const mockUser = {
id: 1,
username: 'testuser',
passwordHash: 'correcthash'
};

mockUserRepo.findByUsername.mockResolvedValue(mockUser);

// Mock the password verification method to return false
authService.verifyPassword = jest.fn().mockResolvedValue(false);

// Act & Assert
await expect(authService.login('testuser', 'wrongpassword'))
.rejects.toThrow('Invalid password');

expect(authService.verifyPassword).toHaveBeenCalledWith('wrongpassword', 'correcthash');
expect(mockTokenGenerator.generate).not.toHaveBeenCalled();
});
});
});

This test suite demonstrates:

  1. Dependency injection for testability
  2. Mocking dependencies to isolate the unit under test
  3. Testing multiple scenarios (happy path and error cases)
  4. Verifying interactions between components

Common Pitfalls and How to Avoid Them

1. Testing Implementation Details

Problem: Tests break when implementation changes, even if functionality remains the same.

Solution: Test behavior (inputs and outputs) rather than implementation details.

2. Flaky Tests

Problem: Tests that sometimes pass and sometimes fail without code changes.

Solution:

  • Avoid dependencies on external services
  • Don't rely on timing-sensitive operations
  • Reset state between tests

3. Overmocking

Problem: Tests with too many mocks may not catch integration issues.

Solution: Use real implementations for simple dependencies, mock only complex or external dependencies.

4. Inadequate Coverage

Problem: Critical paths missing test coverage.

Solution: Use coverage reports to identify gaps and focus on critical business logic first.

Integrating with Different CI/CD Platforms

Jenkins

groovy
// Jenkinsfile
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'npm install'
}
}
stage('Test') {
steps {
sh 'npm test'
}
post {
always {
junit 'reports/junit.xml'
publishHTML(target: [
reportDir: 'coverage',
reportFiles: 'index.html',
reportName: 'Coverage Report'
])
}
}
}
}
}

GitLab CI

yaml
# .gitlab-ci.yml
stages:
- test

unit_tests:
stage: test
image: node:16
script:
- npm ci
- npm test
artifacts:
paths:
- coverage/
reports:
junit: reports/junit.xml

CircleCI

yaml
# .circleci/config.yml
version: 2.1
jobs:
test:
docker:
- image: cimg/node:16.13
steps:
- checkout
- run: npm ci
- run: npm test
- store_test_results:
path: reports
- store_artifacts:
path: coverage
destination: test-coverage

workflows:
version: 2
build_and_test:
jobs:
- test

Summary

Unit testing in CI/CD pipelines is an essential practice for delivering reliable software. By automating tests as part of your build and deployment process, you can:

  • Catch bugs early
  • Prevent regressions
  • Maintain high code quality
  • Build confidence in your codebase
  • Document expected behavior

Remember that effective unit testing requires a commitment to writing testable code and maintaining your test suite. The investment pays off through reduced debugging time, fewer production issues, and the ability to evolve your codebase safely.

Additional Resources

  • Books:

    • "Test-Driven Development: By Example" by Kent Beck
    • "Working Effectively with Legacy Code" by Michael Feathers
  • Online Courses:

    • "JavaScript Testing Practices and Principles" on Frontend Masters
    • "Test-Driven Development with Python" on O'Reilly Learning
  • Tools to Explore:

    • Stryker Mutator: For mutation testing
    • Wallaby.js: For real-time test feedback in your editor
    • SonarQube: For code quality and test coverage analysis

Practice Exercises

  1. Set up a simple CI/CD pipeline with unit tests for a personal project
  2. Convert an existing project to use Test-Driven Development
  3. Improve test coverage in a project to meet an 80% threshold
  4. Implement mock objects for a component with external dependencies
  5. Create a test suite for an authentication system using the example above as inspiration


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