Test Plans

API Testing Integration with Azure DevOps

A practical guide to integrating API testing with Azure DevOps, covering REST API test automation with Node.js, contract testing, schema validation, load testing endpoints, publishing API test results to pipelines, and building comprehensive API test suites for CI/CD.

API Testing Integration with Azure DevOps

Overview

API tests sit between unit tests and end-to-end tests in the testing pyramid. They are faster than UI tests, more realistic than unit tests, and catch integration issues that neither unit nor UI tests reliably detect -- broken endpoints, incorrect response schemas, missing headers, authentication failures, and performance regressions. Integrating API tests with Azure DevOps means running them in your pipeline, publishing results to the Tests tab, enforcing quality gates on response times and error rates, and catching breaking API changes before they reach consumers.

I test APIs before testing the UI. If the API returns wrong data, every UI test that depends on it becomes a confusing false failure. A solid API test suite running in Azure Pipelines catches 80% of backend bugs within minutes of a commit, without launching a browser. This article covers the practical integration -- writing API tests in Node.js, configuring test runners for JUnit output, publishing results, and building quality gates around API behavior.

Prerequisites

  • Node.js 18+ installed locally and on pipeline agents
  • Azure DevOps organization with Azure Pipelines
  • A REST API to test (deployed or running locally in the pipeline)
  • npm for dependency management
  • Familiarity with HTTP methods, status codes, and REST conventions
  • Basic YAML pipeline experience

Setting Up API Tests with Node.js

Project Structure

api-tests/
├── package.json
├── jest.config.js
├── tests/
│   ├── auth.test.js
│   ├── users.test.js
│   ├── projects.test.js
│   └── health.test.js
├── utils/
│   ├── api-client.js
│   ├── auth-helper.js
│   └── schema-validator.js
├── schemas/
│   ├── user.schema.json
│   ├── project.schema.json
│   └── error.schema.json
├── fixtures/
│   └── test-data.js
└── results/
    └── (generated)

API Client

Build a reusable HTTP client that handles authentication, base URL configuration, and response parsing:

// utils/api-client.js
var http = require("http");
var https = require("https");
var url = require("url");

var BASE_URL = process.env.API_BASE_URL || "http://localhost:3000/api";
var AUTH_TOKEN = null;

function request(method, path, body, headers) {
  return new Promise(function (resolve, reject) {
    var fullUrl = BASE_URL + path;
    var parsed = url.parse(fullUrl);
    var transport = parsed.protocol === "https:" ? https : http;

    var requestHeaders = {
      "Content-Type": "application/json",
      Accept: "application/json",
    };

    if (AUTH_TOKEN) {
      requestHeaders["Authorization"] = "Bearer " + AUTH_TOKEN;
    }

    if (headers) {
      Object.keys(headers).forEach(function (key) {
        requestHeaders[key] = headers[key];
      });
    }

    var options = {
      hostname: parsed.hostname,
      port: parsed.port,
      path: parsed.path,
      method: method,
      headers: requestHeaders,
    };

    var startTime = Date.now();

    var req = transport.request(options, function (res) {
      var data = "";
      res.on("data", function (chunk) { data += chunk; });
      res.on("end", function () {
        var duration = Date.now() - startTime;
        var responseBody = null;
        try {
          responseBody = data ? JSON.parse(data) : null;
        } catch (e) {
          responseBody = data;
        }

        resolve({
          status: res.statusCode,
          headers: res.headers,
          body: responseBody,
          duration: duration,
          raw: data,
        });
      });
    });

    req.on("error", reject);
    req.setTimeout(30000, function () {
      req.destroy(new Error("Request timeout: " + method + " " + path));
    });

    if (body) {
      req.write(JSON.stringify(body));
    }
    req.end();
  });
}

function get(path, headers) { return request("GET", path, null, headers); }
function post(path, body, headers) { return request("POST", path, body, headers); }
function put(path, body, headers) { return request("PUT", path, body, headers); }
function del(path, headers) { return request("DELETE", path, null, headers); }

function setToken(token) { AUTH_TOKEN = token; }
function clearToken() { AUTH_TOKEN = null; }

module.exports = {
  get: get,
  post: post,
  put: put,
  del: del,
  request: request,
  setToken: setToken,
  clearToken: clearToken,
};

Authentication Helper

// utils/auth-helper.js
var api = require("./api-client");

