Files
goodgo-platform/K6_QUICK_START.md
Ho Ngoc Hai a5f260ce67 docs: add K6 endpoints summary and quick start guide
- K6_ENDPOINTS_SUMMARY.md: Quick reference for all API endpoints with request/response shapes
- K6_QUICK_START.md: Practical guide with executable examples for search, auth, listing, and payment load tests
- Includes example K6 scripts, CI integration template, and troubleshooting
- Complete with load test scenarios and reporting options

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 01:35:29 +07:00

14 KiB
Raw Blame History

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

  1. Read Full Guide: K6_LOAD_TESTING_GUIDE.md
  2. Check Endpoints: K6_ENDPOINTS_SUMMARY.md
  3. Explore K6 Docs: https://k6.io/docs
  4. 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