Test Plans

Continuous Testing Strategies for CI/CD

A comprehensive guide to continuous testing strategies in CI/CD pipelines, covering the testing pyramid, shift-left testing, test selection by pipeline stage, quality gates, test impact analysis, progressive validation, risk-based testing, and building a testing culture that scales with delivery speed.

Continuous Testing Strategies for CI/CD

Overview

Continuous testing is not "run all the tests on every commit." That approach collapses under its own weight as test suites grow -- a 45-minute test suite on every pull request means developers wait 45 minutes for feedback, which means they batch larger changes, which means more failures, which means longer debugging sessions. Continuous testing is about running the right tests at the right time in the right stage of the pipeline. Unit tests on every commit, integration tests on merge, end-to-end tests on deployment to staging, and manual exploratory tests before release. Each stage adds confidence while keeping feedback fast.

I have built CI/CD pipelines for teams shipping 10 times per day and teams shipping monthly. The testing strategy is different for each, but the principles are the same: fast feedback loops, progressive confidence, and test selection based on risk. Azure DevOps provides the pipeline stages, quality gates, and test analytics to implement these strategies. The tooling is available -- what most teams lack is a deliberate strategy for which tests to run where. This article provides that strategy.

Prerequisites

  • An Azure DevOps organization with Azure Pipelines and Azure Test Plans
  • A multi-stage YAML pipeline (or willingness to create one)
  • Automated tests at multiple levels (unit, integration, end-to-end)
  • Familiarity with pipeline stages, jobs, and conditions
  • Understanding of test types and the testing pyramid
  • Node.js 18+ for example implementations

The Testing Pyramid in CI/CD

The testing pyramid defines the ratio of test types. In a CI/CD context, it also defines where each type runs:

                    /\
                   /  \
                  / E2E \        ← Release stage: Full E2E suite
                 /________\         (slow, expensive, high confidence)
                /          \
               / Integration \   ← Staging stage: Integration + API tests
              /______________\      (moderate speed, real dependencies)
             /                \
            /    Unit Tests    \ ← Commit stage: Unit + lint + static analysis
           /____________________\   (fast, isolated, every commit)

Layer 1: Commit Stage (Every Push)

Fast feedback. Runs in under 5 minutes. Includes:

  • Unit tests (Jest, pytest, xUnit)
  • Linting (ESLint, Prettier)
  • Static analysis (SonarQube, CodeQL)
  • Type checking (TypeScript, mypy)
  • Build verification (does it compile?)
trigger:
  branches:
    include:
      - "*"

stages:
  - stage: Commit
    displayName: Commit Validation
    jobs:
      - job: UnitTests
        pool:
          vmImage: ubuntu-latest
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: "20.x"

          - script: npm ci
            displayName: Install

          - script: npm run lint
            displayName: Lint

          - script: npm test -- --ci --coverage
            displayName: Unit tests
            env:
              CI: "true"

          - task: PublishTestResults@2
            inputs:
              testResultsFormat: JUnit
              testResultsFiles: "**/junit.xml"
              testRunTitle: "Unit Tests"
            condition: always()

          - task: PublishCodeCoverageResults@2
            inputs:
              summaryFileLocation: "**/cobertura-coverage.xml"
            condition: always()

The commit stage answers one question: "Did I break anything obvious?" If unit tests pass and the code lints cleanly, the change is probably safe to merge. If not, the developer gets feedback within minutes while the context is still fresh.

Layer 2: Integration Stage (On Merge to Main)

Moderate speed. Runs in 10-20 minutes. Tests real integrations:

  • API tests against actual services
  • Database integration tests
  • Message queue consumer tests
  • Third-party API integration tests (with sandboxes)
  - stage: Integration
    displayName: Integration Tests
    dependsOn: Commit
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - job: IntegrationTests
        pool:
          vmImage: ubuntu-latest
        services:
          postgres:
            image: postgres:16
            ports:
              - 5432:5432
            env:
              POSTGRES_DB: testdb
              POSTGRES_USER: testuser
              POSTGRES_PASSWORD: testpass

        steps:
          - script: npm ci
            displayName: Install

          - script: npm run db:migrate
            displayName: Run migrations
            env:
              DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb

          - script: npm run db:seed
            displayName: Seed test data
            env:
              DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb

          - script: npm run test:integration
            displayName: Integration tests
            env:
              DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
              API_BASE_URL: http://localhost:3000
            continueOnError: true

          - task: PublishTestResults@2
            inputs:
              testResultsFormat: JUnit
              testResultsFiles: "**/integration-results.xml"
              testRunTitle: "Integration Tests"
            condition: always()