var tokens = {};

function login(email, password) {
  var cacheKey = email;
  if (tokens[cacheKey]) {
    api.setToken(tokens[cacheKey]);
    return Promise.resolve(tokens[cacheKey]);
  }

  return api.post("/auth/login", { email: email, password: password })
    .then(function (res) {
      if (res.status !== 200) {
        throw new Error("Login failed for " + email + ": " + res.status + " " + JSON.stringify(res.body));
      }
      tokens[cacheKey] = res.body.token;
      api.setToken(res.body.token);
      return res.body.token;
    });
}

function loginAsAdmin() {
  return login(
    process.env.ADMIN_EMAIL || "[email protected]",
    process.env.ADMIN_PASSWORD || "AdminP@ss123"
  );
}

function loginAsUser() {
  return login(
    process.env.USER_EMAIL || "[email protected]",
    process.env.USER_PASSWORD || "UserP@ss123"
  );
}

function logout() {
  api.clearToken();
}

module.exports = {
  login: login,
  loginAsAdmin: loginAsAdmin,
  loginAsUser: loginAsUser,
  logout: logout,
};

Schema Validation

Validate API responses against JSON Schema definitions:

// utils/schema-validator.js
var fs = require("fs");
var path = require("path");

var schemaDir = path.join(__dirname, "..", "schemas");

function loadSchema(name) {
  var filePath = path.join(schemaDir, name + ".schema.json");
  var content = fs.readFileSync(filePath, "utf8");
  return JSON.parse(content);
}

function validate(data, schemaName) {
  var schema = loadSchema(schemaName);
  var errors = [];

  validateObject(data, schema, "", errors);

  return {
    valid: errors.length === 0,
    errors: errors,
  };
}

function validateObject(data, schema, path, errors) {
  if (schema.type === "object") {
    if (typeof data !== "object" || data === null || Array.isArray(data)) {
      errors.push(path + ": expected object, got " + typeof data);
      return;
    }

    // Check required fields
    if (schema.required) {
      schema.required.forEach(function (field) {
        if (data[field] === undefined) {
          errors.push(path + "." + field + ": required field missing");
        }
      });
    }

    // Validate properties
    if (schema.properties) {
      Object.keys(schema.properties).forEach(function (key) {
        if (data[key] !== undefined) {
          validateObject(data[key], schema.properties[key], path + "." + key, errors);
        }
      });
    }
  } else if (schema.type === "array") {
    if (!Array.isArray(data)) {
      errors.push(path + ": expected array, got " + typeof data);
      return;
    }
    if (schema.items) {
      data.forEach(function (item, index) {
        validateObject(item, schema.items, path + "[" + index + "]", errors);
      });
    }
  } else if (schema.type === "string") {
    if (typeof data !== "string") {
      errors.push(path + ": expected string, got " + typeof data);
    }
  } else if (schema.type === "number" || schema.type === "integer") {
    if (typeof data !== "number") {
      errors.push(path + ": expected number, got " + typeof data);
    }
  } else if (schema.type === "boolean") {
    if (typeof data !== "boolean") {
      errors.push(path + ": expected boolean, got " + typeof data);
    }
  }
}

module.exports = { validate: validate, loadSchema: loadSchema };

JSON Schema Definition

{
  "type": "object",
  "required": ["id", "email", "name", "role", "createdAt"],
  "properties": {
    "id": { "type": "integer" },
    "email": { "type": "string" },
    "name": { "type": "string" },
    "role": { "type": "string" },
    "createdAt": { "type": "string" },
    "updatedAt": { "type": "string" },
    "avatar": { "type": "string" }
  }
}

Writing API Tests

Authentication Tests

// tests/auth.test.js
var api = require("../utils/api-client");
var auth = require("../utils/auth-helper");

afterEach(function () {
  auth.logout();
});

