API Automation Framework in Azure DevOps
Best practices for building and running an API test automation framework in Azure DevOps. Covers framework design, test isolation, environment management.
An API automation framework in Azure DevOps needs to do two things well: run reliably in CI and give clear, actionable results when something breaks. This article covers the design decisions and pipeline configuration that make that possible.
Framework design principles for CI
Isolation: Each test creates its own data and cleans up after itself. No shared state between tests.
Idempotency: Running the same test twice produces the same result. No tests that depend on data created by previous tests.
Fast failure: Tests that check fundamental prerequisites (auth, connectivity) run first. If they fail, the rest skip.
Clear output: Test names describe exactly what was tested. Failure messages show expected vs actual, not just "test failed".
Node.js API test framework (Jest + Supertest)
TYPESCRIPT1// tests/api/users.test.ts 2import request from 'supertest' 3import { createTestUser, deleteTestUser } from '../helpers/user-factory' 4 5const BASE_URL = process.env.BASE_URL || 'http://localhost:3000' 6const AUTH_TOKEN = process.env.TEST_AUTH_TOKEN 7 8describe('Users API', () => { 9 let testUserId: string 10 11 beforeEach(async () => { 12 // Create isolated test data 13 const user = await createTestUser({ role: 'member' }) 14 testUserId = user.id 15 }) 16 17 afterEach(async () => { 18 // Clean up — always runs even if test fails 19 await deleteTestUser(testUserId) 20 }) 21 22 describe('GET /api/users/:id', () => { 23 it('returns 200 with correct user data for valid ID', async () => { 24 const res = await request(BASE_URL) 25 .get(`/api/users/${testUserId}`) 26 .set('Authorization', `Bearer ${AUTH_TOKEN}`) 27 .expect(200) 28 29 expect(res.body).toMatchObject({ 30 id: testUserId, 31 role: 'member', 32 }) 33 expect(res.body.password).toBeUndefined() // Never expose password 34 }) 35 36 it('returns 404 for non-existent user', async () => { 37 const res = await request(BASE_URL) 38 .get('/api/users/non-existent-id') 39 .set('Authorization', `Bearer ${AUTH_TOKEN}`) 40 .expect(404) 41 42 expect(res.body.error).toMatch(/not found/i) 43 }) 44 45 it('returns 401 without auth token', async () => { 46 await request(BASE_URL) 47 .get(`/api/users/${testUserId}`) 48 .expect(401) 49 }) 50 }) 51})
Environment management
TYPESCRIPT1// config/environments.ts 2export const config = { 3 staging: { 4 baseUrl: 'https://staging.app.com', 5 adminEmail: process.env.STAGING_ADMIN_EMAIL!, 6 adminPassword: process.env.STAGING_ADMIN_PASSWORD!, 7 }, 8 uat: { 9 baseUrl: 'https://uat.app.com', 10 adminEmail: process.env.UAT_ADMIN_EMAIL!, 11 adminPassword: process.env.UAT_ADMIN_PASSWORD!, 12 }, 13} 14 15export const getEnvConfig = () => { 16 const env = process.env.TEST_ENV || 'staging' 17 return config[env as keyof typeof config] 18}
Jest configuration for CI
JAVASCRIPT1// jest.config.js 2module.exports = { 3 testEnvironment: 'node', 4 testMatch: ['**/tests/api/**/*.test.ts'], 5 transform: { '^.+\\.tsx?$': 'ts-jest' }, 6 testTimeout: process.env.CI ? 30000 : 10000, 7 maxWorkers: process.env.CI ? 4 : '50%', 8 reporters: [ 9 'default', 10 ['jest-junit', { 11 outputDirectory: './test-results', 12 outputName: 'api-results.xml', 13 classNameTemplate: '{classname}', 14 titleTemplate: '{title}', 15 }], 16 ], 17 globalSetup: './tests/setup/global-setup.ts', 18 globalTeardown: './tests/setup/global-teardown.ts', 19}
Pipeline YAML
YAML1trigger: 2 branches: 3 include: [main] 4 5pool: 6 vmImage: ubuntu-latest 7 8variables: 9 - group: api-test-credentials 10 - name: TEST_ENV 11 value: staging 12 13stages: 14 - stage: APISmoke 15 displayName: API Smoke Tests 16 jobs: 17 - job: Smoke 18 steps: 19 - task: NodeTool@0 20 inputs: 21 versionSpec: '20.x' 22 - script: npm ci 23 - script: | 24 npx jest --testPathPattern="smoke" \ 25 --forceExit \ 26 --detectOpenHandles 27 displayName: Run API smoke tests 28 env: 29 BASE_URL: $(STAGING_URL) 30 TEST_AUTH_TOKEN: $(API_TEST_TOKEN) 31 TEST_ENV: $(TEST_ENV) 32 - task: PublishTestResults@2 33 inputs: 34 testResultsFormat: JUnit 35 testResultsFiles: test-results/api-results.xml 36 testRunTitle: API Smoke — $(Build.BuildNumber) 37 condition: always() 38 39 - stage: APIRegression 40 displayName: API Regression 41 dependsOn: APISmoke 42 jobs: 43 - job: Regression 44 timeoutInMinutes: 20 45 steps: 46 - task: NodeTool@0 47 inputs: 48 versionSpec: '20.x' 49 - script: npm ci 50 - script: | 51 npx jest tests/api/ \ 52 --forceExit \ 53 --detectOpenHandles \ 54 --verbose 55 displayName: Run full API regression 56 env: 57 BASE_URL: $(STAGING_URL) 58 TEST_AUTH_TOKEN: $(API_TEST_TOKEN) 59 STAGING_ADMIN_EMAIL: $(ADMIN_EMAIL) 60 STAGING_ADMIN_PASSWORD: $(ADMIN_PASSWORD) 61 - task: PublishTestResults@2 62 inputs: 63 testResultsFormat: JUnit 64 testResultsFiles: test-results/api-results.xml 65 testRunTitle: API Regression — $(Build.BuildNumber) 66 condition: always()
Common errors and fixes
Error: Jest did not exit one second after the test run has completed
Fix: Add --forceExit flag to Jest. API tests often leave open HTTP connections. Also check for unclosed database connections or event listeners in teardown code.
Error: Tests fail with connection reset in CI but work locally
Fix: The staging server may have rate limiting. Add a delay between requests with await new Promise(r => setTimeout(r, 100)) or reduce maxWorkers to limit concurrent requests.
Error: Test data from a failed test persists and causes the next run to fail
Fix: Use afterEach with try/catch for cleanup, and implement a cleanup script that runs at the start of the pipeline to reset known test data.
Error: Cannot find module 'supertest' in pipeline
Fix: supertest should be in dependencies or devDependencies in package.json. Run npm ci (not npm install) in the pipeline to ensure exact versions are installed.
Share this article
Follow for more
Follow me on social media for more developer tips, tricks, and tutorials. Let's connect and build something great together!