DATABASE TESTING UNIT-TESTING INTEGRATION-TESTING SOFTWARE-TESTING BACKEND WEB-DEVELOPMENT DATA-INTEGRITY TEST-AUTOMATION BEST-PRACTICES DEVOPS CLEAN-CODE DEVELOPER-EXPERIENCE

Menguji Interaksi Database: Strategi Praktis untuk Developer Web Modern

⏱️ 11 menit baca
👨‍💻

Menguji Interaksi Database: Strategi Praktis untuk Developer Web Modern

1. Pendahuluan

Sebagai developer web, database adalah tulang punggung hampir setiap aplikasi yang kita bangun. Di sinilah data penting disimpan, diakses, dan dimodifikasi. Bayangkan jika ada bug kecil dalam kode yang berinteraksi dengan database Anda – misalnya, salah menyimpan data pengguna, gagal mengambil transaksi, atau bahkan menghapus data yang tidak seharusnya. Dampaknya bisa fatal, mulai dari pengalaman pengguna yang buruk hingga kerugian finansial yang signifikan.

Inilah mengapa menguji interaksi database bukan hanya sekadar “nice-to-have”, melainkan sebuah keharusan. Artikel ini akan membawa Anda menyelami berbagai strategi praktis untuk menguji bagian aplikasi yang paling kritis ini. Kita akan membahas tantangan unik dalam pengujian database dan bagaimana mengatasinya, mulai dari unit testing yang cepat hingga integration testing yang realistis, lengkap dengan contoh konkret. Tujuan kita adalah membangun sistem yang tangguh, bebas bug, dan memberikan kepercayaan diri saat kita melakukan deployment.

📌 Mengapa Pengujian Database Penting?

Mari kita mulai perjalanan kita untuk menguasai pengujian database!

2. Mengapa Menguji Database Itu Sulit?

Sebelum kita melompat ke solusi, penting untuk memahami mengapa pengujian database seringkali menjadi tantangan tersendiri bagi developer. Database itu unik karena sifatnya yang stateful dan melibatkan operasi Input/Output (I/O).

Masalah Umum Tanpa Pengujian yang Baik:

3. Strategi Pengujian Database: Spektrum Pendekatan

Untuk mengatasi tantangan di atas, kita memiliki berbagai strategi pengujian, masing-masing dengan kelebihan dan kekurangannya. Penting untuk memilih pendekatan yang tepat sesuai dengan kebutuhan dan lapisan kode yang ingin Anda uji.

3.1. Unit Testing (Mocking/Stubbing)

🎯 Tujuan: Menguji unit kode terkecil (misalnya, sebuah fungsi atau metode di repository layer) secara terisolasi dari database fisik.

💡 Cara Kerja: Alih-alih membiarkan kode berinteraksi dengan database sungguhan, kita “memalsukan” (mock atau stub) dependensi database-nya. Ini berarti kita membuat objek tiruan yang meniru perilaku database atau ORM Anda.

Kapan Digunakan:

Contoh (Pseudo-code dengan mocking ORM):

// user.repository.js
class UserRepository {
  constructor(dbClient) {
    this.dbClient = dbClient; // Anggap ini ORM client
  }

  async createUser(userDto) {
    // Logika validasi bisnis
    if (!userDto.email || !userDto.password) {
      throw new Error("Email and password are required");
    }
    return await this.dbClient.users.create(userDto);
  }

  async findUserById(id) {
    return await this.dbClient.users.findUnique({ where: { id } });
  }
}

// user.repository.test.js
import { UserRepository } from './user.repository';

describe('UserRepository - Unit Test (Mocking)', () => {
  let mockDbClient;
  let userRepository;

  beforeEach(() => {
    // Membuat mock untuk dbClient
    mockDbClient = {
      users: {
        create: jest.fn(),
        findUnique: jest.fn(),
      },
    };
    userRepository = new UserRepository(mockDbClient);
  });

  test('should create a new user', async () => {
    const newUser = { email: 'test@example.com', password: 'password123' };
    mockDbClient.users.create.mockResolvedValue({ id: 1, ...newUser }); // Menentukan hasil mock

    const createdUser = await userRepository.createUser(newUser);

    expect(mockDbClient.users.create).toHaveBeenCalledWith(newUser);
    expect(createdUser).toEqual({ id: 1, ...newUser });
  });

  test('should throw error if email is missing', async () => {
    const invalidUser = { password: 'password123' };
    await expect(userRepository.createUser(invalidUser)).rejects.toThrow("Email and password are required");
    expect(mockDbClient.users.create).not.toHaveBeenCalled(); // Pastikan tidak memanggil DB
  });
});