describe("POST /api/auth/login", function () {
  test("returns 200 and token for valid credentials", function () {
    return api.post("/auth/login", {
      email: "[email protected]",
      password: "AdminP@ss123",
    }).then(function (res) {
      expect(res.status).toBe(200);
      expect(res.body).toHaveProperty("token");
      expect(typeof res.body.token).toBe("string");
      expect(res.body.token.length).toBeGreaterThan(0);
      expect(res.body).toHaveProperty("expiresIn");
    });
  });

  test("returns 401 for invalid password", function () {
    return api.post("/auth/login", {
      email: "[email protected]",
      password: "WrongPassword",
    }).then(function (res) {
      expect(res.status).toBe(401);
      expect(res.body).toHaveProperty("error");
      expect(res.body.error).toMatch(/invalid/i);
    });
  });

  test("returns 400 for missing email", function () {
    return api.post("/auth/login", {
      password: "AdminP@ss123",
    }).then(function (res) {
      expect(res.status).toBe(400);
      expect(res.body).toHaveProperty("error");
    });
  });

  test("returns 429 after too many failed attempts", function () {
    var attempts = [];
    for (var i = 0; i < 10; i++) {
      attempts.push({ email: "[email protected]", password: "Wrong" + i });
    }

    var chain = Promise.resolve();
    var lastStatus;

    attempts.forEach(function (creds) {
      chain = chain.then(function () {
        return api.post("/auth/login", creds).then(function (res) {
          lastStatus = res.status;
        });
      });
    });

    return chain.then(function () {
      expect(lastStatus).toBe(429);
    });
  });

  test("responds within 500ms", function () {
    return api.post("/auth/login", {
      email: "[email protected]",
      password: "AdminP@ss123",
    }).then(function (res) {
      expect(res.duration).toBeLessThan(500);
    });
  });
});

CRUD Endpoint Tests

// tests/users.test.js
var api = require("../utils/api-client");
var auth = require("../utils/auth-helper");
var schemaValidator = require("../utils/schema-validator");

var createdUserId;

beforeAll(function () {
  return auth.loginAsAdmin();
});

afterAll(function () {
  auth.logout();
});

describe("GET /api/users", function () {
  test("returns 200 and array of users", function () {
    return api.get("/users").then(function (res) {
      expect(res.status).toBe(200);
      expect(Array.isArray(res.body)).toBe(true);
      expect(res.body.length).toBeGreaterThan(0);
    });
  });

  test("each user matches schema", function () {
    return api.get("/users").then(function (res) {
      res.body.forEach(function (user) {
        var result = schemaValidator.validate(user, "user");
        expect(result.valid).toBe(true);
        if (!result.valid) {
          console.log("Schema errors for user " + user.id + ":", result.errors);
        }
      });
    });
  });

  test("supports pagination", function () {
    return api.get("/users?page=1&limit=5").then(function (res) {
      expect(res.status).toBe(200);
      expect(res.body.length).toBeLessThanOrEqual(5);
      expect(res.headers).toHaveProperty("x-total-count");
    });
  });

  test("returns 401 without authentication", function () {
    auth.logout();
    return api.get("/users").then(function (res) {
      expect(res.status).toBe(401);
    }).then(function () {
      return auth.loginAsAdmin();
    });
  });
});

describe("POST /api/users", function () {
  test("creates a new user and returns 201", function () {
    var newUser = {
      email: "apitest-" + Date.now() + "@test.com",
      name: "API Test User",
      role: "viewer",
      password: "TestP@ss123",
    };

    return api.post("/users", newUser).then(function (res) {
      expect(res.status).toBe(201);
      expect(res.body).toHaveProperty("id");
      expect(res.body.email).toBe(newUser.email);
      expect(res.body.name).toBe(newUser.name);
      expect(res.body).not.toHaveProperty("password");
      createdUserId = res.body.id;
    });
  });

  test("returns 409 for duplicate email", function () {
    return api.post("/users", {
      email: "[email protected]",
      name: "Duplicate",
      role: "viewer",
      password: "TestP@ss123",
    }).then(function (res) {
      expect(res.status).toBe(409);
      expect(res.body.error).toMatch(/already exists|duplicate/i);
    });
  });
});

describe("DELETE /api/users/:id", function () {
  test("deletes the created user", function () {
    if (!createdUserId) {
      throw new Error("No user to delete -- create test must run first");
    }
    return api.del("/users/" + createdUserId).then(function (res) {
      expect(res.status).toBe(204);
    });
  });

  test("returns 404 for deleted user", function () {
    if (!createdUserId) { return; }
    return api.get("/users/" + createdUserId).then(function (res) {
      expect(res.status).toBe(404);
    });
  });
});

Response Header Validation

// tests/health.test.js
var api = require("../utils/api-client");