Layer 3: Deployment Stage (After Deploy to Staging)

Runs after deployment to a real environment. Includes:

  • End-to-end UI tests (Playwright, Selenium)
  • Smoke tests against the deployed application
  • Performance baseline tests
  • Security scans
  - stage: E2E
    displayName: End-to-End Tests
    dependsOn: DeployStaging
    jobs:
      - job: E2ETests
        pool:
          vmImage: ubuntu-latest
        steps:
          - script: npm ci
            workingDirectory: e2e-tests
            displayName: Install

          - script: npx playwright install --with-deps chromium
            workingDirectory: e2e-tests
            displayName: Install browsers

          - script: npx playwright test --project=chromium
            workingDirectory: e2e-tests
            displayName: E2E tests
            env:
              BASE_URL: https://staging.example.com
            continueOnError: true

          - task: PublishTestResults@2
            inputs:
              testResultsFormat: JUnit
              testResultsFiles: "**/e2e-results.xml"
              testRunTitle: "E2E Tests - Staging"
            condition: always()

          - task: PublishBuildArtifacts@1
            inputs:
              pathToPublish: e2e-tests/playwright-report
              artifactName: e2e-report
            condition: always()

Layer 4: Release Validation (Before Production)

The final gate. Includes:

  • Full cross-browser E2E suite
  • Performance load tests
  • Manual exploratory testing sessions
  • Accessibility verification
  • Security penetration tests

This stage is often gated by manual approval in Azure DevOps environments.

Shift-Left Testing

Shift-left means moving testing earlier in the development lifecycle. Instead of testing after development is complete, test during and before development.

Test Before Code: Test-Driven Development in CI

Write test cases before implementation. In Azure DevOps, link test cases to user stories at the beginning of the sprint. When the developer starts a story, test cases already exist -- they define the acceptance criteria.

Pipeline enforcement:

# Quality gate: Every user story must have at least one test case
- script: |
    STORIES_WITHOUT_TESTS=$(node check-coverage.js --type=requirement)
    if [ "$STORIES_WITHOUT_TESTS" -gt "0" ]; then
      echo "##vso[task.logissue type=warning]$STORIES_WITHOUT_TESTS user stories have no test cases"
    fi
  displayName: Check requirement test coverage

Static Analysis as First Test

Static analysis catches bugs before any test executes:

steps:
  - script: npx eslint src/ --max-warnings=0
    displayName: Lint (zero warnings)

  - script: npx tsc --noEmit
    displayName: Type check

  - script: npx audit-ci --moderate
    displayName: Dependency vulnerability check

PR-Level Quality Gates

Configure branch policies that require passing tests before merging:

  1. Navigate to Project Settings > Repositories > Policies
  2. Select the target branch (main)
  3. Add a build validation policy pointing to your commit stage pipeline
  4. Set "Trigger" to "Automatic" and "Policy requirement" to "Required"

Now every PR must pass unit tests, linting, and static analysis before it can be merged. This catches issues at the earliest possible point.

Quality Gates by Pipeline Stage

Quality gates are pass/fail checkpoints that prevent bad code from advancing to the next stage.

Commit Stage Gates

Gate: Unit test pass rate >= 100%
Gate: Code coverage >= 70% (or no decrease from baseline)
Gate: Zero linting errors
Gate: Zero high/critical security vulnerabilities
Gate: Build succeeds

Integration Stage Gates

Gate: All integration tests pass
Gate: API response times within thresholds
Gate: Database migration succeeds
Gate: No breaking API contract changes

Deployment Stage Gates

Gate: Smoke tests pass on deployed environment
Gate: E2E critical path tests pass
Gate: Performance test p95 latency < threshold
Gate: No new error log patterns detected
Gate: Health check returns healthy

