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.comistnieje- Użytkownik ma co najmniej jedno zamówienie
- To zamówienie ma ID
#12345
Co się dzieje gdy:
- Ktoś usuwa
testuser@example.compodczas debugowania? - Inny test tworzy zamówienie
#12345dla 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
| Warstwa | Narzędzie | Cel |
|---|---|---|
| Fabryki | Niestandardowe helpery lub Faker.js | Generuj unikalne dane testowe na żądanie |
| API Seeding | REST/GraphQL API twojej aplikacji | Seeduj dane przez prawdziwe przepływy |
| Izolacja | TestContainers / Docker Compose | Rozkręcaj izolowane bazy danych na uruchomienie |
| Sprzątanie | Fikstury testowe lub rollback transakcji | Automatycznie 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:
- Każdy test tworzy i posiada swoje dane — brak współdzielonych fikstur
- Używaj fabryk, nie hardcodowanych danych — generuj dynamicznie
- Izoluj dane na test — unikaj race conditions
- Sprzątaj po sobie — zapobiegaj zanieczyszczeniu bazy danych
- Unikaj współdzielonego stanu bazy danych — używaj kontenerów dla izolacji
- 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ść.