Prestaňte mockovať databázu: Integračné testy v ére Testcontainers
Ked sme sa hadali o mocks vs Testcontainers, produkcia vzdy vybrala vitaza. “Unit testy musia byť rýchle, preto mockujeme databázu.” Toto pravidlo som roky neotázne prijímal. Až kým mi mocky nezačali klamať - testy prechádzali, ale produkcia zlyhávala na edge cases, ktoré mockovaná in-memory databáza nikdy neodhalila.
Testcontainers zmenili moje uvažovanie. Spustíš reálnu PostgreSQL v Docker kontajneri, testy bežia proti skutočnej databáze, a predsa sú dostatočne rýchle pre CI.
Testované na: Node.js 20+, Jest/Vitest, PostgreSQL 15-16, Testcontainers Node.js 10.x. Rovnaké princípy platia pre Java, Python, Go.
Prečo mocky klamú
Problém 1: Správanie sa líši
// Mock
jest.mock('../db', () => ({
query: jest.fn().mockResolvedValue({ rows: [{ id: 1, name: 'Test' }] })
}));
test('finds user', async () => {
const user = await findUser(1);
expect(user.name).toBe('Test'); // Prechádza!
});
V produkcii:
-- Reálna query
SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL;
-- Mock nevedel o deleted_at stĺpci
Problém 2: Transakcie a izolácia
// Mock nepodporuje transakcie
test('atomic transfer', async () => {
await transfer(accountA, accountB, 100);
// Mock neoverí, že obe operácie sú v jednej transakcii
// V produkcii môže dôjsť k partial failure
});
Problém 3: Constraint violations
// Mock nevidí FK constraints
test('creates order with invalid user', async () => {
const order = await createOrder({ userId: 999 }); // User neexistuje
expect(order).toBeDefined(); // Mock: OK!
// PostgreSQL: ERROR: violates foreign key constraint
});
Problém 4: Query syntax a typos
// Mock nevaliduje SQL
const query = "SELECT * FORM users"; // Typo: FORM namiesto FROM
// Mock prejde, PostgreSQL hodí syntax error
Testcontainers: Reálna DB v testoch
Základný Setup
// src/tests/setup.ts
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
let container: StartedPostgreSqlContainer;
let pool: Pool;
export async function setupDatabase(): Promise<Pool> {
container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('testdb')
.withUsername('test')
.withPassword('test')
.start();
pool = new Pool({
connectionString: container.getConnectionUri()
});
// Spusti migrácie
await runMigrations(pool);
return pool;
}
export async function teardownDatabase(): Promise<void> {
await pool.end();
await container.stop();
}
export function getPool(): Pool {
return pool;
}
Jest Konfigurácia
// jest.setup.ts
import { setupDatabase, teardownDatabase, getPool } from './setup';
beforeAll(async () => {
await setupDatabase();
}, 60000); // Container startup môže trvať
afterAll(async () => {
await teardownDatabase();
});
// Reset dát medzi testami
afterEach(async () => {
const pool = getPool();
await pool.query('TRUNCATE users, orders, products RESTART IDENTITY CASCADE');
});
Vitest Konfigurácia
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globalSetup: './src/tests/global-setup.ts',
setupFiles: ['./src/tests/setup.ts'],
testTimeout: 30000,
hookTimeout: 60000,
pool: 'forks', // Izolácia medzi test súbormi
}
});
// src/tests/global-setup.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
export default async function globalSetup() {
const container = await new PostgreSqlContainer('postgres:16-alpine').start();
process.env.DATABASE_URL = container.getConnectionUri();
process.env.TESTCONTAINER_ID = container.getId();
return async () => {
await container.stop();
};
}
Stratégie izolácie dát
Stratégia 1: Truncate po každom teste
afterEach(async () => {
await pool.query(`
TRUNCATE users, orders, products
RESTART IDENTITY CASCADE
`);
});
Výhody: Čistý stav, jednoduché Nevýhody: Pomalšie pre veľa tabuliek
Stratégia 2: Transakčný rollback
let client: PoolClient;
beforeEach(async () => {
client = await pool.connect();
await client.query('BEGIN');
});
afterEach(async () => {
await client.query('ROLLBACK');
client.release();
});
// Testy používajú client namiesto pool
test('creates user', async () => {
await client.query('INSERT INTO users (name) VALUES ($1)', ['Test']);
// Po teste sa rollbackne
});
Výhody: Najrýchlejšie, žiadne mazanie Nevýhody: Nevhodné pre testy s externými volaniami
Stratégia 3: Savepoints pre nested transakcie
class TestContext {
private savepointCounter = 0;
async withSavepoint<T>(fn: () => Promise<T>): Promise<T> {
const savepoint = `sp_${++this.savepointCounter}`;
await this.client.query(`SAVEPOINT ${savepoint}`);
try {
return await fn();
} finally {
await this.client.query(`ROLLBACK TO SAVEPOINT ${savepoint}`);
}
}
}
Stratégia 4: Database per test (pomalé, ale izolované)
import { v4 as uuid } from 'uuid';
async function createTestDatabase(): Promise<string> {
const dbName = `test_${uuid().replace(/-/g, '')}`;
await adminPool.query(`CREATE DATABASE ${dbName} TEMPLATE testdb_template`);
return dbName;
}
afterEach(async () => {
await adminPool.query(`DROP DATABASE ${testDbName}`);
});
CI/CD Pipeline Setup
GitHub Actions
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
# Nie je potrebné - Testcontainers si spustia vlastný container
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
env:
TESTCONTAINERS_RYUK_DISABLED: true # GitHub Actions optimalizácia
- name: Upload coverage
uses: codecov/codecov-action@v3
Optimalizácie pre CI
// Reuse containers medzi test súbormi
const container = await new PostgreSqlContainer()
.withReuse() // Reuse ak existuje
.start();
// Menší image
const container = await new PostgreSqlContainer('postgres:16-alpine')
.start();
// Paralelné testy s izolovanými schémami
const schema = `test_${process.env.JEST_WORKER_ID}`;
await pool.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
await pool.query(`SET search_path TO ${schema}`);
Príklady testov
Test s reálnymi constraints
describe('Order creation', () => {
test('fails with invalid user', async () => {
await expect(
createOrder({ userId: 9999, items: [] })
).rejects.toThrow(/foreign key constraint/);
});
test('fails with duplicate order number', async () => {
await createOrder({ orderNumber: 'ORD-001', userId: 1 });
await expect(
createOrder({ orderNumber: 'ORD-001', userId: 1 })
).rejects.toThrow(/unique constraint/);
});
});
Test s transakciou
describe('Money transfer', () => {
test('is atomic', async () => {
await createAccount({ id: 1, balance: 100 });
await createAccount({ id: 2, balance: 50 });
// Simuluj failure počas transfer
jest.spyOn(db, 'query').mockImplementationOnce((query) => {
if (query.includes('UPDATE accounts SET balance')) {
throw new Error('Network error');
}
return originalQuery(query);
});
await expect(transfer(1, 2, 30)).rejects.toThrow();
// Obe účty musia mať pôvodné hodnoty
const acc1 = await getAccount(1);
const acc2 = await getAccount(2);
expect(acc1.balance).toBe(100); // Nezmenené
expect(acc2.balance).toBe(50); // Nezmenené
});
});
Test s JSON/JSONB
describe('JSONB operations', () => {
test('queries nested JSON correctly', async () => {
await pool.query(`
INSERT INTO products (name, metadata)
VALUES ('Laptop', '{"specs": {"ram": 16, "storage": "512GB"}}')
`);
const result = await pool.query(`
SELECT * FROM products
WHERE metadata->'specs'->>'ram' = '16'
`);
expect(result.rows).toHaveLength(1);
expect(result.rows[0].metadata.specs.storage).toBe('512GB');
});
});
Bug ktorý mock neodhalí
Case Study: Locale-sensitive sorting
// Mock
test('sorts users by name', async () => {
mockDb.query.mockResolvedValue({
rows: [{ name: 'Ábel' }, { name: 'Adam' }, { name: 'Boris' }]
});
const users = await getUsers({ orderBy: 'name' });
// Test prechádza, mock vracia presne čo sme mu dali
});
// Reálna DB
test('sorts users by name - Testcontainers', async () => {
await pool.query(`
INSERT INTO users (name) VALUES ('Boris'), ('Adam'), ('Ábel')
`);
const users = await getUsers({ orderBy: 'name' });
// PostgreSQL s C locale: Ábel je na konci (ASCII sorting)
// PostgreSQL s Slovak locale: Ábel je na začiatku
// Mock by toto nikdy neodhalil!
});
Case Study: Deadlock detection
test('handles deadlock gracefully', async () => {
// Simuluj konkurentné transakcie
const tx1 = pool.connect();
const tx2 = pool.connect();
await Promise.all([
(async () => {
await tx1.query('BEGIN');
await tx1.query('UPDATE accounts SET balance = 100 WHERE id = 1');
await delay(100);
await tx1.query('UPDATE accounts SET balance = 200 WHERE id = 2');
})(),
(async () => {
await tx2.query('BEGIN');
await tx2.query('UPDATE accounts SET balance = 300 WHERE id = 2');
await delay(100);
await tx2.query('UPDATE accounts SET balance = 400 WHERE id = 1');
})()
]).catch(e => {
expect(e.message).toContain('deadlock');
// Mock by deadlock nikdy nesimuloval
});
});
Performance porovnanie
| Metrika | Mocks | Testcontainers |
|---|---|---|
| Test startup | ~100ms | ~3-5s (prvý test) |
| Per-test overhead | ~1ms | ~10-50ms |
| Bug detection | Nízka | Vysoká |
| Maintenance | Vysoká | Nízka |
| CI time (100 testov) | ~10s | ~60s |
| Production parity | Žiadna | Vysoká |
Kedy použiť Mocks
Mocks majú stále miesto:
- Unit testy business logiky - žiadne DB volania
- Externé API - tretie strany, platobné brány
- Nedeterministické operácie - čas, random
- Failure testing - network errors, timeouts
// Mock pre externé API - správne použitie
jest.mock('../payment-gateway', () => ({
charge: jest.fn().mockResolvedValue({ transactionId: 'tx_123' })
}));
// Databáza - Testcontainers
test('creates order with payment', async () => {
const order = await createOrder({ ... });
expect(paymentGateway.charge).toHaveBeenCalled();
// Overíme že order je správne uložený v reálnej DB
const saved = await pool.query('SELECT * FROM orders WHERE id = $1', [order.id]);
expect(saved.rows[0].status).toBe('paid');
});
Záver
Testcontainers nie sú náhrada za unit testy - sú doplnok. Používaj:
- Unit testy + mocks pre izolovanú business logiku
- Integration testy + Testcontainers pre databázové operácie
- E2E testy pre celý flow
Investícia do Testcontainers setup sa vráti pri prvom bugu, ktorý by mock neodhalil.
FAQ
Sú Testcontainers pomalé pre CI?
S dobrým cachingom a reuse stratégiou pridávajú ~30-60s k celkovému CI času. Pre väčšinu projektov je to prijateľné za zvýšenú confidence.
Môžem bežať testy paralelne?
Áno. Použite izolované schémy alebo transakčný rollback. Testcontainers podporujú viacero paralelných spojení.
Čo ak nemám Docker v CI?
GitHub Actions, GitLab CI, CircleCI - všetky majú Docker. Pre Azure DevOps potrebuješ Linux agents alebo Windows containers.
Súvisiace články
- CI/CD pre monorepo - Optimalizácia test pipeline
- Zero-downtime migrácie PostgreSQL - Testovanie migrácií
Súvisiace články
GIN Index Pending List Overflow: Rýchle Zápisy, Pomalé Vyhľadávanie
Full-text search bol rýchly, teraz je pomalý. Príčina: GIN index pending list narástol obrovský počas bulk insertov a každé vyhľadávanie musí teraz skenovať nezoradené pending záznamy.
Feature flags bez technického dlhu: Automatická detekcia stale flags
End-to-end riešenie pre lifecycle management feature flags. Od runtime metrík cez statickú analýzu až po automatické removal PR.
Kubernetes rollout bez výpadku DB: Ako zastaviť PostgreSQL connection storm
Reprodukovateľný lab na demonštráciu connection stormu pri K8s rolloutoch. PgBouncer, preStop hooks a jitter - praktické riešenia s benchmarkmi.
PostgreSQL Idle in Transaction: Núdzový Playbook pre Zaseknuté Spojenia
Autovacuum nemôže bežať, table bloat rastie, všetko kvôli jednému 'idle in transaction' spojeniu. Tu je detekcia a kill playbook.
Citujte tento článok
Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.