Strategie danych testowych dla stabilnej automatyzacji

Złe dane testowe to cichy zabójca automatyzacji. Naucz się tworzyć izolowane, powtarzalne dane testowe bez zanieczyszczeń, race conditions i powiązań ze środowiskiem.

Ten wpis jest częścią serii Strategia automatyzacji. Jeśli przegapiłeś poprzedni wpis, przeczytaj najpierw Część 3 — Co automatyzować najpierw.


„Testy przechodzą na mojej maszynie.”

Ta fraza rzadko dotyczy kodu. Dotyczy danych.

Uruchamiasz test lokalnie, przechodzi. CI go uruchamia, failuje. Kolega go uruchamia, failuje. Badasz: ktoś usunął użytkownika testowego. Poprzedni test zmodyfikował współdzielone dane. Baza danych staging została przywrócona z produkcji i teraz ma inne ID.

Zła strategia danych testowych to najczęstsza przyczyna niestabilnych, zawodnych testów automatycznych — i jest całkowicie możliwa do zapobieżenia.

W tym wpisie omówimy jak projektować dane testowe dla automatyzacji: fabryki, izolacja, sprzątanie, seeding przez API i niebezpieczeństwa współdzielonych fikstur bazodanowych.


Problem: współdzielone, zanieczyszczone dane testowe

Większość zespołów zaczyna od prostego podejścia: zasiej bazę danych testowych danymi fikstur, potem pisz testy zakładające, że istnieją.

// Źle: zależy od danych stworzonych przez kogoś innego
test('użytkownik może zobaczyć swoją historię zamówień', async () => {
  await loginAs('testuser@example.com', 'password123');
  await expect(page.getByText('Zamówienie #12345')).toBeVisible();
});

Ten test zakłada:

  • testuser@example.com istnieje
  • Użytkownik ma co najmniej jedno zamówienie
  • To zamówienie ma ID #12345

Co się dzieje gdy:

  • Ktoś usuwa testuser@example.com podczas debugowania?
  • Inny test tworzy zamówienie #12345 dla innego użytkownika?
  • Baza danych testowych jest wyczyszczona i ponownie zasiana z innymi ID zamówień?

Test failuje — nie dlatego, że kod jest zepsuty, ale dlatego, że dane się zmieniły.

:::warning[Współdzielone dane to fabryka niestabilnych testów] Każdy test zależny od pre-istniejących, współdzielonych danych w końcu sfailuje z powodów niezwiązanych z kodem. Testy powinny posiadać swoje dane: tworzyć je, używać, sprzątać. :::


Zasada #1: każdy test tworzy i posiada swoje dane

Naprawa jest prosta koncepcyjnie: każdy test tworzy dane, których potrzebuje przed uruchomieniem, i sprząta po.

Przed

// Źle: zależy od pre-istniejących danych
test('może złożyć zamówienie', async () => {
  await loginAs('testuser@example.com', 'password123');
  // ...logika testu
});

Po

// Dobrze: tworzy własnego użytkownika
test('może złożyć zamówienie', async ({ request }) => {
  const user = await createUser(request, {
    email: `test-${Date.now()}@example.com`,
    password: 'Test1234!'
  });

  await loginAs(user.email, 'Test1234!');
  // ...logika testu

  await deleteUser(request, user.id);
});

Teraz test jest izolowany. Nie ma znaczenia, jakie dane istnieją w bazie danych gdy startuje. Tworzy co potrzebuje, używa i sprząta.


Zasada #2: używaj fabryk, nie hardcodowanych fikstur

Hardcodowane dane testowe prowadzą do duplikacji i obciążenia utrzymaniowego. Zamiast tego używaj fabryk — funkcji generujących dane testowe na żądanie.

Wzorzec fabryki

// testHelpers.ts
export const createUser = async (request: APIRequestContext, overrides = {}) => {
  const defaults = {
    email: `user-${Date.now()}-${Math.random().toString(36)}@example.com`,
    password: 'Test1234!',
    firstName: 'Test',
    lastName: 'User',
  };

  const response = await request.post('/api/users', {
    data: { ...defaults, ...overrides }
  });

  return await response.json();
};

Teraz możesz tworzyć użytkowników z niestandardowymi właściwościami bez duplikacji logiki setupu:

