Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 18s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m15s
Deploy / Build API Image (push) Failing after 28s
Deploy / Build Web Image (push) Failing after 16s
Deploy / Build AI Services Image (push) Failing after 17s
E2E Tests / Playwright E2E (push) Failing after 31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m46s
Security Scanning / Trivy Scan — Web Image (push) Failing after 1m7s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 53s
Security Scanning / Trivy Filesystem Scan (push) Failing after 35s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 0s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Hoàn tất đợt cuối của nhiệm vụ chuyển toàn bộ tài liệu sang tiếng Việt. Đã dịch 22 file `.md` còn sót (~9.7k dòng) — gồm RUNBOOK, audits, docs/architecture, docs/load-testing, libs READMEs và các quick references. Giữ nguyên code blocks, đường dẫn, identifier kỹ thuật, URL và biến môi trường. Co-Authored-By: Paperclip <noreply@paperclip.ing>
660 lines
15 KiB
Markdown
660 lines
15 KiB
Markdown
# K6 Load Testing — Hướng Dẫn Bắt Đầu Nhanh
|
||
|
||
Bắt đầu với K6 load tests cho GoodGo Platform API trong vài phút.
|
||
|
||
---
|
||
|
||
## 1️⃣ Cài Đặt
|
||
|
||
### macOS
|
||
```bash
|
||
brew install k6
|
||
```
|
||
|
||
### Linux
|
||
```bash
|
||
apt-get install k6
|
||
```
|
||
|
||
### Docker
|
||
```bash
|
||
docker pull grafana/k6:latest
|
||
```
|
||
|
||
### Xác Minh Cài Đặt
|
||
```bash
|
||
k6 version
|
||
```
|
||
|
||
---
|
||
|
||
## 2️⃣ Thiết Lập Môi Trường Test
|
||
|
||
### Khởi Động API & Database
|
||
```bash
|
||
# Terminal 1: Start all services
|
||
pnpm dev
|
||
|
||
# Terminal 2: Seed test database (if needed)
|
||
pnpm db:seed
|
||
```
|
||
|
||
### Xác Minh API Đang Chạy
|
||
```bash
|
||
curl http://localhost:3001/api/v1/docs
|
||
# Should return Swagger UI
|
||
```
|
||
|
||
---
|
||
|
||
## 3️⃣ Tạo Test K6 Đầu Tiên
|
||
|
||
### Search Load Test Đơn Giản
|
||
|
||
Tạo file: `load-tests/search.k6.js`
|
||
|
||
```javascript
|
||
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️⃣ Chạy Test Đầu Tiên
|
||
|
||
### Phát Triển Cục Bộ
|
||
```bash
|
||
k6 run load-tests/search.k6.js
|
||
```
|
||
|
||
### Với Base URL Tùy Chỉnh
|
||
```bash
|
||
BASE_URL=http://localhost:3001/api/v1 k6 run load-tests/search.k6.js
|
||
```
|
||
|
||
### Với Docker
|
||
```bash
|
||
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️⃣ Test Authentication
|
||
|
||
Tạo file: `load-tests/auth.k6.js`
|
||
|
||
```javascript
|
||
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);
|
||
}
|
||
}
|
||
```
|
||
|
||
### Chạy Auth Test
|
||
```bash
|
||
k6 run load-tests/auth.k6.js
|
||
```
|
||
|
||
---
|
||
|
||
## 6️⃣ Test Tạo Listing (Đã Xác Thực)
|
||
|
||
Tạo file: `load-tests/listings.k6.js`
|
||
|
||
```javascript
|
||
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,
|
||
});
|
||
});
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
### Chạy Listing Test
|
||
```bash
|
||
k6 run load-tests/listings.k6.js
|
||
```
|
||
|
||
---
|
||
|
||
## 7️⃣ Test Payment
|
||
|
||
Tạo file: `load-tests/payments.k6.js`
|
||
|
||
```javascript
|
||
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,
|
||
});
|
||
});
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
### Chạy Payment Test
|
||
```bash
|
||
k6 run load-tests/payments.k6.js
|
||
```
|
||
|
||
---
|
||
|
||
## 8️⃣ Chạy Tất Cả Tests Với Kết Quả
|
||
|
||
Tạo file: `load-tests/all-scenarios.k6.js`
|
||
|
||
```javascript
|
||
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);
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
### Chạy Với Tóm Tắt
|
||
```bash
|
||
k6 run load-tests/all-scenarios.k6.js \
|
||
--vus=50 \
|
||
--duration=5m \
|
||
--summary-export=summary.json
|
||
```
|
||
|
||
---
|
||
|
||
## 9️⃣ Tạo Báo Cáo
|
||
|
||
### Báo Cáo JSON
|
||
```bash
|
||
k6 run load-tests/search.k6.js --summary-export=report.json
|
||
```
|
||
|
||
### Tải Lên Grafana Cloud
|
||
```bash
|
||
k6 run load-tests/search.k6.js \
|
||
--out cloud
|
||
# Requires: k6 login or K6_CLOUD_TOKEN env var
|
||
```
|
||
|
||
### Tạo Báo Cáo HTML (với extension)
|
||
```bash
|
||
k6 run load-tests/search.k6.js \
|
||
--out csv=results.csv
|
||
```
|
||
|
||
---
|
||
|
||
## 🔟 Tích Hợp CI
|
||
|
||
Tạo: `.github/workflows/load-test.yml`
|
||
|
||
```yaml
|
||
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
|
||
```
|
||
|
||
---
|
||
|
||
## 📊 Các Check K6 Phổ Biến
|
||
|
||
```javascript
|
||
// 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',
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 🛠️ Debug
|
||
|
||
### Output Chi Tiết
|
||
```bash
|
||
k6 run -v load-tests/search.k6.js
|
||
```
|
||
|
||
### Hiển Thị Request/Response
|
||
```bash
|
||
k6 run --http-debug=full load-tests/search.k6.js
|
||
```
|
||
|
||
### Giới Hạn Một VU
|
||
```bash
|
||
k6 run --vus=1 --iterations=1 load-tests/search.k6.js
|
||
```
|
||
|
||
---
|
||
|
||
## 📚 Bước Tiếp Theo
|
||
|
||
1. **Đọc Hướng Dẫn Đầy Đủ**: `K6_LOAD_TESTING_GUIDE.md`
|
||
2. **Kiểm Tra Endpoint**: `K6_ENDPOINTS_SUMMARY.md`
|
||
3. **Khám Phá Tài Liệu K6**: https://k6.io/docs
|
||
4. **Script Cộng Đồng**: https://github.com/grafana/k6-templates
|
||
|
||
---
|
||
|
||
## ✅ Xử Lý Sự Cố
|
||
|
||
### "connection refused"
|
||
- Đảm bảo API đang chạy: `pnpm dev`
|
||
- Kiểm tra port: 3001
|
||
- Xác minh BASE_URL: `http://localhost:3001/api/v1`
|
||
|
||
### "rate limit exceeded"
|
||
- Endpoint auth: giới hạn 5/giờ
|
||
- Phân tán request hoặc dùng dữ liệu test khác
|
||
- Kiểm tra database test có dữ liệu seed
|
||
|
||
### "insufficient credits" (payments)
|
||
- Payment yêu cầu người dùng đã xác thực
|
||
- Tạo phiên người dùng trước trong test
|
||
- Sử dụng thông tin xác thực provider test
|
||
|
||
### "timeout"
|
||
- Tăng K6 timeout trong options
|
||
- Kiểm tra log API để tìm lỗi
|
||
- Giảm số lượng VU ban đầu
|
||
|
||
---
|
||
|