Implementing Quality Gates in YAML

  - stage: QualityGate
    dependsOn: [UnitTests, IntegrationTests]
    condition: always()
    jobs:
      - job: EvaluateQuality
        steps:
          - script: |
              echo "Evaluating quality gates..."

              # Check test pass rate
              PASS_RATE=$(node scripts/get-pass-rate.js)
              echo "Test pass rate: ${PASS_RATE}%"
              if [ "$PASS_RATE" -lt "95" ]; then
                echo "##vso[task.logissue type=error]Pass rate ${PASS_RATE}% below 95% threshold"
                echo "##vso[task.complete result=Failed;]Quality gate failed: pass rate"
                exit 1
              fi

              # Check coverage
              COVERAGE=$(node scripts/get-coverage.js)
              echo "Code coverage: ${COVERAGE}%"
              if [ "$COVERAGE" -lt "70" ]; then
                echo "##vso[task.logissue type=error]Coverage ${COVERAGE}% below 70% threshold"
                echo "##vso[task.complete result=Failed;]Quality gate failed: coverage"
                exit 1
              fi

              echo "All quality gates passed"
            displayName: Evaluate quality gates

Test Selection Strategies

Running every test on every change is wasteful. Smart test selection runs only the tests that matter for the current change.

Changed-File Based Selection

Map source files to test files and run only the relevant tests:

// scripts/select-tests.js
var execSync = require("child_process").execSync;

// Get changed files from git
var changedFiles = execSync("git diff --name-only HEAD~1")
  .toString()
  .trim()
  .split("\n");

console.log("Changed files: " + changedFiles.length);

// Map source files to test files
var testFiles = [];
changedFiles.forEach(function (file) {
  if (file.startsWith("src/") && file.endsWith(".js")) {
    var testFile = file
      .replace("src/", "tests/")
      .replace(".js", ".test.js");
    testFiles.push(testFile);
  }
});

// Also include tests that import changed modules
var fs = require("fs");
var allTestFiles = execSync('find tests -name "*.test.js"')
  .toString()
  .trim()
  .split("\n");

allTestFiles.forEach(function (testFile) {
  if (testFiles.indexOf(testFile) >= 0) { return; }

  var content = fs.readFileSync(testFile, "utf8");
  var isAffected = changedFiles.some(function (changed) {
    var moduleName = changed.replace("src/", "").replace(".js", "");
    return content.indexOf(moduleName) >= 0;
  });

  if (isAffected) {
    testFiles.push(testFile);
  }
});

var unique = testFiles.filter(function (f, i) { return testFiles.indexOf(f) === i; });
console.log("Selected tests: " + unique.length);
unique.forEach(function (f) { console.log("  " + f); });

// Output for pipeline consumption
console.log("##vso[task.setvariable variable=SELECTED_TESTS]" + unique.join(" "));
- script: node scripts/select-tests.js
  displayName: Select impacted tests

- script: npx jest $(SELECTED_TESTS) --ci
  displayName: Run impacted tests
  condition: ne(variables['SELECTED_TESTS'], '')

Risk-Based Test Selection

Prioritize tests based on code change risk:

// scripts/risk-based-selection.js
var execSync = require("child_process").execSync;

var changedFiles = execSync("git diff --name-only HEAD~1").toString().trim().split("\n");

// Risk scoring
var HIGH_RISK_PATHS = ["src/auth/", "src/payment/", "src/database/"];
var MEDIUM_RISK_PATHS = ["src/api/", "src/middleware/"];

var riskLevel = "low";

changedFiles.forEach(function (file) {
  HIGH_RISK_PATHS.forEach(function (path) {
    if (file.startsWith(path)) { riskLevel = "high"; }
  });
  if (riskLevel !== "high") {
    MEDIUM_RISK_PATHS.forEach(function (path) {
      if (file.startsWith(path)) { riskLevel = "medium"; }
    });
  }
});

console.log("Risk level: " + riskLevel);
console.log("##vso[task.setvariable variable=RISK_LEVEL]" + riskLevel);

// Test selection based on risk
var testStrategy = {
  low: "unit",
  medium: "unit,integration",
  high: "unit,integration,e2e",
};

console.log("Test strategy: " + testStrategy[riskLevel]);
console.log("##vso[task.setvariable variable=TEST_STRATEGY]" + testStrategy[riskLevel]);
- script: node scripts/risk-based-selection.js
  displayName: Assess change risk