const adminUser = await createUser(request, { role: 'admin' });
const guestUser = await createUser(request, { role: 'guest' });
const premiumUser = await createUser(request, { subscriptionTier: 'premium' });

Fabryki dla złożonych obiektów

export const createOrder = async (request: APIRequestContext, userId: string, overrides = {}) => {
  const defaults = {
    userId,
    items: [
      { productId: 'prod-123', quantity: 1, price: 29.99 }
    ],
    shippingAddress: {
      street: '123 Test St',
      city: 'Testville',
      postcode: 'TE5 7ST',
    },
    status: 'pending',
  };

  const response = await request.post('/api/orders', {
    data: { ...defaults, ...overrides }
  });

  return await response.json();
};

Teraz tworzenie zamówienia z niestandardowymi właściwościami to jedna linia:

const order = await createOrder(request, user.id, { status: 'completed' });

:::tip[Używaj fabryk od pierwszego dnia] Nawet jeśli masz tylko dwa testy, używaj fabryk. Koszt jest minimalny z góry i oszczędza masywną refaktoryzację później, gdy masz 200 testów z duplikowanym kodem setupu. :::


Zasada #3: izoluj dane, aby uniknąć race conditions

Gdy testy działają równolegle (co powinny dla szybkości), testy mogą sobie przeszkadzać, jeśli modyfikują współdzielone dane.

Przykład: Race Condition

// Test 1
test('admin może usunąć użytkownika', async () => {
  await deleteUser('testuser@example.com');
  // ...
});

// Test 2
test('użytkownik może zaktualizować profil', async () => {
  await loginAs('testuser@example.com', 'password123');
  // Failuje, bo Test 1 usunął użytkownika
});

Oba testy używają tego samego użytkownika. Jeśli działają równolegle, jeden usuwa użytkownika, gdy drugi próbuje się zalogować. Niestabilny fail.

Naprawa: unikalne dane na test

// Test 1
test('admin może usunąć użytkownika', async ({ request }) => {
  const user = await createUser(request); // unikalny użytkownik
  await deleteUser(user.id);
});

// Test 2
test('użytkownik może zaktualizować profil', async ({ request }) => {
  const user = await createUser(request); // inny unikalny użytkownik
  await loginAs(user.email, 'Test1234!');
  // ...
});

Teraz każdy test ma własnego użytkownika. Mogą działać równolegle bez interferencji.

Unikalne identyfikatory

Aby zagwarantować unikalność:

const uniqueEmail = `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}@example.com`;

To zapewnia, że nawet jeśli dwa testy działają w dokładnej tej samej milisekundzie, ich dane nie będą kolidować.


Zasada #4: sprzątaj po sobie

Testy zostawiające dane zanieczyszczają bazę danych, spowalniają przyszłe testy i mogą powodować nieoczekiwane faile.

Strategie sprzątania

Opcja 1: Explicite sprzątanie (najlepsze dla testów opartych na API)

test('może utworzyć zamówienie', async ({ request }) => {
  const user = await createUser(request);
  const order = await createOrder(request, user.id);

  // ...logika testu

  // Sprzątanie
  await deleteOrder(request, order.id);
  await deleteUser(request, user.id);
});

Zalety: Explicite, łatwe do debugowania
Wady: Wymaga logiki sprzątania w każdym teście

Opcja 2: Fikstury testowe (Playwright)

import { test as base } from '@playwright/test';

const test = base.extend<{ testUser: User }>({
  testUser: async ({ request }, use) => {
    // Setup
    const user = await createUser(request);
    
    // Użycie
    await use(user);
    
    // Teardown
    await deleteUser(request, user.id);
  },
});

// Teraz każdy test dostaje auto-sprzątanego użytkownika
test('może złożyć zamówienie', async ({ testUser, request }) => {
  const order = await createOrder(request, testUser.id);
  // Sprzątanie dzieje się automatycznie
});

Zalety: Automatyczne, mniej duplikacji
Wady: Wymaga zrozumienia systemów fikstur frameworków testowych

Opcja 3: Transakcje bazodanowe (testy integracyjne)

[Fact]
public async Task CanCreateOrder()
{
    using var transaction = await _dbContext.Database.BeginTransactionAsync();
    
    var user = await CreateTestUser();
    var order = await _orderService.CreateOrder(user.Id, items);
    
    Assert.NotNull(order);
    
    // Rollback — nic nie persystuje
    await transaction.RollbackAsync();
}

