Test Data Management in Azure DevOps Pipelines
How to manage test data in Azure DevOps CI/CD pipelines. Covers database seeding, factory patterns, environment isolation, data cleanup strategies, and using Azure Key Vault for sensitive test credentials.
Test data management is the unglamorous but critical work that determines whether your automated test suite is reliable or flaky. In Azure DevOps pipelines, it requires deliberate design: data must be available, isolated, and cleaned up.
The four test data problems in CI
- Stale data: tests depend on records that were manually created and may change
- Shared data conflicts: parallel tests read/write the same records
- Leftover data: failed tests leave records that cause subsequent runs to fail
- Sensitive data: production credentials or PII in test fixtures
Strategy 1: Database seeding in pipelines
Seed the test database at the start of each pipeline run:
stages:
- stage: PrepareEnvironment
jobs:
- job: SeedDatabase
steps:
- script: npm ci
- script: npm run db:reset # Drop and recreate test database
env:
DATABASE_URL: $(TEST_DB_URL)
- script: npm run db:migrate # Apply schema migrations
env:
DATABASE_URL: $(TEST_DB_URL)
- script: npm run db:seed # Insert baseline test data
env:
DATABASE_URL: $(TEST_DB_URL)
- stage: Tests
dependsOn: PrepareEnvironment
jobs:
- job: RunTests
steps:
- script: npx playwright test
env:
DATABASE_URL: $(TEST_DB_URL)
BASE_URL: $(STAGING_URL)The seed script populates known, stable data. Tests that need dynamic data create it themselves.
Example seed script
// scripts/db-seed.ts
import { db } from '../src/database'
async function seed() {
// Create base users (stable across runs)
await db.users.upsert({
where: { email: 'admin@test.com' },
create: {
id: '00000000-0000-0000-0000-000000000001',
email: 'admin@test.com',
role: 'admin',
name: 'Test Admin'
},
update: {}
})
// Create test products
await db.products.createMany({
data: SEED_PRODUCTS,
skipDuplicates: true
})
console.log('Seed complete')
}
seed().catch(console.error).finally(() => db.$disconnect())Strategy 2: Factory pattern for dynamic data
Each test creates and destroys its own data:
// tests/helpers/factory.ts
import { randomUUID } from 'crypto'
export class UserFactory {
private createdIds: string[] = []
async create(overrides = {}) {
const userData = {
id: randomUUID(),
email: `test-${randomUUID()}@example.com`,
name: 'Test User',
role: 'member',
...overrides
}
const res = await fetch(`${process.env.API_URL}/admin/users`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.ADMIN_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
})
const user = await res.json()
this.createdIds.push(user.id)
return user
}
async cleanup() {
for (const id of this.createdIds) {
await fetch(`${process.env.API_URL}/admin/users/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${process.env.ADMIN_TOKEN}` }
}).catch(err => console.warn(`Cleanup failed for ${id}: ${err.message}`))
}
this.createdIds = []
}
}// In tests
test.beforeEach(async () => {
userFactory = new UserFactory()
})
test.afterEach(async () => {
await userFactory.cleanup()
})
test('Admin can view user details', async ({ request }) => {
const user = await userFactory.create({ role: 'member' })
const res = await request.get(`/api/users/${user.id}`)
expect(res.status()).toBe(200)
})Storing test credentials securely
Never hardcode credentials in test code or YAML. Use Azure Key Vault with a service connection:
steps:
- task: AzureKeyVault@2
displayName: Load test secrets
inputs:
azureSubscription: $(AZURE_SERVICE_CONNECTION)
KeyVaultName: 'myapp-test-keyvault'
SecretsFilter: 'TEST-DB-URL, TEST-ADMIN-TOKEN, STAGING-URL'
RunAsPreJob: true
- script: npx playwright test
env:
DATABASE_URL: $(TEST-DB-URL) # Loaded from Key Vault
ADMIN_TOKEN: $(TEST-ADMIN-TOKEN)
BASE_URL: $(STAGING-URL)Pre-run cleanup for resilience
Even with good cleanup, CI failures can leave orphaned test data. Add a pre-run cleanup:
// scripts/cleanup-test-data.ts
async function cleanupTestData() {
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000) // 24 hours ago
// Delete users created by tests (identified by email pattern)
const deleted = await db.users.deleteMany({
where: {
email: { contains: '@example.com' },
createdAt: { lt: cutoff }
}
})
console.log(`Cleaned up ${deleted.count} stale test users`)
}Add this to your pipeline's preparation stage.
Common errors and fixes
Error: Database seed fails because tables don't exist yet
Fix: Run db:migrate before db:seed. Migrations create the schema; seeds populate data. The order matters.
Error: Parallel test jobs conflict on shared test data Fix: Use the factory pattern with UUID-based identifiers. Each test creates its own records and doesn't depend on shared data.
Error: Test data cleanup fails silently and data accumulates Fix: Log cleanup results explicitly. Monitor the test database size over time. If it grows between pipeline runs, cleanup is failing. Use a pre-run cleanup as a safety net.
Error: Key Vault access fails in pipeline with "Forbidden" Fix: The service connection's service principal must have "Key Vault Secrets User" role on the Key Vault. Grant this in the Azure portal under Key Vault → Access control (IAM).
Stay ahead in AI-driven QA
Get practical tutorials on test automation, AI testing, and quality engineering — straight to your inbox. No spam, unsubscribe any time.
Discussion
Sign in with GitHub to comment · powered by Giscus