Späť na blog

Prestaňte mockovať databázu: Integračné testy v ére Testcontainers

|
| testing, testcontainers, postgresql, docker, ci-cd

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

MetrikaMocksTestcontainers
Test startup~100ms~3-5s (prvý test)
Per-test overhead~1ms~10-50ms
Bug detectionNízkaVysoká
MaintenanceVysokáNízka
CI time (100 testov)~10s~60s
Production parityŽiadnaVysoká

Kedy použiť Mocks

Mocks majú stále miesto:

  1. Unit testy business logiky - žiadne DB volania
  2. Externé API - tretie strany, platobné brány
  3. Nedeterministické operácie - čas, random
  4. 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

Súvisiace články

Citujte tento článok

Ak na článok odkazujete, pridajte pôvodnú URL a uveďte autora.

Michal Drozd. "Prestaňte mockovať databázu: Integračné testy v ére Testcontainers". https://www.michal-drozd.com/sk/blog/testcontainers-vs-mocking/ (Publikované 24. apríla 2025).