describe("GET /api/health", function () {
  test("returns 200 with health status", function () {
    return api.get("/health").then(function (res) {
      expect(res.status).toBe(200);
      expect(res.body).toHaveProperty("status", "healthy");
      expect(res.body).toHaveProperty("uptime");
      expect(res.body).toHaveProperty("version");
    });
  });

  test("includes required security headers", function () {
    return api.get("/health").then(function (res) {
      expect(res.headers).toHaveProperty("x-content-type-options", "nosniff");
      expect(res.headers).toHaveProperty("x-frame-options");
      expect(res.headers["cache-control"]).toMatch(/no-store|no-cache/);
    });
  });

  test("responds within 200ms", function () {
    return api.get("/health").then(function (res) {
      expect(res.duration).toBeLessThan(200);
    });
  });

  test("returns correct content type", function () {
    return api.get("/health").then(function (res) {
      expect(res.headers["content-type"]).toMatch(/application\/json/);
    });
  });
});

Azure Pipeline Configuration

Basic API Test Pipeline

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

pool:
  vmImage: ubuntu-latest

variables:
  API_BASE_URL: "https://staging-api.example.com"

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: "20.x"

  - script: npm ci
    workingDirectory: api-tests
    displayName: Install dependencies

  - script: npm test
    workingDirectory: api-tests
    displayName: Run API tests
    env:
      API_BASE_URL: $(API_BASE_URL)
      ADMIN_EMAIL: $(ADMIN_EMAIL)
      ADMIN_PASSWORD: $(ADMIN_PASSWORD)
    continueOnError: true

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

API Tests with Local Server

For PR builds, run the API server locally in the pipeline:

steps:
  - script: npm ci
    workingDirectory: api-server
    displayName: Install API dependencies

  - script: npm ci
    workingDirectory: api-tests
    displayName: Install test dependencies

  - script: |
      cd api-server
      npm start &
      sleep 5
      curl -f http://localhost:3000/api/health || exit 1
    displayName: Start API server
    env:
      NODE_ENV: test
      DATABASE_URL: $(TEST_DATABASE_URL)
      PORT: 3000

  - script: npm test
    workingDirectory: api-tests
    displayName: Run API tests
    env:
      API_BASE_URL: "http://localhost:3000/api"
    continueOnError: true

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

Contract Testing Pipeline

Run contract tests that verify API responses match documented schemas:

- script: |
    npm run test:contracts 2>&1 | tee contract-results.log
    VIOLATIONS=$(grep -c "VIOLATION" contract-results.log || true)
    echo "Contract violations: $VIOLATIONS"
    if [ "$VIOLATIONS" -gt "0" ]; then
      echo "##vso[task.logissue type=error]API contract violations detected"
      exit 1
    fi
  displayName: Run contract tests
  continueOnError: true

Performance Testing API Endpoints

Add response time assertions to catch performance regressions:

// tests/performance.test.js
var api = require("../utils/api-client");
var auth = require("../utils/auth-helper");

var THRESHOLDS = {
  health: 100,
  usersList: 500,
  userDetail: 200,
  search: 1000,
  login: 500,
};

beforeAll(function () {
  return auth.loginAsAdmin();
});

describe("API Response Time Thresholds", function () {
  test("GET /health responds within " + THRESHOLDS.health + "ms", function () {
    return api.get("/health").then(function (res) {
      console.log("  /health: " + res.duration + "ms (threshold: " + THRESHOLDS.health + "ms)");
      expect(res.duration).toBeLessThan(THRESHOLDS.health);
    });
  });

  test("GET /users responds within " + THRESHOLDS.usersList + "ms", function () {
    return api.get("/users?limit=50").then(function (res) {
      console.log("  /users: " + res.duration + "ms (threshold: " + THRESHOLDS.usersList + "ms)");
      expect(res.duration).toBeLessThan(THRESHOLDS.usersList);
    });
  });

  test("GET /users/:id responds within " + THRESHOLDS.userDetail + "ms", function () {
    return api.get("/users/1").then(function (res) {
      console.log("  /users/1: " + res.duration + "ms (threshold: " + THRESHOLDS.userDetail + "ms)");
      expect(res.duration).toBeLessThan(THRESHOLDS.userDetail);
    });
  });

  test("GET /search responds within " + THRESHOLDS.search + "ms", function () {
    return api.get("/search?q=test&limit=20").then(function (res) {
      console.log("  /search: " + res.duration + "ms (threshold: " + THRESHOLDS.search + "ms)");
      expect(res.duration).toBeLessThan(THRESHOLDS.search);
    });
  });
});

