TL;DR: Stop manually creating test data for load testing. Use Node.js seed scripts to generate realistic datasets (100K–1M records) in seconds. Integrate into CI/CD pipelines so every test run gets fresh, identical data.
The Problem: Load Testing Without Data
You're building a SaaS product. Your checkout flow works fine with 100 test orders. But what about 500K orders? Will your CSV export endpoint timeout? Will your search crash?
You can't know. Manual test data creation is impossible at scale—a single month of realistic data requires weeks of clicking through your UI.
Result: You ship features untested at scale. Performance bugs hit production.
This is why elite teams use seeding: one command generates a million realistic records in seconds to minutes. Then you load-test against real-world data volumes before customers do.
What Is Database Seeding?
Seeding = populating a database with initial data through code.
There are two types:
Reference data (static): Roles, permissions, currencies, countries. This data doesn't change. You create it once, reuse it forever.
Test data (dynamic): Users, orders, transactions. This varies per test run. You generate different datasets for different scenarios.
Most guides cover reference data and small dev datasets (100 records). This guide focuses on the harder problem: generating realistic test data at scale for load testing.
Part 1: Development Seeding (Quick Setup)
For day-to-day development, you need a fast, repeatable setup. Every developer on your team should be able to run one command and get identical test data.
Here's the pattern with Prisma:
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// Safety check: only run in non-production environments
if (process.env.NODE_ENV === 'production') {
throw new Error('Cannot seed production database');
}
console.log('Clearing existing data...');
await prisma.order.deleteMany();
await prisma.user.deleteMany();
await prisma.role.deleteMany();
console.log('Creating reference data...');
const adminRole = await prisma.role.create({
data: { name: 'admin', permissions: ['*'] },
});
const userRole = await prisma.role.create({
data: { name: 'user', permissions: ['read'] },
});
console.log('Creating test users...');
const alice = await prisma.user.create({
data: {
email: 'alice@test.com',
name: 'Alice',
roleId: adminRole.id,
},
});
const bob = await prisma.user.create({
data: {
email: 'bob@test.com',
name: 'Bob',
roleId: userRole.id,
},
});
console.log('Creating test orders...');
await prisma.order.createMany({
data: [
{ userId: alice.id, amount: 100.0, status: 'completed' },
{ userId: bob.id, amount: 50.0, status: 'pending' },
],
});
console.log('Seed complete: 2 roles, 2 users, 2 orders');
}
main()
.catch(e => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());Add this to package.json:
{
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}Run it:
npx prisma db seedThis approach is idempotent (run it 10 times, same result). It's fast (< 1 second). It's safe (deletes data only in dev environments).
But it only creates ~10 records. For load testing, you need thousands or millions.
Part 2: Testing & QA Seeding (Realistic Data at Scale)
This is where the real power of seeding comes in. You need realistic, abundant test data without waiting weeks to create it.
The key insight: batch inserts, not one-by-one. One query to insert 10,000 records beats 10,000 queries to insert 1 record each.
Install Faker.js (generates realistic fake data):
npm install @faker-js/fakerPrerequisites for this pattern:
- Database: PostgreSQL or CockroachDB (skipDuplicates not supported on SQLite, MongoDB, or SQLServer)
- Prisma v4+
- Node.js 16+ Now build a test-data seed script:
// prisma/seed-load-test.ts
import { PrismaClient } from '@prisma/client';
import { faker } from '@faker-js/faker';
const prisma = new PrismaClient();
async function seedLoadTest() {
console.log('⚙️ Starting load-test seed...');
console.time('Total seed time');
// Step 1: Create reference data
const adminRole = await prisma.role.upsert({
where: { name: 'admin' },
update: {},
create: { name: 'admin', permissions: ['*'] },
});
const userRole = await prisma.role.upsert({
where: { name: 'user' },
update: {},
create: { name: 'user', permissions: ['read'] },
});
// Step 2: Bulk insert 100K users
console.log('📝 Creating 100K test users...');
const batchSize = 1000;
const totalUsers = 100_000;
for (let i = 0; i < totalUsers; i += batchSize) {
const userBatch = Array.from({ length: Math.min(batchSize, totalUsers - i) }, () => ({
email: faker.internet.email(),
name: faker.person.fullName(),
roleId: Math.random() > 0.8 ? adminRole.id : userRole.id, // 20% admins
}));
await prisma.user.createMany({
data: userBatch,
skipDuplicates: true,
});
const created = Math.min(i + batchSize, totalUsers);
console.log(` ✓ Created ${created}/${totalUsers} users`);
}
// Step 3: Fetch all users for order creation
console.log('📋 Fetching user IDs...');
const allUsers = await prisma.user.findMany({
select: { id: true },
});
// Step 4: Bulk insert 500K orders
console.log('📊 Creating 500K test orders...');
const totalOrders = 500_000;
for (let i = 0; i < totalOrders; i += batchSize) {
const orderBatch = Array.from({ length: Math.min(batchSize, totalOrders - i) }, () => ({
userId: allUsers[Math.floor(Math.random() * allUsers.length)].id,
amount: faker.number.float({ min: 10, max: 1000, precision: 0.01 }),
status: faker.helpers.arrayElement(['pending', 'completed', 'failed']),
createdAt: faker.date.past(2),
}));
await prisma.order.createMany({
data: orderBatch,
skipDuplicates: true,
});
const created = Math.min(i + batchSize, totalOrders);
console.log(` ✓ Created ${created}/${totalOrders} orders`);
}
console.timeEnd('Total seed time');
console.log('✅ Load-test seed complete: 100K users, 500K orders');
}
seedLoadTest()
.catch(e => {
console.error('❌ Seed failed:', e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());Run it:
NODE_ENV=test npx ts-node prisma/seed-load-test.tsPerformance: This generates 100K users + 500K orders in ~60-90 seconds (depends on database performance, hardware, and network latency). Actual timing may vary—test locally to verify.
Database Note: skipDuplicates is supported on PostgreSQL and CockroachDB. For SQLite, MongoDB, or SQL Server, use upsert() instead.
The critical techniques:
- Batch inserts (1000 at a time) — Orders of magnitude faster than one-by-one
- Faker.js — Generates realistic emails, names, amounts, dates
- Progress logging — Feedback during long runs
skipDuplicates— Handles unique constraint violations gracefully (PostgreSQL/CockroachDB only)
Part 3: Integrating Into CI/CD
Seeding is powerful when automated. Before every performance test, generate fresh data—same seed, same results, every time.
GitHub Actions example:
# .github/workflows/load-test.yml
name: Load Testing
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
load-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: testpassword
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm install
- name: Setup database
run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://postgres:testpassword@localhost:5432/test
- name: Seed test data
run: npm run seed:load-test
env:
DATABASE_URL: postgresql://postgres:testpassword@localhost:5432/test
NODE_ENV: test
- name: Run load tests
run: npm run test:load
env:
DATABASE_URL: postgresql://postgres:testpassword@localhost:5432/test
- name: Upload results
if: always()
uses: actions/upload-artifact@v3
with:
name: load-test-results
path: results/Add to package.json:
{
"scripts": {
"seed:load-test": "ts-node prisma/seed-load-test.ts",
"test:load": "node tests/load.js"
}
}Now every commit triggers: migrations → seeding → load tests. No manual data setup required.
Part 4: The Idempotency Gotcha
Run the seed script twice. What happens? Do you get 2M records or still 1M?
If you're not careful, you get 2M. This breaks CI/CD pipelines.
The fix: idempotent seeding. Use upsert for reference data:
// Reference data: use upsert (safe to run multiple times)
const adminRole = await prisma.role.upsert({
where: { name: 'admin' },
update: {}, // No change if already exists
create: { name: 'admin', permissions: ['*'] },
});For test data, use skipDuplicates in bulk inserts:
// Test data: use skipDuplicates
await prisma.user.createMany({
data: userBatch,
skipDuplicates: true, // Ignores unique constraint violations
});Alternative: hash-based IDs ensure the same seed produces the same IDs:
import crypto from 'crypto';
const stableId = crypto
.createHash('md5')
.update('test-user-' + i)
.digest('hex');
await prisma.user.upsert({
where: { id: stableId },
update: {},
create: {
id: stableId,
email: `user${i}@test.com`,
name: faker.person.fullName(),
},
});This ensures: run the script 10 times, same 1M records every time.
Part 5: Real-World Example (E-Commerce Load Test)
You're building an e-commerce platform. Your product listing page queries the database for 100 products, calculates inventory, applies discounts. Works fine with 1000 products.
But what about 1M SKUs across 10K sellers? Does it still render in 500ms?
Without seeding, you can't answer that question.
With seeding, you can:
// Seed 10K sellers, 100K products, 5M orders
async function seedEcommerce() {
console.log('Creating sellers...');
for (let i = 0; i < 10000; i += 100) {
const batch = Array.from({ length: 100 }, (_, j) => ({
email: faker.internet.email(),
name: faker.company.name(),
}));
await prisma.seller.createMany({ data: batch });
}
console.log('Creating products...');
const allSellers = await prisma.seller.findMany({ select: { id: true } });
for (let i = 0; i < 1000000; i += 1000) {
const batch = Array.from({ length: 1000 }, () => ({
sellerId: allSellers[Math.floor(Math.random() * allSellers.length)].id,
name: faker.commerce.productName(),
price: parseFloat(faker.commerce.price()),
stock: faker.number.int({ min: 0, max: 1000 }),
}));
await prisma.product.createMany({ data: batch });
console.log(`Created ${i + 1000} products...`);
}
console.log('Creating orders...');
// ... repeat pattern for 5M orders
}Then run your load test:
it('should render product listing with 1M SKUs in < 500ms', async () => {
const start = performance.now();
const response = await api.get('/products?page=1&limit=100');
const duration = performance.now() - start;
expect(duration).toBeLessThan(500);
expect(response.body.length).toBe(100);
});Run this in CI/CD before every deploy. Catch performance regressions immediately, not in production.
Key Takeaways
Problem: Load testing requires realistic data. Manual creation is impossible.
Solution: Automated seed scripts with bulk inserts. Generate 1M records in 60 seconds.
Integration: Add to CI/CD. Every test run gets fresh, identical data.
The gotcha: Make seeds idempotent (upsert, skipDuplicates, stable IDs) so they work in pipelines.
The payoff: Performance bugs caught before customers hit them. Confidence that your system works at scale.
That's database seeding for testing. Simple concept, massive impact on reliability.
Tools You'll Need
- Prisma (ORM with built-in seed support) or Sequelize, knex
- Faker.js (generates realistic fake data)
- Node.js 16+ (async/await support)
- PostgreSQL or MySQL (tested with both) All are open-source, zero cost.
Start small: seed 1K records, verify it works. Then scale to 1M. The pattern doesn't change—just the batch size and iteration count.
Got stuck, or want this shipped end-to-end for you? bitroot.club builds custom products for founders. →