- script: npm run test:unit
  displayName: Unit tests
  condition: always()

- script: npm run test:integration
  displayName: Integration tests
  condition: or(eq(variables['RISK_LEVEL'], 'medium'), eq(variables['RISK_LEVEL'], 'high'))

- script: npm run test:e2e
  displayName: E2E tests
  condition: eq(variables['RISK_LEVEL'], 'high')

Progressive Validation

Progressive validation adds confidence incrementally as code moves toward production:

Commit    → Unit tests pass       → 60% confidence
PR merge  → Integration tests pass → 80% confidence
Staging   → E2E tests pass        → 90% confidence
Canary    → 5% traffic, monitoring → 95% confidence
Full roll → 100% traffic          → 99% confidence

Canary Deployment with Test Validation

  - stage: CanaryDeploy
    dependsOn: StagingTests
    jobs:
      - deployment: Canary
        environment: production
        strategy:
          canary:
            increments: [5, 25, 50, 100]
            preDeploy:
              steps:
                - script: echo "Deploying canary to 5% of traffic"
            routeTraffic:
              steps:
                - script: |
                    # Update load balancer weights
                    echo "Routing $(strategy.increment)% traffic to canary"
            postRouteTraffic:
              steps:
                - script: |
                    # Run smoke tests against canary
                    npm run test:smoke -- --base-url=https://canary.example.com
                  displayName: Canary smoke tests

                - script: |
                    # Check error rates
                    ERROR_RATE=$(node scripts/check-error-rate.js canary 5)
                    echo "Canary error rate: ${ERROR_RATE}%"
                    if [ $(echo "$ERROR_RATE > 1.0" | bc) -eq 1 ]; then
                      echo "Error rate too high, rolling back"
                      exit 1
                    fi
                  displayName: Validate error rate

Complete Working Example

A comprehensive multi-stage pipeline implementing the full continuous testing strategy:

trigger:
  branches:
    include:
      - main
      - feature/*

pr:
  branches:
    include:
      - main

pool:
  vmImage: ubuntu-latest

variables:
  CI: "true"

stages:
  # Stage 1: Fast feedback on every commit
  - stage: Commit
    displayName: Commit Validation
    jobs:
      - job: FastChecks
        displayName: Lint + Unit Tests
        timeoutInMinutes: 10
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: "20.x"

          - script: npm ci
            displayName: Install

          - script: npm run lint
            displayName: Lint

          - script: npm test -- --ci --coverage --forceExit
            displayName: Unit tests
            continueOnError: true

          - task: PublishTestResults@2
            inputs:
              testResultsFormat: JUnit
              testResultsFiles: "**/junit.xml"
              testRunTitle: "Unit Tests"
            condition: always()

          - task: PublishCodeCoverageResults@2
            inputs:
              summaryFileLocation: "**/cobertura-coverage.xml"
            condition: always()

  # Stage 2: Integration tests on main branch only
  - stage: Integration
    displayName: Integration Tests
    dependsOn: Commit
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - job: APITests
        displayName: API Integration Tests
        timeoutInMinutes: 20
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: "20.x"

          - script: npm ci
            displayName: Install

          - script: |
              npm start &
              sleep 5
              curl -sf http://localhost:3000/api/health
            displayName: Start API
            env:
              NODE_ENV: test

          - script: npm run test:api -- --ci
            displayName: API tests
            env:
              API_BASE_URL: http://localhost:3000/api
            continueOnError: true

          - task: PublishTestResults@2
            inputs:
              testResultsFormat: JUnit
              testResultsFiles: "**/api-results.xml"
              testRunTitle: "API Integration Tests"
            condition: always()

  # Stage 3: Deploy to staging
  - stage: DeployStaging
    displayName: Deploy to Staging
    dependsOn: Integration
    condition: succeeded()
    jobs:
      - deployment: Staging
        environment: staging
        strategy:
          runOnce:
            deploy:
              steps:
                - script: echo "Deploying to staging..."
                  displayName: Deploy

  # Stage 4: E2E tests against staging
  - stage: E2E
    displayName: E2E Validation
    dependsOn: DeployStaging
    jobs:
      - job: PlaywrightTests
        displayName: Playwright E2E
        timeoutInMinutes: 30
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: "20.x"

          - script: npm ci
            workingDirectory: e2e-tests

          - script: npx playwright install --with-deps chromium
            workingDirectory: e2e-tests

          - script: npx playwright test --project=chromium
            workingDirectory: e2e-tests
            displayName: E2E tests
            env:
              BASE_URL: https://staging.example.com
            continueOnError: true

          - task: PublishTestResults@2
            inputs:
              testResultsFormat: JUnit
              testResultsFiles: "**/e2e-junit.xml"
              testRunTitle: "E2E - Staging"
            condition: always()

  # Stage 5: Quality gate
  - stage: QualityGate
    displayName: Release Quality Gate
    dependsOn: [Commit, Integration, E2E]
    condition: always()
    jobs:
      - job: Evaluate
        steps:
          - script: |
              echo "=== Release Quality Gate ==="
              echo "All automated test stages evaluated"
              echo "Gate: PASSED"
            displayName: Quality gate evaluation