Kelebihan: Sangat cepat, terisolasi, mudah di-debug. ❌ Kekurangan: Tidak menguji interaksi nyata dengan database. Bisa ada bug jika mock tidak mencerminkan perilaku database sebenarnya (misal, constraint error).

3.2. Integration Testing (In-Memory Database)

🎯 Tujuan: Menguji lapisan kode yang berinteraksi langsung dengan database, menggunakan database ringan yang berjalan di memori atau sebagai file lokal.

💡 Cara Kerja: Tes berinteraksi dengan database sungguhan, tetapi versi yang lebih sederhana atau in-memory (misalnya SQLite in-memory, H2 Database untuk Java). Setiap tes biasanya memulai dan mengakhiri dengan database yang bersih.

Kapan Digunakan:

Contoh (Pseudo-code dengan SQLite in-memory):

// user.repository.js (sama seperti sebelumnya, tapi sekarang akan berinteraksi dengan DB nyata)

// user.repository.integration.test.js
import { UserRepository } from './user.repository';
import { initializeInMemoryDb, cleanupInMemoryDb } from './db.utils'; // Fungsi untuk setup/cleanup DB

describe('UserRepository - Integration Test (In-Memory SQLite)', () => {
  let dbClient; // Klien DB nyata
  let userRepository;

  beforeAll(async () => {
    dbClient = await initializeInMemoryDb(); // Inisialisasi DB in-memory
    userRepository = new UserRepository(dbClient);
  });

  afterAll(async () => {
    await cleanupInMemoryDb(dbClient); // Bersihkan DB setelah semua tes
  });

  beforeEach(async () => {
    // Bersihkan tabel sebelum setiap tes untuk isolasi
    await dbClient.run('DELETE FROM users');
  });

  test('should create and retrieve a user', async () => {
    const newUser = { email: 'test@example.com', password: 'password123' };
    const createdUser = await userRepository.createUser(newUser);

    expect(createdUser.id).toBeDefined();
    expect(createdUser.email).toBe(newUser.email);

    const foundUser = await userRepository.findUserById(createdUser.id);
    expect(foundUser).toEqual(createdUser);
  });

  test('should not create user with duplicate email (assuming unique constraint)', async () => {
    const user1 = { email: 'unique@example.com', password: 'p1' };
    await userRepository.createUser(user1);

    const user2 = { email: 'unique@example.com', password: 'p2' };
    // Tergantung ORM/DB, ini mungkin throw error atau return null
    await expect(userRepository.createUser(user2)).rejects.toThrow();
  });
});

Kelebihan: Lebih realistis daripada mocking, relatif cepat, mudah diatur. ❌ Kekurangan: Database in-memory mungkin tidak 100% sama dengan database produksi (misalnya, perbedaan SQL dialect, fitur spesifik).

3.3. Integration Testing (Real Database dalam Kontainer)

🎯 Tujuan: Menguji interaksi database dengan database sungguhan yang sama persis atau sangat mirip dengan produksi, tetapi berjalan dalam lingkungan yang terisolasi (kontainer Docker).

💡 Cara Kerja: Setiap tes atau suite tes meluncurkan instance database dalam kontainer Docker (misalnya PostgreSQL, MySQL, MongoDB). Alat seperti Testcontainers sangat populer untuk pendekatan ini. Kontainer database diinisialisasi, data seeding dilakukan, tes dijalankan, dan kontainer dibersihkan.

Kapan Digunakan:

Contoh (Konsep dengan Testcontainers):

// user.repository.integration.test.js (dengan Testcontainers)
import { UserRepository } from './user.repository';
import { GenericContainer } from 'testcontainers'; // Contoh dari library Testcontainers