Complete Working Example

A comprehensive API test runner with built-in schema validation, performance tracking, and JUnit output for Azure DevOps:

// run-api-tests.js
var http = require("http");
var https = require("https");
var url = require("url");
var fs = require("fs");

var BASE_URL = process.env.API_BASE_URL || "http://localhost:3000/api";
var RESULTS_DIR = "results";
var results = [];
var startTime = Date.now();

if (!fs.existsSync(RESULTS_DIR)) {
  fs.mkdirSync(RESULTS_DIR, { recursive: true });
}

function apiRequest(method, path, body, token) {
  return new Promise(function (resolve, reject) {
    var fullUrl = BASE_URL + path;
    var parsed = url.parse(fullUrl);
    var transport = parsed.protocol === "https:" ? https : http;

    var headers = { "Content-Type": "application/json", Accept: "application/json" };
    if (token) { headers["Authorization"] = "Bearer " + token; }

    var start = Date.now();
    var options = {
      hostname: parsed.hostname,
      port: parsed.port,
      path: parsed.path,
      method: method,
      headers: headers,
    };

    var req = transport.request(options, function (res) {
      var data = "";
      res.on("data", function (chunk) { data += chunk; });
      res.on("end", function () {
        resolve({
          status: res.statusCode,
          headers: res.headers,
          body: data ? JSON.parse(data) : null,
          duration: Date.now() - start,
        });
      });
    });

    req.on("error", reject);
    req.setTimeout(15000, function () { req.destroy(new Error("Timeout")); });
    if (body) { req.write(JSON.stringify(body)); }
    req.end();
  });
}

function runTest(name, testFn) {
  var start = Date.now();
  process.stdout.write("  " + name + "... ");

  return testFn()
    .then(function () {
      var duration = Date.now() - start;
      results.push({ name: name, status: "passed", duration: duration });
      console.log("PASSED (" + duration + "ms)");
    })
    .catch(function (err) {
      var duration = Date.now() - start;
      results.push({ name: name, status: "failed", duration: duration, error: err.message });
      console.log("FAILED: " + err.message);
    });
}

function assert(condition, message) {
  if (!condition) { throw new Error(message); }
}

// Test execution
console.log("API Test Suite");
console.log("Target: " + BASE_URL);
console.log("");

var token;

runTest("Health check returns 200", function () {
  return apiRequest("GET", "/health").then(function (res) {
    assert(res.status === 200, "Expected 200, got " + res.status);
    assert(res.body.status === "healthy", "Expected healthy status");
    assert(res.duration < 200, "Response too slow: " + res.duration + "ms");
  });
})
.then(function () {
  return runTest("Login returns token", function () {
    return apiRequest("POST", "/auth/login", {
      email: "[email protected]",
      password: "AdminP@ss123",
    }).then(function (res) {
      assert(res.status === 200, "Login failed: " + res.status);
      assert(res.body.token, "No token in response");
      token = res.body.token;
    });
  });
})
.then(function () {
  return runTest("GET /users returns array", function () {
    return apiRequest("GET", "/users", null, token).then(function (res) {
      assert(res.status === 200, "Expected 200, got " + res.status);
      assert(Array.isArray(res.body), "Expected array response");
      assert(res.body.length > 0, "Expected at least one user");
    });
  });
})
.then(function () {
  return runTest("GET /users without auth returns 401", function () {
    return apiRequest("GET", "/users").then(function (res) {
      assert(res.status === 401, "Expected 401, got " + res.status);
    });
  });
})
.then(function () {
  return runTest("POST /users creates user", function () {
    var email = "apitest-" + Date.now() + "@test.com";
    return apiRequest("POST", "/users", {
      email: email, name: "Test", role: "viewer", password: "TestP@ss1"
    }, token).then(function (res) {
      assert(res.status === 201, "Expected 201, got " + res.status);
      assert(res.body.email === email, "Email mismatch");
      assert(!res.body.password, "Password should not be in response");
    });
  });
})
.then(function () {
  return runTest("Invalid login returns 401", function () {
    return apiRequest("POST", "/auth/login", {
      email: "[email protected]", password: "wrong"
    }).then(function (res) {
      assert(res.status === 401, "Expected 401, got " + res.status);
    });
  });
})
.then(function () {
  // Write results
  var totalDuration = ((Date.now() - startTime) / 1000).toFixed(3);
  var passed = results.filter(function (r) { return r.status === "passed"; }).length;
  var failed = results.filter(function (r) { return r.status === "failed"; }).length;

  console.log("\n=== Results ===");
  console.log("Total: " + results.length + " | Passed: " + passed + " | Failed: " + failed);
  console.log("Duration: " + totalDuration + "s");

  // Write JUnit XML
  var xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
  xml += '<testsuites>\n<testsuite name="API Tests" tests="' + results.length +
    '" failures="' + failed + '" time="' + totalDuration + '">\n';

  results.forEach(function (r) {
    var time = (r.duration / 1000).toFixed(3);
    xml += '  <testcase name="' + escapeXml(r.name) + '" classname="api" time="' + time + '">\n';
    if (r.status === "failed") {
      xml += '    <failure message="' + escapeXml(r.error) + '">' + escapeXml(r.error) + '</failure>\n';
    }
    xml += '  </testcase>\n';
  });

  xml += '</testsuite>\n</testsuites>';
  fs.writeFileSync(RESULTS_DIR + "/junit-results.xml", xml);
  console.log("Results written to " + RESULTS_DIR + "/junit-results.xml");

  process.exit(failed > 0 ? 1 : 0);
});

