Introduction
When I first wired up a CI pipeline for one of my Express APIs, I made the classic mistake: I bolted tests onto the end of the process like an afterthought. It took one bad deploy to a Proxmox-hosted staging VM to convince me that testing needs to live at every stage of delivery, not just at the finish line. That shift in mindset — moving tests as close to the initial commit as possible — is what the industry calls “Shift Left” testing, and it changes how quality is owned across the entire team.
The problem is that saying “write more tests” is easy. Designing a suite that gives you genuine confidence without grinding your pipeline to a halt is much harder. A bloated or flaky test suite creates what I’d call pipeline fatigue: developers start ignoring red builds because they’ve learned the failures are probably noise. This post breaks down how to build a tiered testing strategy that balances speed, coverage, and reliability — keeping your software genuinely production-ready at every stage of its lifecycle.
The Modern Testing Pyramid: A Practical Re-evaluation
The Testing Pyramid is still the right mental model, but it’s worth revisiting with a focus on practical execution rather than theoretical coverage percentages. The goal is a fast feedback loop that helps developers rather than blocking them.
Unit Testing: The Foundation of Speed
Unit tests should form the bulk of your suite. They test individual functions or classes in isolation, using mocks or stubs for external dependencies like databases or APIs. In a real delivery pipeline, unit tests need to run in seconds. If a developer has to wait ten minutes for unit tests to pass before pushing, they’ll stop running them locally — and then you’ve already lost the benefit.
Aim for thorough coverage of business logic and edge cases, but don’t obsess over testing boilerplate like simple getters and setters. For JavaScript and TypeScript projects, Jest is the obvious choice and integrates cleanly with Node.js and Express. Here’s a simple example of a unit test for a utility function in a TypeScript Express service:
// src/utils/calculateDiscount.ts
export function calculateDiscount(price: number, discountPercent: number): number {
if (discountPercent < 0 || discountPercent > 100) {
throw new RangeError('Discount must be between 0 and 100');
}
return parseFloat((price * (1 - discountPercent / 100)).toFixed(2));
}
// src/utils/calculateDiscount.test.ts
import { calculateDiscount } from './calculateDiscount';
describe('calculateDiscount', () => {
it('applies a standard discount correctly', () => {
expect(calculateDiscount(100, 20)).toBe(80.00);
});
it('returns the full price when discount is 0', () => {
expect(calculateDiscount(50, 0)).toBe(50.00);
});
it('throws a RangeError for invalid discount values', () => {
expect(() => calculateDiscount(100, 110)).toThrow(RangeError);
});
});The key thing to notice here is that this test runs with zero network calls, zero database connections, and no Docker containers needed. It executes in milliseconds. That’s exactly the property you want from a unit test — fast, deterministic, and something a developer can run on every file save inside their IDE.
Integration Testing: Validating the “In-Between”
Integration tests are where many teams struggle. They verify that different modules or services work together correctly — for instance, testing the interaction between an Express route handler and a MongoDB collection, or between two microservices communicating via a REST API. Unlike unit tests, these require a semi-functional environment. In my homelab setup, I use Docker Compose to spin up a real MongoDB instance for integration test runs, rather than mocking the database layer entirely. Mocking Mongoose at the unit level is fine, but at some point you need to verify that your actual queries behave correctly against a real database engine.
The practical payoff is significant: integration tests catch the class of bugs that unit tests structurally cannot — things like a Mongoose schema validation mismatch, or a MongoDB index that causes a query to time out under realistic data volume.
End-to-End (E2E) Testing: The User’s Perspective
E2E tests simulate full user journeys through the application. Because they are slow and prone to flakiness from network latency or UI changes, they should be used sparingly. Focus on the happy paths that are genuinely critical to the business — a user completing checkout, creating an account, or submitting a form that triggers a backend workflow. These tests belong at the top of the pyramid precisely because they’re expensive to run and maintain. The pyramid shape is a reminder: few E2E tests, many unit tests, with integration tests filling the middle.
Integrating Testing into the CI/CD Pipeline
A testing strategy is only as good as its automation. Tests that developers have to remember to run manually will be forgotten. The goal is to enforce testing at specific gates in the pipeline without requiring any manual intervention.
The Commit Stage (Pre-Merge)
Before code is merged into the main branch, a focused subset of tests must pass automatically. In GitHub Actions or GitLab CI, this is straightforward to configure. This stage should cover static analysis and linting (catching syntax errors and style violations before they become code review noise), dependency vulnerability scanning using tools like Snyk or Trivy, and the full unit test suite. The entire commit-stage pipeline should aim to complete in under five minutes — anything longer and developers start looking for ways to skip it.
The Deployment Stage (Post-Merge)
Once code is merged and a build artifact is created, the pipeline triggers more intensive tests in a staging environment. This is where Infrastructure as Code becomes genuinely valuable. Using Terraform or Pulumi, you can provision a mirror of production, run your integration and E2E tests against it, and then tear it down to avoid idle infrastructure costs. In my homelab, I approximate this with Proxmox VMs that are created from a base template via cloud-init at the start of a test run and destroyed at the end — a lightweight version of the same principle.
The Smoke Test
After a deployment to production — whether via a canary release or a blue/green swap — a smoke test runs immediately. This is a small, high-level suite that checks whether the application is up and responding correctly to basic requests. If the smoke test fails, the pipeline should trigger an automatic rollback to the previous stable version. The smoke test is not about thoroughness; it’s about detecting catastrophic failure within seconds of a deployment landing.
Handling Flakiness and Maintenance
Nothing undermines a testing culture faster than a flaky test — one that passes and fails inconsistently without any change to the underlying code. Once developers learn that a red pipeline might just be noise, they stop trusting it. At that point your entire testing investment starts working against you.
There are three practical rules I follow to keep flakiness under control. First, quarantine flaky tests: if a test fails inconsistently, pull it out of the main pipeline into a separate suite. It should not block deployments until it’s been diagnosed, fixed, and proven stable over multiple runs. Second, use automated retries with genuine caution. Some CI tools allow you to retry a failed test automatically, which can help with transient network hiccups in integration tests, but it must never become a band-aid for a poorly written test. If a test needs to be retried to pass, that’s a signal, not a solution. Third, manage test data carefully. Integration and E2E tests frequently fail because of dirty state left over from a previous run. Every test run should either start with a clean database — a fresh Docker container is the cleanest approach — or include a teardown phase that restores the environment to a known state before the next test executes.
Practical Example: The Testing Manifest
For teams with multiple services and multiple contributors, a Testing Manifest is a straightforward way to enforce consistency. It’s a document or configuration file that defines exactly what a service must provide before it can be considered adequately tested. The specifics will vary by organisation, but a reasonable baseline for a Node.js microservice might look like this:
- Unit test coverage: A minimum of 80% line coverage across business logic, enforced by Jest’s coverage threshold configuration so the pipeline fails automatically if coverage drops below the bar.
- API documentation: Automatically generated OpenAPI/Swagger docs that are validated against the actual implementation on every build — not a manually maintained file that drifts out of sync.
- Contract testing: If Service A depends on Service B’s API, a contract test (using a tool like Pact) verifies that Service B hasn’t changed its response format in a way that would silently break Service A. This is especially important in microservices architectures where teams deploy independently.
- Performance baseline: A test that fails if a critical endpoint exceeds an agreed response time threshold under simulated load. In an Express API, even a missing MongoDB index can cause a previously fast endpoint to degrade significantly as data grows — catching that in CI rather than production is worth the effort.
Conclusion
Testing is not a gate to be cleared before release; it’s the mechanism that makes fast, confident delivery possible in the first place. A tiered strategy — many fast unit tests, targeted integration checks, a small number of E2E scenarios, and automated gates at each stage of the pipeline — shifts quality from a final inspection into a continuous, ongoing property of the codebase. This approach reduces your mean time to recovery when something does go wrong, and it means your delivery pipeline becomes a source of genuine confidence rather than a source of anxiety.
The most useful thing you can do right now is audit your current pipeline and find the one test suite causing the most delays or false positives. Fix it, quarantine it, or replace it with something more reliable. A smaller suite of tests that developers actually trust is worth more than a large suite that everyone has learned to ignore.

One thought on “Shift Left in Action: Practical Testing Strategies for CI/CD Pipelines”