describe('UserRepository - Integration Test (PostgreSQL with Testcontainers)', () => {
  let container;
  let dbClient; // Klien DB nyata

  beforeAll(async () => {
    // 1. Luncurkan kontainer PostgreSQL
    container = await new GenericContainer('postgres:14')
      .withExposedPorts(5432)
      .withEnv('POSTGRES_DB', 'testdb')
      .withEnv('POSTGRES_USER', 'testuser')
      .withEnv('POSTGRES_PASSWORD', 'testpass')
      .start();

    // 2. Dapatkan koneksi string dan inisialisasi dbClient
    const connectionString = `postgresql://testuser:testpass@localhost:${container.getMappedPort(5432)}/testdb`;
    dbClient = await initializeDbClient(connectionString); // Fungsi inisialisasi koneksi nyata

    // 3. Jalankan migrasi skema untuk database tes
    await runMigrations(dbClient);
  }, 60000); // Timeout lebih besar karena startup kontainer

  afterAll(async () => {
    await dbClient.close();
    await container.stop(); // Hentikan dan hapus kontainer
  });

  beforeEach(async () => {
    // Bersihkan data sebelum setiap tes menggunakan transaksi dan rollback
    await dbClient.run('DELETE FROM users');
    // Atau bisa juga dengan memulai transaksi dan rollback di akhir tes
  });

  test('should create and retrieve a user from real PostgreSQL', async () => {
    const newUser = { email: 'prod@example.com', password: 'password123' };
    const createdUser = await userRepository.createUser(newUser);

    expect(createdUser.id).toBeDefined();
    expect(createdUser.email).toBe(newUser.email);

    const foundUser = await userRepository.findUserById(createdUser.id);
    expect(foundUser).toEqual(createdUser);
  });
});

Kelebihan: Sangat realistis, isolasi antar tes, reproduktif, mendukung fitur database produksi. ❌ Kekurangan: Lebih lambat daripada in-memory atau mocking, memerlukan Docker, overhead manajemen kontainer.

3.4. End-to-End Testing

🎯 Tujuan: Menguji seluruh alur aplikasi, dari antarmuka pengguna (UI) hingga database, seolah-olah pengguna sungguhan sedang berinteraksi.

💡 Cara Kerja: Menggunakan alat seperti Cypress atau Playwright untuk mengotomatiskan interaksi browser. Tes akan menavigasi UI, mengisi formulir, mengklik tombol, dan kemudian memverifikasi bahwa perubahan yang diharapkan terjadi di database melalui API atau langsung jika memungkinkan.

Kapan Digunakan:

Kelebihan: Mencakup seluruh sistem, memberikan kepercayaan diri yang tinggi. ❌ Kekurangan: Paling lambat, paling rentan terhadap kegagalan (flaky), sulit di-debug jika ada masalah di lapisan bawah.

4. Praktik Terbaik untuk Pengujian Database yang Efektif

Tidak peduli strategi mana yang Anda pilih, ada beberapa praktik terbaik yang akan membantu Anda menulis tes database yang kuat, cepat, dan dapat diandalkan.

4.1. Isolasi Tes dan Data Seeding

⚠️ Setiap tes harus berjalan secara independen. Pastikan satu tes tidak memengaruhi hasil tes lainnya.

-- Contoh pseudo-SQL untuk setup/teardown dengan transaksi
BEGIN; -- Mulai transaksi untuk tes
INSERT INTO users (email, password) VALUES ('test@example.com', 'hashedpassword');
-- Jalankan logika tes Anda
ROLLBACK; -- Batalkan semua perubahan

4.2. Kecepatan Tes

💡 Tes yang lambat akan dihindari. Usahakan tes database Anda secepat mungkin.

4.3. Pemisahan Lingkungan Testing

✅ Jangan pernah menjalankan tes otomatis Anda di database produksi atau staging yang sedang digunakan. Selalu gunakan lingkungan testing yang terpisah dan terisolasi. Ini mencegah kerusakan data dan masalah performa.

4.4. Verifikasi yang Tepat

4.5. Idempotensi Tes

📌 Tes Anda harus idempotent. Artinya, menjalankan tes yang sama berkali-kali harus selalu menghasilkan hasil yang sama, tanpa efek samping yang tidak diinginkan. Ini sangat penting untuk CI/CD.

5. Contoh Konkret: Menguji Repository dengan Testcontainers

Mari kita lihat contoh yang lebih detail menggunakan Testcontainers untuk menguji sebuah UserRepository yang berinteraksi dengan PostgreSQL. Kita akan menggunakan pendekatan “transaksi per tes” untuk isolasi data.

Anggap kita punya UserRepository sederhana yang menggunakan pg-promise (atau ORM/SQL