function escapeXml(str) {
  return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;")
    .replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

Common Issues and Troubleshooting

API Server Not Ready When Tests Start

Tests fail with ECONNREFUSED because the API server has not finished starting when tests begin. Add a readiness check in the pipeline that polls the health endpoint:

- script: |
    for i in $(seq 1 30); do
      curl -sf http://localhost:3000/api/health && break
      echo "Waiting for API... ($i/30)"
      sleep 2
    done
    curl -sf http://localhost:3000/api/health || (echo "API failed to start" && exit 1)
  displayName: Wait for API readiness

Authentication Tokens Expiring Mid-Test

Long test suites can exceed the JWT expiration time. Add a token refresh mechanism to the auth helper, or use a long-lived test token. In the pipeline, set the JWT expiration to a longer duration for the test environment.

Test Data Conflicts in Parallel Runs

Multiple pipeline runs creating users with the same email address cause 409 Conflict errors. Use unique identifiers per test run: "apitest-" + Date.now() + "@test.com" or "apitest-" + process.env.BUILD_BUILDID + "@test.com".

Response Schema Changes Breaking Tests

When the API adds a new field, tests that do strict equality checks fail. Use toHaveProperty for required fields and schema validation for structure, not exact object matching. Allow additional properties in schemas to accommodate non-breaking additions.

SSL Certificate Errors in Pipeline

When testing against staging with self-signed certificates, Node.js rejects the connection. Set NODE_TLS_REJECT_UNAUTHORIZED=0 for the test step only (never in production). Alternatively, add the CA certificate to the pipeline agent's trust store.

Best Practices

  • Test the API contract, not the implementation. API tests should verify status codes, response shapes, and business rules. They should not test internal database queries or service implementations -- that is what unit tests are for.

  • Use schema validation for all response bodies. JSON Schema validation catches missing fields, wrong types, and unexpected structures automatically. Maintain schemas alongside the API code and run validation in every test.

  • Include response time assertions. Every API endpoint should have a response time threshold. This catches performance regressions early -- a query that suddenly takes 2 seconds instead of 200ms is a bug, even if the response body is correct.

  • Clean up test data after every test run. API tests that create resources (users, projects, orders) should delete them in the afterAll hook. Accumulated test data pollutes the environment and can cause future test failures.

  • Run API tests before UI tests in the pipeline. If the API is broken, UI tests will fail too -- but with confusing error messages about elements not loading or wrong text on the page. API test failures give clear, specific error information.

  • Use environment variables for all configuration. Base URL, credentials, timeouts, and feature flags should all come from environment variables, not hard-coded values. This makes the same test suite runnable against dev, staging, and production.

  • Publish results even when tests fail. Always use condition: always() on the PublishTestResults task. Failed test results in the Azure DevOps UI are the primary debugging tool -- without them, developers have to read raw pipeline logs.

References

Powered by Contentful