Zalety: Szybkie, gwarantowane sprzątanie
Wady: Działa tylko dla testów tylko-bazodanowych (nie E2E z UI)


Zasada #5: unikaj współdzielonego stanu bazy danych

Niektóre zespoły używają pojedynczej „bazy danych testowej” współdzielonej przez wszystkie uruchomienia testów. To katastrofa czekająca na wydarzenie się.

Problemy ze współdzielonymi bazami danych

  • Wolne testy — Baza danych zapełnia się starymi danymi testowymi, zapytania spowalniają
  • Niestabilne testy — Testy przeszkadzają sobie nawzajem przez współdzielone rekordy
  • Piekło debugowania — Trudno odtworzyć problemy lokalnie, bo stan twojej bazy różni się od CI

Rozwiązanie: baza danych na uruchomienie testu

Używaj TestContainers lub Docker Compose, aby rozkręcić izolowane bazy danych na uruchomienie testu.

Przykład: Playwright z Postgres TestContainer

import { PostgreSqlContainer } from '@testcontainers/postgresql';

let container: PostgreSqlContainer;
let connectionString: string;

beforeAll(async () => {
  container = await new PostgreSqlContainer('postgres:16').start();
  connectionString = container.getConnectionUri();
  
  // Uruchom migracje
  await runMigrations(connectionString);
});

afterAll(async () => {
  await container.stop();
});

Teraz każde uruchomienie testu dostaje świeżą, izolowaną bazę danych. Brak zanieczyszczeń, brak konfliktów.

:::tip[Używaj Dockera dla izolacji testów] Jeśli twoje środowisko CI obsługuje Dockera (GitHub Actions, GitLab CI, Azure Pipelines wszystkie to robią), używaj kontenerów dla baz danych, kolejek wiadomości i zewnętrznych zależności. Testy stają się odtwarzalne w każdym środowisku. :::


Zasada #6: seeduj dane przez API, nie bezpośredni dostęp do bazy danych

Wiele zespołów seeduje dane testowe uruchamiając skrypty SQL lub bezpośrednio wstawiając rekordy przez ORM.

Problem: To omija logikę aplikacji — walidację, triggery, side effects. Testy przechodzą, ale prawdziwe przepływy użytkowników failują.

Źle: bezpośredni insert do bazy danych

// Źle: omija walidację, nie wyzwala eventów
await db.query('INSERT INTO users (email, password) VALUES (?, ?)', 
  ['test@example.com', 'plaintext_password']);

Jeśli prawdziwa aplikacja:

  • Hashuje hasła
  • Wysyła email powitalny
  • Tworzy powiązane rekordy (profil użytkownika, preferencje)

…test nie złapie błędów w tych przepływach.

Dobrze: seeduj przez API

// Dobrze: przechodzi przez prawdziwy przepływ rejestracji
const response = await request.post('/api/users', {
  data: {
    email: 'test@example.com',
    password: 'Test1234!',
  }
});

const user = await response.json();

Teraz test waliduje:

  • Hashowanie hasła działa
  • Email powitalny się wyzwala
  • Powiązane rekordy są tworzone

Jeśli którykolwiek z tych się złamie, test failuje.

:::info[Testuj prawdziwą ścieżkę] Testy automatyczne są najbardziej wartościowe, gdy wykonują tę samą ścieżkę kodu co użytkownicy. Seeding przez API zapewnia, że testujesz pełny przepływ, nie tylko logikę happy-path. :::


Praktyczny przykład: test E2E z izolacją danych

Połóżmy to wszystko razem.

Pełny test z fabrykami i sprzątaniem

import { test, expect } from '@playwright/test';
import { createUser, createProduct, deleteUser } from './testHelpers';