Common Issues and Troubleshooting

Pipeline Takes Too Long for PR Feedback

If the commit stage takes more than 10 minutes, developers lose patience and stop waiting. Solutions: (a) run only impacted tests on PRs using test selection, (b) shard unit tests across parallel agents, (c) cache node_modules with the Cache@2 task, (d) move integration tests out of the PR pipeline into the merge pipeline.

Tests Pass in One Stage But Fail in the Next

Different stages use different environments, configurations, and data. Ensure environment variables, feature flags, and test data are consistent or deliberately different. Use variable groups to manage environment-specific configurations and log the effective configuration at the start of each test job.

Quality Gates Are Too Strict or Too Lenient

Strict gates that block every deployment for a minor test issue cause alert fatigue and workarounds. Lenient gates that never block anything provide no value. Start with lenient gates (warnings), analyze the data for 2-4 weeks, then tighten thresholds based on observed baselines. A coverage threshold of 70% is reasonable if your current coverage is 75%. A threshold of 90% when you are at 72% will just be ignored.

Manual Testing Bottleneck Before Release

If manual exploratory testing is the only gate between staging and production, it becomes a bottleneck. Reduce the manual testing surface by automating the most critical exploratory scenarios as E2E tests. Use manual testing for genuinely exploratory work -- new features, UX evaluation, edge cases -- not for regression verification that automated tests should cover.

Best Practices

  • Keep the commit stage under 5 minutes. This is non-negotiable. Developers need fast feedback on every push. If your unit test suite takes longer, parallelize it, select impacted tests, or split the suite. A 5-minute feedback loop enables continuous integration; a 30-minute one prevents it.

  • Run integration tests on merge, not on every PR. PR builds should be fast. Integration tests that spin up databases and services belong in the merge pipeline where they validate the final state, not in the PR pipeline where they slow down iteration.

  • Use progressive confidence, not binary pass/fail. Instead of a single "tests passed" / "tests failed" decision, build confidence incrementally: unit tests provide baseline confidence, integration tests add more, E2E tests on staging add more, and canary deployment with monitoring provides the final validation.

  • Match test investment to risk. Payment processing, authentication, and data integrity deserve more testing investment than cosmetic features. Use risk-based test selection to run more tests for high-risk changes and fewer for low-risk ones.

  • Measure and reduce feedback cycle time. Track the time from commit to first test result (commit stage duration) and from commit to production deployment (total pipeline duration). Set targets and optimize. Every minute saved from the feedback loop pays dividends across every developer on every commit.

  • Treat test infrastructure as production infrastructure. Test databases, staging environments, and CI agents need monitoring, maintenance, and capacity planning. A flaky CI agent or a full staging database causes pipeline failures that look like test failures. Invest in test infrastructure reliability.

  • Review testing strategy quarterly. As the application grows, the testing pyramid shifts. Areas that were adequately tested may need more coverage. Test suites that were fast may need optimization. Review the test analytics quarterly: pass rates, durations, flaky rates, and coverage trends all inform strategy adjustments.

  • Automate the testing of the pipeline itself. If your pipeline configuration is complex, test it. Use pipeline templates to standardize stages, and validate template changes in a sandbox pipeline before rolling out to all teams.

References

Powered by Contentful