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:
- Early bug detection: Finding bugs early in development reduces fixing costs
- Regression prevention: Ensures new changes don't break existing functionality
- Documentation: Tests serve as executable documentation of how code should behave
- Refactoring confidence: Allows developers to improve code without fear of breaking it
- 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:
// 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:
// 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):
# .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:
{
"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:
// 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:
// 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:
- Write a failing test for new functionality
- Implement the minimal code to make the test pass
- 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:
# 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:
// 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:
// 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:
- Dependency injection for testability
- Mocking dependencies to isolate the unit under test
- Testing multiple scenarios (happy path and error cases)
- 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
// 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
# .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
# .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
- Set up a simple CI/CD pipeline with unit tests for a personal project
- Convert an existing project to use Test-Driven Development
- Improve test coverage in a project to meet an 80% threshold
- Implement mock objects for a component with external dependencies
- 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! :)