QA Interview Hub · Knowledge base · Quizzes

System Design

Test data and environments

Test data strategies

Test data is the most frequent cause of unstable and slow tests. There are several approaches, and a Senior must explain when to use which.

ApproachProsCons
Static data in the DBSimple, fastConflicts in parallel runs, data gets "spoiled"
Generating through the API in the testIsolation, realisticSlower, depends on the API
A seed script or a DB snapshotA fast environment startMust be maintained together with the schema
Factories plus direct DB writesVery fastBypasses business logic, can create an impossible state

The basic rule: each test creates its own data and doesn't rely on data from other tests. Only reference data that nobody changes can be shared: currencies, countries, tariffs.

For fintech there is a nuance: creating a "ready" user often requires KYC, phone verification, opening an account. Test hooks help here: an internal endpoint, available only in test environments, that creates a verified user with one request. This must be agreed with development and closed off from production.

Data must be unique: an email with testInfo.workerIndex and a random suffix, unique account numbers. No real personal data and no real cards: only the provider's test cards.

In an interview: My principle is that each test creates the needed data itself through the API or test hooks and doesn't depend on other tests. I leave only immutable reference data shared, and I don't use real personal data in tests at all.

Task: test data for parallel runs

The condition: 64 parallel workers, a shared staging, tests of transfers between accounts. The tests periodically fail because the balance isn't what was expected.

Why it fails

Two tests use one user, "test_user_1". Test A transfers 30, and test B at that moment checks the balance of 100 and gets 70. This is a classic shared state conflict.

The solution

  1. Unique data per test. A fixture creates a new user and account before the test.
  2. A pool of accounts per worker, if creation is expensive. The worker with index N takes accounts from pool N. A worker-scoped fixture in Playwright creates them once per worker.
  3. A run prefix. All entities carry a run label, for example run_8f2c_w3_. That makes it easy to find and clean up garbage after the run.
  4. Relative checks, not absolute, if the state is shared: remember the balance before the action and compare the difference.
export const test = base.extend<{}, { account: Account }>({
  account: [async ({}, use, workerInfo) => {
    const acc = await createAccount(`run_${RUN_ID}_w${workerInfo.workerIndex}`);
    await use(acc);
    await closeAccount(acc.id);
  }, { scope: 'worker' }],
});

Trade-offs

Fresh data for every test gives maximum isolation, but loads the API and the database grows. A pool per worker is faster, but tests within a worker must clean up their state.

Metrics

The number of failures caused by data, the volume of garbage in the DB after a run, the time spent preparing data in a test.

In an interview: I remove shared state: each test or each worker gets its own accounts with a run label. If creation is expensive, I use a worker-scoped fixture, and after the run I clean the data by prefix.

Environments: local, ephemeral, staging

A test system is not only the test code but also the environments where they run.

Local (docker compose)        -> a fast test development loop
Ephemeral env per PR          -> isolated verification of a change
Shared staging                -> integration with real dependencies
Production (smoke, synthetic) -> a check after release

An ephemeral environment is an environment brought up for every PR (for example a namespace in Kubernetes) and destroyed after the merge. The plus: no conflicts between teams, clean data. The minus: cost and spin-up time, and it is hard to bring up all 40 microservices.

The compromise: bring up only the changed service and its closest dependencies, and take the rest from the shared staging or replace them with stubs.

External dependencies. A payment provider, a KYC service and bank APIs often have a sandbox with limitations: rate limits, instability, the inability to reproduce certain errors. So for most tests I put in a stub at the HTTP level (WireMock or similar) with controllable responses: success, refusal, timeout, a duplicate callback. I keep the real sandbox for a small set of integration tests that run separately and don't block the PR.

In an interview: I separate environments by purpose: local for development, ephemeral per PR for isolation, staging for integration and smoke in production. External payment services I mostly replace with controllable stubs, to reproduce errors and timeouts, and I use the real sandbox in a separate small set.