karawaci.kode

2026-08-07 · 10 min

Database Seeder Otomatis dengan Faker.js untuk Staging Realistis

Saya baru saja menemukan staging environment sebuah klien Jakarta yang isinya tiga user bernama “Test User”, “Admin”, dan “John Doe”. QA mereka melaporkan tidak ada bug, tapi satu minggu setelah deploy ke production, laporan error masuk: nama pengguna dengan spasi di awal menyebabkan validasi gagal, transaksi dengan jumlah di atas 10 juta berperilaku aneh, dan pagination di halaman riwayat pecah ketika data lebih dari 50 row. Semuanya tidak terdeteksi karena staging tidak pernah punya data yang cukup realistis untuk mengekspos masalah itu.

Solusinya bukan menarik data production ke staging — itu risiko privasi, rumit secara hukum, dan sering dilarang compliance. Solusinya adalah seeder yang menghasilkan data sintetis yang cukup realistis untuk merepresentasikan pola production. Itulah yang akan saya bahas: seeder otomatis dengan Faker.js yang menghasilkan data Indonesia, deterministik, dan aman dijalankan berulang di CI.

Kenapa Data Seeder Generik Tidak Cukup

Seeder bawaan framework biasanya menghasilkan “[email protected]”, “[email protected]”, dan nama seperti “Foo Bar”. Data seperti ini gagal mengekspos masalah nyata:

  • Validasi nama yang tidak mengantisipasi multi-kata Bahasa Indonesia (“Muhammad Rizky Al-Farisi”)
  • Query sorting yang rusak dengan karakter non-ASCII
  • Layout UI yang pecah dengan nama panjang atau alamat dengan karakter khusus
  • Pagination dan offset query yang baru terlihat aneh saat data di atas ribuan row
  • Bug race condition yang hanya muncul saat banyak transaksi berdampingan dengan timestamp dekat

Faker.js dengan locale id_ID menghasilkan nama Indonesia, alamat dengan format lokal, hingga nomor telepon dengan prefiks operator Indonesia. Dikombinasikan dengan volume data yang cukup dan distribusi yang mendekati pola nyata, staging jadi jauh lebih berguna.

Setup: Faker.js v9 dengan Locale Indonesia

npm install --save-dev @faker-js/faker

Faker.js v9 tidak punya singleton global. Buat instance eksplisit dengan locale:

// src/seed/faker-instance.ts
import { Faker, id_ID, en } from '@faker-js/faker';

// id_ID sebagai primary locale, en sebagai fallback
// untuk method yang belum ada di locale Indonesia
export const faker = new Faker({
  locale: [id_ID, en],
});

Untuk seeder yang deterministik (penting untuk CI), set seed sebelum generate:

// Angka seed tetap = output selalu sama di semua run
faker.seed(42);

Ini krusial kalau ada snapshot test atau QA script yang mengandalkan nilai spesifik.

Struktur Seeder yang Maintainable

Jangan taruh semua logika seeder di satu file seed.ts yang panjang. Pisahkan per domain:

src/seed/
  index.ts           ← orchestrator, urutan eksekusi
  faker-instance.ts  ← instance Faker dengan locale
  users.ts
  products.ts
  orders.ts
  transactions.ts

Orchestrator mengontrol urutan dan volume:

// src/seed/index.ts
import { PrismaClient } from '@prisma/client';
import { faker } from './faker-instance';
import { seedUsers } from './users';
import { seedProducts } from './products';
import { seedOrders } from './orders';

const prisma = new PrismaClient();

const config = {
  users: parseInt(process.env.SEED_USERS ?? '500'),
  products: parseInt(process.env.SEED_PRODUCTS ?? '200'),
  ordersPerUser: parseInt(process.env.SEED_ORDERS_PER_USER ?? '10'),
};

async function main() {
  console.log('Seeding with config:', config);
  faker.seed(42); // deterministik

  const users = await seedUsers(prisma, config.users);
  const products = await seedProducts(prisma, config.products);
  await seedOrders(prisma, users, products, config.ordersPerUser);

  console.log('Seed selesai.');
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());

