Skip to main content
/tayyab/portfolio — zsh
tayyab
TA
// dispatch.read --classified=false --access-level: public

K6 Load Testing Tutorial for QA Engineers: From Zero to CI/CD Integration (2026)

March 5, 2026 EST. READ: 14 MIN #API Testing

K6 Load Testing Tutorial for QA Engineers: From Zero to CI/CD Integration (2026)

Load testing is the forgotten child of test automation. While everyone builds UI tests with Playwright and API tests with Postman, production systems regularly crash under user load because nobody tested whether the API can handle 100 concurrent users.

K6 changes that. It's a load testing tool built for engineers (not just performance teams). You write tests in JavaScript—syntax you already know—and run them locally or in CI/CD.

In this guide, I'll show you how to load test your APIs, set performance thresholds that fail the build automatically, and integrate everything into GitHub Actions so load tests run on every PR.

Table of Contents

  1. Why Load Testing Matters
  2. Install K6
  3. Write Your First Load Test
  4. Ramp Up Users Over Time
  5. Set Thresholds to Fail the Build
  6. Real Project Example: AI Sales Assistant
  7. Generate HTML Reports
  8. Integrate with GitHub Actions
  9. Troubleshooting
  10. FAQ

Why Load Testing Matters

The Reality: I've worked on 12 projects. 3 of them had production outages because APIs couldn't handle normal user load.

  • Wells Fargo automation: 50 concurrent users → 80% of requests timed out
  • Finboa fintech platform: 200 concurrent users → database connection pool exhausted
  • AI Sales Assistant: 100 concurrent requests → response time degraded from 200ms to 5000ms

Load testing catches this before production, when it's free to fix. After production, it costs 10x more in engineering time + reputation damage.

K6 makes load testing so easy that there's no excuse not to do it.


Install K6

Mac / Linux

# Homebrew
brew install k6

# Verify
k6 version
# Output: k6 v0.50.0

Windows

# Chocolatey
choco install k6

# Or download from https://github.com/grafana/k6/releases

Docker

docker run --rm -v $(pwd):/scripts grafana/k6 run /scripts/load-test.js

Write Your First Load Test

Create load-test.js:

import http from 'k6/http';
import { check } from 'k6';

export default function () {
  // Send a GET request
  const res = http.get('https://jsonplaceholder.typicode.com/posts/1');

  // Verify the response
  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
    'has title': (r) => r.json('title') !== null,
  });
}

export const options = {
  // 10 virtual users for 30 seconds
  vus: 10,
  duration: '30s',
};

Run the test:

k6 run load-test.js

Output:

          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (  /  /
   /          \   |  |\  \ /  ‾‾
  / _________ \  |__| \__\/

     execution: local
        script: load-test.js
        output: -

  scenarios: (100.00%) 1 scenario, 10 max VUs, 30s max duration

     ✓ status is 200
     ✓ response time < 500ms
     ✓ has title

     checks.........................: 100% ✓ 300
     http_reqs......................: 300    10/s
     http_req_duration..............: avg=105.21ms min=85.2ms max=320.1ms p(95)=220.3ms

✅ All checks passed. Response time averaged 105ms. API handled 10 concurrent users easily.


Ramp Up Users Over Time

Instead of maintaining 10 users constantly, ramp up gradually (like real traffic):

import http from 'k6/http';
import { check } from 'k6';

export default function () {
  const res = http.get('https://api.example.com/leads');
  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 1000ms': (r) => r.timings.duration < 1000,
  });
}

export const options = {
  stages: [
    { duration: '30s', target: 0 },      // Start with 0 users
    { duration: '1m30s', target: 100 },  // Ramp up to 100 users over 90s
    { duration: '20s', target: 100 },    // Stay at 100 for 20s (stress test)
    { duration: '30s', target: 0 },      // Ramp down to 0
  ],
  thresholds: {
    http_req_duration: ['p(95)<1000'],  // 95th percentile response time < 1000ms
    http_req_failed: ['rate<0.1'],      // Failure rate < 10%
  },
};

What this does:

  • Gradually increases load from 0 to 100 concurrent users over 90 seconds
  • Maintains 100 users for 20 seconds (stress phase)
  • Gradually reduces back to 0
  • Fails the test if: p(95) response time > 1000ms OR failure rate > 10%

Set Thresholds to Fail the Build

Thresholds are the key to integrating K6 into CI/CD. Without thresholds, every load test succeeds (not useful).

export const options = {
  vus: 50,
  duration: '1m',
  thresholds: {
    // Response time thresholds
    'http_req_duration': [
      'p(50)<200',    // Median response time < 200ms
      'p(95)<500',    // 95th percentile < 500ms
      'p(99)<1000',   // 99th percentile < 1000ms
    ],
    // Error thresholds
    'http_req_failed': [
      'rate<0.05',    // Less than 5% of requests fail
    ],
    // Connection time
    'http_req_connecting': [
      'p(95)<100',
    ],
  },
};

If ANY threshold is exceeded, k6 run exits with code 1 (fails in CI). If all pass, exit code 0 (success).


Real Project Example: AI Sales Assistant

On my AI Sales Assistant project, I load tested the Lead API:

Test scenario: 200 concurrent users creating leads over 2 minutes

import http from 'k6/http';
import { check } from 'k6';

const baseUrl = __ENV.BASE_URL || 'https://api.example.com';
const apiKey = __ENV.API_KEY; // Passed from GitHub Secrets

