# 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 ---