Seeder User: Data Indonesia yang Realistis

// src/seed/users.ts
import { PrismaClient } from '@prisma/client';
import { faker } from './faker-instance';

export async function seedUsers(prisma: PrismaClient, count: number) {
  const data = Array.from({ length: count }, () => {
    const firstName = faker.person.firstName();
    const lastName = faker.person.lastName();
    const username = faker.internet.username({ firstName, lastName }).toLowerCase();

    return {
      email: faker.internet.email({ firstName, lastName }).toLowerCase(),
      name: `${firstName} ${lastName}`,
      username,
      phone: faker.phone.number({ style: 'national' }), // format +62...
      address: faker.location.streetAddress({ useFullAddress: true }),
      city: faker.location.city(),
      createdAt: faker.date.between({
        from: new Date('2023-01-01'),
        to: new Date(),
      }),
      isActive: faker.datatype.boolean({ probability: 0.85 }), // 85% aktif
    };
  });

  // createMany jauh lebih cepat dari loop create satu-satu
  await prisma.user.createMany({
    data,
    skipDuplicates: true, // idempoten
  });

  return prisma.user.findMany({ select: { id: true, email: true } });
}

Perhatikan beberapa keputusan di sini: probability: 0.85 pada isActive merepresentasikan bahwa tidak semua user aktif — persis seperti production. faker.date.between dengan rentang 3 tahun menghasilkan distribusi createdAt yang realistis, bukan semua user dibuat pada hari yang sama.

Seeder Transaksi: Volume dan Edge Case

Bagian ini yang paling penting. Transaksi adalah tempat sebagian besar bug production bersembunyi.

// src/seed/transactions.ts
import { PrismaClient } from '@prisma/client';
import { faker } from './faker-instance';

type UserRef = { id: string };

export async function seedTransactions(
  prisma: PrismaClient,
  users: UserRef[],
  countPerUser: number,
) {
  const statuses = ['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED'] as const;
  // Distribusi mendekati production: mayoritas sukses
  const statusWeights = [10, 70, 15, 5];

  const allTransactions = users.flatMap((user) =>
    Array.from({ length: countPerUser }, () => {
      // Sengaja sisipkan edge case: jumlah di atas 10 juta
      const isLargeAmount = faker.datatype.boolean({ probability: 0.05 });
      const amount = isLargeAmount
        ? faker.number.int({ min: 10_000_001, max: 100_000_000 })
        : faker.number.int({ min: 1_000, max: 9_999_999 });

      return {
        userId: user.id,
        amount,
        currency: 'IDR',
        status: faker.helpers.weightedArrayElement(
          statuses.map((s, i) => ({ value: s, weight: statusWeights[i] })),
        ),
        referenceId: faker.string.alphanumeric(16).toUpperCase(),
        description: faker.commerce.productName(),
        createdAt: faker.date.recent({ days: 90 }),
        metadata: JSON.stringify({
          channel: faker.helpers.arrayElement(['QRIS', 'TRANSFER', 'VA', 'EWALLET']),
          deviceId: faker.string.uuid(),
        }),
      };
    }),
  );

  // Insert dalam batch 500 untuk menghindari timeout
  const batchSize = 500;
  for (let i = 0; i < allTransactions.length; i += batchSize) {
    await prisma.transaction.createMany({
      data: allTransactions.slice(i, i + batchSize),
      skipDuplicates: true,
    });
  }
}

faker.helpers.weightedArrayElement adalah senjata utama untuk distribusi realistis. Status SUCCESS 70% jauh lebih mendekati data nyata dibanding distribusi acak yang membagi rata empat status. Saya juga sengaja menanam 5% transaksi di atas 10 juta — edge case yang persis seperti yang ditemukan klien tadi.

Integrasi dengan Prisma dan package.json

Di package.json, tambahkan script seeder:

{
  "scripts": {
    "db:seed": "tsx src/seed/index.ts",
    "db:reset": "prisma migrate reset --force && npm run db:seed",
    "db:seed:ci": "SEED_USERS=100 SEED_PRODUCTS=50 SEED_ORDERS_PER_USER=5 tsx src/seed/index.ts"
  },
  "prisma": {
    "seed": "tsx src/seed/index.ts"
  }
}

