Root now contains only essential files: README.md, CLAUDE.md, CHANGELOG.md, CONTRIBUTING.md Reorganized into: docs/audits/ — all audit reports & checklists (71 files) docs/architecture/ — codebase overview, implementation plan docs/guides/ — auth guide, implementation checklist docs/load-testing/ — k6 load test guides & endpoints docs/security/ — payment & security reviews Also removed 5 untracked debug/investigation files and cleaned up playwright-report/ & test-results/ artifacts. Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
14 KiB
14 KiB
K6 Load Testing — Quick Start Guide
Get started with K6 load tests for GoodGo Platform API in minutes.
1️⃣ Installation
macOS
brew install k6
Linux
apt-get install k6
Docker
docker pull grafana/k6:latest
Verify Installation
k6 version
2️⃣ Setup Test Environment
Start API & Database
# Terminal 1: Start all services
pnpm dev
# Terminal 2: Seed test database (if needed)
pnpm db:seed
Verify API is Running
curl http://localhost:3001/api/v1/docs
# Should return Swagger UI
3️⃣ Create Your First K6 Test
Simple Search Load Test
Create file: load-tests/search.k6.js
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1';
export const options = {
stages: [
{ duration: '1m', target: 50 }, // Ramp up to 50 users
{ duration: '2m', target: 50 }, // Stay at 50 users
{ duration: '1m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.05'],
},
};
export default function() {
// Full-text search
const searchRes = http.get(
`${BASE_URL}/search?q=chung cu&page=1&perPage=20`
);
check(searchRes, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'has results': (r) => r.body.includes('items'),
});
sleep(2);
// Geo search
const geoRes = http.get(
`${BASE_URL}/search/geo?lat=10.7769&lng=106.7009&radiusKm=5`
);
check(geoRes, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(2);
}
4️⃣ Run Your First Test
Local Development
k6 run load-tests/search.k6.js
With Custom Base URL
BASE_URL=http://localhost:3001/api/v1 k6 run load-tests/search.k6.js
With Docker
docker run -i grafana/k6 run - < load-tests/search.k6.js
Output
/\ |‾‾| /‾‾/‾‾ |‾‾| /‾‾/‾‾
/ \ | |/ / | |/ /
/ \ | ( | (
/ \ | |\ \ | |\ \
/ \ |__| \_/\_/|__| \_/\_/
execution: local
script: load-tests/search.k6.js
output: -
scenarios: (100.00%) 1 local scenario, 50 max VUs, 4m30s max duration
✓ checks........................ 100%
✓ http_req_duration............. 96% ✓ 0 ✗ 12
✓ http_req_failed............... 0%
data_received........: 512 kB (1.8 kB/s)
data_sent..............: 45 kB (250 B/s)
http_reqs..............: 120 (0.44/s)
iteration_duration.....: 4s
iterations.............: 60 (0.22/s)
5️⃣ Authentication Test
Create file: load-tests/auth.k6.js
import http from 'k6/http';
import { check } from 'k6';
import { Counter, Trend } from 'k6/metrics';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1';
const loginCounter = new Counter('login_success');
const loginDuration = new Trend('login_duration');
export const options = {
stages: [
{ duration: '30s', target: 10 }, // 10 users
{ duration: '1m', target: 10 }, // Hold for 1 min
{ duration: '30s', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<300', 'p(99)<500'],
},
};
// Generate unique phone for each user
function generatePhone() {
const timestamp = new Date().getTime();
const random = Math.floor(Math.random() * 1000000);
return `09${String(timestamp + random).slice(-8)}`;
}
export default function() {
const phone = generatePhone();
const password = 'TestPass123!';
const fullName = 'Load Test User';
// Register
const registerRes = http.post(`${BASE_URL}/auth/register`, {
phone,
password,
fullName,
});
check(registerRes, {
'register status is 201': (r) => r.status === 201,
'has tokens': (r) => r.json().accessToken && r.json().refreshToken,
});
// Login (same credentials)
const loginRes = http.post(`${BASE_URL}/auth/login`, {
phone,
password,
});
loginDuration.add(loginRes.timings.duration);
check(loginRes, {
'login status is 201': (r) => r.status === 201,
'login response time < 300ms': (r) => r.timings.duration < 300,
});
if (loginRes.status === 201) {
loginCounter.add(1);
}
}
Run Auth Test
k6 run load-tests/auth.k6.js
6️⃣ Listing Creation Test (Authenticated)
Create file: load-tests/listings.k6.js
import http from 'k6/http';
import { check, group } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1';
export const options = {
stages: [
{ duration: '30s', target: 5 }, // 5 users (quota limited)
{ duration: '1m30s', target: 5 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<1000'],
},
};
function createAuthenticatedSession() {
// Generate unique user
const phone = `09${Math.random().toString().slice(2, 10)}`;
const password = 'TestPass123!';
const fullName = 'Listing Agent';
// Register user
const registerRes = http.post(`${BASE_URL}/auth/register`, {
phone,
password,
fullName,
});
if (registerRes.status !== 201) {
console.error('Registration failed:', registerRes.body);
return null;
}
return registerRes.json();
}
export default function() {
const session = createAuthenticatedSession();
if (!session?.accessToken) return;
const headers = {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
};
group('Create Listing', () => {
const listingData = {
transactionType: 'SALE',
priceVND: '5500000000',
propertyType: 'APARTMENT',
title: 'Căn hộ 3PN view sông Sài Gòn - Load Test',
description: 'Căn hộ cao cấp 3 phòng ngủ, nội thất đầy đủ, view trực diện sông Sài Gòn.',
address: '208 Nguyễn Hữu Cảnh',
ward: 'Phường 22',
district: 'Bình Thạnh',
city: 'Hồ Chí Minh',
latitude: 10.7942,
longitude: 106.7219,
areaM2: 85.5,
bedrooms: 3,
bathrooms: 2,
floor: 15,
totalFloors: 30,
yearBuilt: 2020,
legalStatus: 'Sổ hồng',
amenities: ['Hồ bơi', 'Gym', 'Sân chơi trẻ em'],
};
const res = http.post(`${BASE_URL}/listings`, listingData, {
headers,
});
check(res, {
'status is 201': (r) => r.status === 201,
'has listing id': (r) => r.json().id !== undefined,
'response time < 1s': (r) => r.timings.duration < 1000,
});
if (res.status === 201) {
const listingId = res.json().id;
// Get listing details
group('Get Listing Details', () => {
const detailRes = http.get(`${BASE_URL}/listings/${listingId}`);
check(detailRes, {
'status is 200': (r) => r.status === 200,
'title matches': (r) => r.json().title === listingData.title,
});
});
}
});
}
Run Listing Test
k6 run load-tests/listings.k6.js
7️⃣ Payment Test
Create file: load-tests/payments.k6.js
import http from 'k6/http';
import { check, group } from 'k6';
import { randomString } from 'https://jslib.k6.io/k6-utils/1.1.0/index.js';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1';
export const options = {
stages: [
{ duration: '30s', target: 10 },
{ duration: '1m', target: 10 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<1000'],
},
};
export default function() {
// Create session first
const phone = `09${Math.random().toString().slice(2, 10)}`;
const password = 'TestPass123!';
const registerRes = http.post(`${BASE_URL}/auth/register`, {
phone,
password,
fullName: 'Payment Test User',
});
if (registerRes.status !== 201) return;
const { accessToken } = registerRes.json();
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
};
group('Create Payment', () => {
const paymentRes = http.post(`${BASE_URL}/payments`, {
provider: 'VNPAY',
type: 'LISTING_FEE',
amountVND: 500000,
description: 'Load test payment',
returnUrl: 'http://localhost:3000/payment/return',
idempotencyKey: `load-test-${randomString(16)}`,
}, {
headers,
});
check(paymentRes, {
'status is 201': (r) => r.status === 201,
'has paymentUrl': (r) => r.json().paymentUrl !== undefined,
'response time < 1s': (r) => r.timings.duration < 1000,
});
if (paymentRes.status === 201) {
const paymentId = paymentRes.json().id;
// Check payment status
group('Get Payment Status', () => {
const statusRes = http.get(`${BASE_URL}/payments/${paymentId}`, {
headers,
});
check(statusRes, {
'status is 200': (r) => r.status === 200,
'has status field': (r) => r.json().status !== undefined,
});
});
}
});
}
Run Payment Test
k6 run load-tests/payments.k6.js
8️⃣ Run All Tests with Results
Create file: load-tests/all-scenarios.k6.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001/api/v1';
export const options = {
stages: [
{ duration: '1m', target: 50 }, // Ramp up
{ duration: '3m', target: 50 }, // Sustain
{ duration: '1m', target: 0 }, // Ramp down
],
thresholds: {
'http_req_duration{scenario:search}': ['p(95)<500'],
'http_req_duration{scenario:auth}': ['p(95)<300'],
'http_req_failed': ['rate<0.05'],
},
};
export default function() {
// Scenario 1: Public Search (60% of load)
group('Search Scenario', () => {
const res = http.get(`${BASE_URL}/search?q=chung cu&page=1&perPage=20`, {
tags: { scenario: 'search' },
});
check(res, { 'status 200': (r) => r.status === 200 });
sleep(1);
});
// Scenario 2: Auth (20% of load)
if (Math.random() < 0.2) {
group('Auth Scenario', () => {
const res = http.post(`${BASE_URL}/auth/login`, {
phone: '0912345678',
password: 'TestPass123!',
}, {
tags: { scenario: 'auth' },
});
check(res, { 'status 201 or 401': (r) => r.status === 201 || r.status === 401 });
sleep(1);
});
}
// Scenario 3: Geo Search (20% of load)
if (Math.random() < 0.2) {
group('Geo Search Scenario', () => {
const res = http.get(
`${BASE_URL}/search/geo?lat=10.77&lng=106.70&radiusKm=5`,
{ tags: { scenario: 'geo' } }
);
check(res, { 'status 200': (r) => r.status === 200 });
sleep(1);
});
}
}
Run with Summary
k6 run load-tests/all-scenarios.k6.js \
--vus=50 \
--duration=5m \
--summary-export=summary.json
9️⃣ Generate Reports
JSON Report
k6 run load-tests/search.k6.js --summary-export=report.json
Upload to Grafana Cloud
k6 run load-tests/search.k6.js \
--out cloud
# Requires: k6 login or K6_CLOUD_TOKEN env var
Generate HTML Report (with extension)
k6 run load-tests/search.k6.js \
--out csv=results.csv
🔟 CI Integration
Create: .github/workflows/load-test.yml
name: Load Tests
on:
schedule:
- cron: '0 2 * * *' # Daily at 2 AM
workflow_dispatch:
jobs:
load-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:16
env:
POSTGRES_DB: goodgo_test
POSTGRES_USER: goodgo
POSTGRES_PASSWORD: test_secret
options: --health-cmd pg_isready --health-interval 10s
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup database
env:
DATABASE_URL: postgresql://goodgo:test_secret@localhost:5432/goodgo_test
run: |
pnpm db:migrate:deploy
pnpm db:seed
- name: Start API
env:
DATABASE_URL: postgresql://goodgo:test_secret@localhost:5432/goodgo_test
run: pnpm --filter @goodgo/api run build &
- name: Install K6
run: sudo apt-get install -y k6
- name: Wait for API
run: |
for i in {1..30}; do
curl http://localhost:3001/api/v1/docs && break
sleep 2
done
- name: Run Load Tests
run: k6 run load-tests/search.k6.js --summary-export=results.json
- name: Upload Results
uses: actions/upload-artifact@v4
if: always()
with:
name: k6-results
path: results.json
📊 Common K6 Checks
// HTTP Status
check(res, { 'status 200': (r) => r.status === 200 });
// Response Time
check(res, { 'response < 500ms': (r) => r.timings.duration < 500 });
// JSON Content
check(res, { 'has id': (r) => r.json().id });
// Body Contains
check(res, { 'body contains': (r) => r.body.includes('text') });
// Multiple Conditions
check(res, {
'status is 2xx': (r) => r.status >= 200 && r.status < 300,
'response time acceptable': (r) => r.timings.duration < 1000,
});
// Response Headers
check(res, {
'content-type is json': (r) => r.headers['Content-Type'] === 'application/json',
});
🛠️ Debugging
Verbose Output
k6 run -v load-tests/search.k6.js
Show Request/Response
k6 run --http-debug=full load-tests/search.k6.js
Limit to Single VU
k6 run --vus=1 --iterations=1 load-tests/search.k6.js
📚 Next Steps
- Read Full Guide:
K6_LOAD_TESTING_GUIDE.md - Check Endpoints:
K6_ENDPOINTS_SUMMARY.md - Explore K6 Docs: https://k6.io/docs
- Community Scripts: https://github.com/grafana/k6-templates
✅ Troubleshooting
"connection refused"
- Ensure API is running:
pnpm dev - Check port: 3001
- Verify BASE_URL:
http://localhost:3001/api/v1
"rate limit exceeded"
- Auth endpoints: 5/hour limit
- Spread requests or use different test data
- Check test database has seed data
"insufficient credits" (payments)
- Payments require authenticated user
- Create user session first in test
- Use test provider credentials
"timeout"
- Increase K6 timeout in options
- Check API logs for errors
- Reduce number of VUs initially