test('użytkownik może dodać produkt do koszyka i checkout', async ({ page, request }) => {
  // Setup: stwórz izolowane dane testowe
  const user = await createUser(request);
  const product = await createProduct(request, { name: 'Test Widget', price: 29.99 });

  // Test
  await page.goto('/login');
  await page.getByLabel('Email').fill(user.email);
  await page.getByLabel('Hasło').fill('Test1234!');
  await page.getByRole('button', { name: 'Zaloguj' }).click();

  await page.goto(`/products/${product.id}`);
  await page.getByRole('button', { name: 'Dodaj do koszyka' }).click();
  await expect(page.getByText('Koszyk (1)')).toBeVisible();

  await page.getByRole('link', { name: 'Checkout' }).click();
  await expect(page.getByText(`Razem: ${product.price} zł`)).toBeVisible();

  // Sprzątanie
  await deleteUser(request, user.id);
  // Sprzątanie produktu by się tutaj działo gdyby potrzeba
});

Ten test:

  • Tworzy własnego użytkownika i produkt
  • Nie zależy od żadnych pre-istniejących danych
  • Może działać równolegle z innymi testami bez konfliktów
  • Sprząta po sobie

Powszechne pułapki i naprawy

Pułapka #1: Zapominanie o sprzątaniu

Symptom: Baza danych testowych rośnie do milionów rekordów. Zapytania spowalniają. Testy zaczynają timeoutować.

Naprawa: Automatyzuj sprzątanie. Używaj fikstur testowych, transakcji bazodanowych lub hooków teardown. Nigdy nie polegaj na manualnym sprzątaniu.

Pułapka #2: Hardcodowanie ID

// Źle: zakłada, że produkt ID 123 istnieje
const product = await request.get('/api/products/123');

Naprawa: Stwórz produkt w teście lub wyszukaj go dynamicznie.

Pułapka #3: Reużywanie danych w testach

// Źle: wiele testów używa tego samego użytkownika
const ADMIN_USER = 'admin@example.com';

test('admin może usuwać użytkowników', () => { /* używa ADMIN_USER */ });
test('admin może przeglądać logi', () => { /* używa ADMIN_USER */ });

Jeśli testy działają równolegle, jeden może modyfikować ADMIN_USER, gdy inny go używa.

Naprawa: Każdy test tworzy własnego użytkownika admin.

Pułapka #4: Brak strategii dla dużych datasetów

Niektóre testy potrzebują realistycznych wolumenów danych (paginacja, testy wydajnościowe).

Naprawa: Używaj skryptów seedujących działających raz na środowisko (nie na test). Oddziel „dane seed” (statyczne, współdzielone, tylko do odczytu) od „danych testowych” (tworzone, modyfikowane, usuwane na test).


Rekomendowany stack danych testowych

WarstwaNarzędzieCel
FabrykiNiestandardowe helpery lub Faker.jsGeneruj unikalne dane testowe na żądanie
API SeedingREST/GraphQL API twojej aplikacjiSeeduj dane przez prawdziwe przepływy
IzolacjaTestContainers / Docker ComposeRozkręcaj izolowane bazy danych na uruchomienie
SprzątanieFikstury testowe lub rollback transakcjiAutomatycznie usuwaj dane testowe

Podsumowanie

Dane testowe to nie dodatek. Zła strategia danych to cichy zabójca automatyzacji — powodujący niestabilne testy, wolne CI i zmarnowany czas debugowania.

Zasady są proste:

  1. Każdy test tworzy i posiada swoje dane — brak współdzielonych fikstur
  2. Używaj fabryk, nie hardcodowanych danych — generuj dynamicznie
  3. Izoluj dane na test — unikaj race conditions
  4. Sprzątaj po sobie — zapobiegaj zanieczyszczeniu bazy danych
  5. Unikaj współdzielonego stanu bazy danych — używaj kontenerów dla izolacji
  6. Seeduj przez API, nie bezpośredni dostęp DB — testuj prawdziwe przepływy

Zastosuj te zasady od pierwszego dnia, a twój suite testowy będzie szybszy, bardziej niezawodny i łatwiejszy w utrzymaniu.

W następnym wpisie omówimy projektowanie utrzymywalnych suite’ów automatycznych — strukturę folderów, konwencje nazewnictwa, ownership i jak reviewować kod testowy jak kod produkcyjny.


Zadanie na ten tydzień: Wybierz jeden niestabilny test w swoim suite. Sprawdź, czy zależy od pre-istniejących danych. Jeśli tak, refaktoryzuj go, aby tworzył własne dane używając funkcji fabryki. Uruchom test 10 razy równolegle (Playwright: --workers=10). Jeśli przechodzi za każdym razem, naprawiłeś niestabilność.