Dengan field prisma.seed, Prisma akan menjalankan seeder otomatis setelah prisma migrate reset. Script db:seed:ci menggunakan volume kecil agar CI tidak memakan waktu terlalu lama.

Di GitHub Actions atau pipeline apapun, tambahkan step seeder setelah migration:

- name: Run migrations and seed
  run: |
    npx prisma migrate deploy
    SEED_USERS=200 npm run db:seed
  env:
    DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}

Anonymisasi Sebagian Data Production untuk Edge Case Spesifik

Ada kelas bug yang hanya bisa direproduksi dengan pola data production yang sangat spesifik. Dalam kasus seperti itu, anonymisasi data production lebih efektif dari generasi sintetis murni.

Script anonymisasi sederhana dengan pg-anonymizer atau query SQL langsung:

-- Anonymize nama dan email, pertahankan pola transaksi asli
UPDATE users
SET
  name     = 'User_' || id,
  email    = 'user_' || id || '@staging.example.com',
  phone    = '08' || LPAD(FLOOR(RANDOM() * 1000000000)::TEXT, 9, '0')
WHERE id IN (
  SELECT id FROM users ORDER BY RANDOM() LIMIT 1000
);

Jalankan ini pada dump production sebelum restore ke staging. Anda mendapat distribusi dan pola nyata tapi tanpa PII. Saya pakai pendekatan hybrid: Faker untuk volume besar, anonymized production sample untuk edge case spesifik. Kombinasi ini menangkap yang terlewat oleh masing-masing pendekatan.

Trade-off yang Perlu Diakui

Pendekatan ini tidak gratis:

Waktu setup awal: menulis seeder yang bagus untuk skema yang kompleks butuh 4-8 jam. Untuk klien dengan 20+ tabel yang saling relasi, saya butuh sehari penuh. Tapi investasi ini terbayar setelah satu siklus release — bug yang dulunya baru ketahuan di production sekarang tertangkap di staging.

Maintenance overhead: setiap kali skema berubah, seeder ikut diupdate. Kalau kolom baru NOT NULL ditambahkan tanpa default, seeder langsung error. Ini sebenarnya bagus karena memaksa developer memikirkan data default, tapi bisa jadi gesekan di tim yang belum terbiasa.

Data tidak selalu realistis secara statistik: Faker menghasilkan nilai valid tapi distribusinya acak. Kalau bisnis Anda punya pola distribusi yang sangat spesifik (misalnya 90% transaksi dari satu kota), Faker tidak otomatis mereplikasinya kecuali Anda menanamnya secara eksplisit lewat weightedArrayElement.

Tidak menggantikan test yang proper: seeder staging bukan pengganti unit test dan integration test. Ia melengkapi — memberikan lingkungan yang lebih realistis untuk pengujian manual dan smoke test, bukan menggantikan pengujian otomatis yang harusnya sudah ada.

Verdict

Kalau staging Anda masih berjalan dengan data test yang dibuat manual dan volumenya bisa dihitung dengan jari, Anda sedang bermain roulette dengan setiap deploy. Setup Faker.js dengan locale id_ID, distributor berbobot untuk status dan jumlah, dan upsert idempoten untuk CI memakan waktu setengah hari tapi langsung mengubah kualitas staging secara nyata.

Mulai dari tabel yang paling sering jadi sumber bug — biasanya tabel transaksi atau user — dan build dari sana. Jangan tunggu skema selesai sempurna; seeder yang cukup bagus hari ini jauh lebih berharga dari seeder sempurna yang tidak pernah jadi.

Di production kami, setelah implementasi seeder realistis, jumlah bug “tidak terdeteksi di staging” turun signifikan dalam dua sprint pertama. Bukan karena developer lebih hati-hati, tapi karena lingkungan testing akhirnya cukup jujur untuk mengekspos masalah yang ada.

Ditulis oleh Reza Pradipta