Xnavi AI

Published on

|

8 min

Unit Testing in the Development Phase of the CI/CD Pipeline

Ayush Shrivastava
Ayush Shrivastava

This blog covers the importance of integrating unit testing early in CI/CD pipelines, sharing practical setups and advanced techniques to improve code quality, reduce bugs, and enhance deployment efficiency, all based on real-world experiences.
Cover Image for Unit Testing in the Development Phase of the CI/CD Pipeline

Let’s get real about unit testing in CI/CD pipelines. This isn’t just a box to check off before pushing code—it’s a game-changer for how we build software. I’m talking from experience: making unit testing a priority completely shifted how I approach development, and it could do the same for you.

The Wake-Up Call

A few years ago, I found myself in the middle of a production crisis—2 AM, trying to decipher logs, with my phone blowing up from angry customer messages. That’s when it hit me: unit testing isn’t just a “nice to have”—it’s essential. I learned the hard way that robust testing is what keeps you afloat when things go south.

The Shift Left Revolution

You’ve probably heard the term “shift left” thrown around. It’s not just a buzzword—it’s a real game-changer. Moving testing to the earliest stages of development isn’t just about catching bugs early; it’s about preventing them from ever surfacing.When we fully embraced this approach, here’s what happened:

  • Our bug-fixing costs dropped by 80%.

  • Code quality improved so much that our tech debt started decreasing.

  • We went from bi-weekly releases to daily deploys with almost zero stress.

Setting Up Your Testing Fortress

To establish a robust unit testing environment within your CI/CD pipeline, let's delve into specifics for two popular CI tools: Jenkins and GitHub Actions.

Jenkins Setup:

groovy

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                sh 'npm install'
            }
        }
        stage('Test') {
            steps {
                sh 'npm test'
            }
        }
    }
    post {
        always {
            junit 'test-results/*.xml'
        }
    }
}

This Jenkins pipeline does more than just run tests:

  • It manages dependencies, ensuring builds are quick.

  • It archives test results, making reporting and analysis easier.

GitHub Actions Setup:

yaml

name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14.x, 16.x, 18.x]
    steps:
    - uses: actions/checkout@v2
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v2
      with:
        node-version: ${{ matrix.node-version }}
    - name: Cache node modules
      uses: actions/cache@v2
      env:
        cache-name: cache-node-modules
      with:
        path: ~/.npm
        key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
        restore-keys: |
          ${{ runner.os }}-build-${{ env.cache-name }}-
          ${{ runner.os }}-build-
          ${{ runner.os }}-
    - name: Install Dependencies
      run: npm ci
    - name: Run Tests
      run: npm test
    - name: Upload coverage
      uses: codecov/codecov-action@v1

This GitHub Actions workflow is designed for efficiency:

  • It uses a matrix strategy to test across multiple Node versions.

  • It caches dependencies to speed up builds.

  • It automatically uploads coverage reports, giving you continuous insight into your code’s quality.

The Monolith Slayer: A Real-World Tale

Last year, we had a legacy monolith that was causing endless headaches. Our mission? Break it down into microservices without any downtime. Sound impossible? Here’s how unit testing came to our rescue:

  • We wrote characterization tests to document the monolith’s behavior.

  • For each new microservice, we wrote unit tests first, then implemented the functionality.

  • We ran both systems in parallel, using unit tests to verify identical outputs.

The results were incredible: test coverage jumped from 20% to 85%, deployment frequency went from bi-weekly to daily, and our mean time to recovery dropped by 60%. It was the difference between 3 AM panic and a good night’s sleep.

Leveling Up: Advanced Testing Techniques

If you’re ready to take your testing game to the next level, here are some advanced techniques to consider:

Property-Based Testing

Instead of writing individual test cases, define properties that your functions should always satisfy.

python

from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_sort_idempotent(lst):
    assert sorted(sorted(lst)) == sorted(lst)

This approach tests a wide range of input sets, increasing the chances of catching edge cases.

Mutation Testing

Introduce controlled mutations to your code to check the effectiveness of your tests.

bash

pip install mutmut
mutmut run --paths-to-mutate=./src

Mutation testing ensures your tests are doing more than just passing—they’re catching actual flaws.

Fuzzing

Use random data to test your system’s handling of unexpected inputs.

python

import atheris
import json
import sys

@atheris.instrument_func
def test_parse_json(input_bytes):
    try:
        json.loads(input_bytes)
    except json.JSONDecodeError:
        pass
    except Exception as e:
        raise Exception(f"Unexpected exception: {e}")

atheris.Setup(sys.argv, test_parse_json)
atheris.Fuzz()

Fuzzing is essential for uncovering hidden vulnerabilities and ensuring robust error handling.

Battling the Testing Demons

Let’s be honest—testing isn’t always smooth sailing. Here’s how to tackle some common problems:

Flaky Tests

  • Implement retry logic.

  • Use deterministic data generation.

  • Mock external dependencies consistently.

Slow Test Suites

  • Parallelize tests.

  • Implement test sharding in CI:

yaml

# In GitHub Actions
jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - run: npm test -- --shard=${{ matrix.shard }}/4

Test Data Management

  • Use factories or fixtures for consistent test data.

  • Implement database transactions to reset state between tests.

python

import pytest
from sqlalchemy.orm import Session

@pytest.fixture(scope="function")
def db_session():
    connection = engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    yield session
    session.close()
    transaction.rollback()
    connection.close()

The Proof is in the Pudding: Measuring Success

After rolling out these practices across multiple projects, here’s what we saw:

  • 80% of bugs caught before they hit production.

  • 40% reduction in code smells (thanks, SonarQube).

  • 25% increase in feature delivery speed.

  • 30% decrease in customer-reported issues.

But the real win? The confidence we feel with every deploy. No more crossing fingers and hoping for the best.

The Testing Mindset: A New Way of Life

Integrating unit testing into your CI/CD pipeline isn’t just about catching bugs—it’s about fostering a culture of quality and confidence. It’s transformed how I approach development, turning those late-night debugging sessions into a thing of the past.Remember, effective unit testing is as much about mindset as it is about tools. Write testable code from the get-go, think critically about edge cases, and always look at your codebase through the lens of verifiability.Here’s a challenge for you: Pick one project this week and boost its test coverage by 10%. Your future self will thank you when that 3 AM production incident doesn’t happen.

The Journey Continues

We’ve covered a lot of ground, from basic setups to advanced techniques. But the learning never stops. I’m constantly experimenting with new testing strategies, and I’d love to hear from you.What’s your most clever unit testing hack? Any stories where a well-placed test saved the day? Share your experiences in the comments—let’s learn from each other and keep leveling up.Here’s to cleaner code, smoother releases, and a few extra hours of sleep. Happy testing, everyone!