export const options = {
  stages: [
    { duration: '30s', target: 50 },
    { duration: '30s', target: 100 },
    { duration: '30s', target: 200 },   // Peak load: 200 concurrent
    { duration: '30s', target: 0 },
  ],
  thresholds: {
    'http_req_duration': [
      'p(50)<300',
      'p(95)<700',
      'p(99)<2000',
    ],
    'http_req_failed': ['rate<0.05'],
  },
};

export default function () {
  const payload = JSON.stringify({
    first_name: `User_${Date.now()}`,
    email: `user_${Date.now()}@example.com`,
    phone: '555-0100',
    company: 'TechCorp',
  });

  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${apiKey}`,
  };

  const res = http.post(`${baseUrl}/leads`, payload, { headers });

  check(res, {
    'create lead: status 201': (r) => r.status === 201,
    'create lead: has id': (r) => r.json('id') !== null,
    'create lead: response < 500ms': (r) => r.timings.duration < 500,
  });
}

Results:

✓ All thresholds passed
http_req_duration............: avg=245ms p(95)=580ms p(99)=1200ms
http_req_failed..............: 0.00%
http_reqs....................: 5400  (at 200 VUs)

Verdict: API handles 200 concurrent users comfortably. Response time stays under 500ms for 95% of requests. Deploy with confidence.

Before this test, we were flying blind. After adding load tests to the PR workflow, we caught a database connection pool issue that would have caused production outages.


Generate HTML Reports

K6 generates reports in multiple formats:

Web Dashboard (Real-time)

k6 run --out web load-test.js
# Opens http://localhost:8084 in your browser automatically

JSON Report (For parsing/CI)

k6 run --out json=results.json load-test.js

JUnit XML (For GitHub)

Install plugin:

k6 extension install github.com/grafana/xk6-output-prometheus-remote

Then:

k6 run --out prometheus-remote load-test.js

Integrate with GitHub Actions

Create .github/workflows/load-tests.yml:

name: K6 Load Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  load-test:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      
      - name: Setup K6
        run: |
          sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
          echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6-stable.list
          sudo apt-get update
          sudo apt-get install k6
      
      - name: Run load tests
        env:
          BASE_URL: https://staging-api.example.com
          API_KEY: ${{ secrets.STAGING_API_KEY }}
        run: |
          k6 run --out json=results.json tests/load-test.js
      
      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: k6-results
          path: results.json
          retention-days: 30
      
      - name: Comment on PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const results = JSON.parse(fs.readFileSync('results.json', 'utf8'));
            const metrics = results.metrics;
            
            const comment = `
## 📊 Load Test Results
- **Status:** ✅ PASSED
- **Duration:** 2 minutes
- **Peak VUs:** 200
- **Avg Response Time:** ${Math.round(metrics.http_req_duration.values.avg)}ms
- **P95 Response Time:** ${Math.round(metrics.http_req_duration.values['p(95)'])}ms
- **Error Rate:** ${(metrics.http_req_failed.values.rate * 100).toFixed(2)}%
            `;
            
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: comment,
            });

Now every PR gets a load test run automatically. If thresholds fail, the build fails and the PR can't be merged.


Troubleshooting

Issue: "k6: command not found"

  • Fix: Install via your package manager (brew, apt, choco) or use Docker

Issue: "thresholds failed: rate<0.05 failed"

  • Cause: API is returning 5%+ errors under load
  • Fix: Investigate API error logs; likely database or connection pool issue

Issue: "response time p(95)>1000ms"

  • Cause: API is slow under load
  • Fix: Profile database queries; add caching; scale horizontally

Issue: "connection timeout after 30s"

  • Cause: Too many concurrent connections overwhelming the API
  • Fix: Either reduce VUs (test configuration) or increase API capacity

FAQ

Q: Should I load test every PR?
A: Yes, but with realistic load (50-100 VUs, not 1000+). Full stress tests (200+ VUs) once per week.

Q: What's the difference between K6 and Apache JMeter?
A: K6 = modern, JavaScript-based, CI/CD first. JMeter = older, Java GUI-heavy, overkill for most projects. K6 wins for speed and integration.

Q: Can K6 test databases?
A: Not directly (K6 is HTTP/WebSocket focused). For database load testing, use dedicated tools like pgbench.

Q: What load should I test against?
A: Start with your peak expected users × 1.5 (safety margin). Example: 1000 daily active → test 1500 concurrent.

Q: Can K6 replace manual performance testing?
A: 90% yes. K6 automated testing + manual investigation of outliers = best approach.

Q: How do I load test authenticated APIs?
A: Use login endpoint to get token in setup, pass it to requests:

export function setup() {
  const authRes = http.post('https://api.example.com/login', { email, password });
  return { token: authRes.json('access_token') };
}

export default function (data) {
  const headers = { Authorization: `Bearer ${data.token}` };
  http.get('https://api.example.com/protected', { headers });
}
Tayyab Akmal
// author

Tayyab Akmal

AI & QA Automation Engineer

I've caught critical bugs in fintech, e-commerce, and SaaS platforms — then built the automation that prevents them from shipping again. 6+ years scaling test automation and AI-driven QA.

// feedback_channel

FOUND THIS USEFUL?

Share your thoughts or let's discuss automation testing strategies.

→ Start Conversation
Available for hire