Claude
Skills
Sign in
Back

running-load-tests-with-k6

Included with Lifetime
$97 forever

Single source of truth for designing, running, and interpreting k6 load tests against lenne.tech fullstack APIs. Defines installation paths (brew, docker, npm), the three canonical scenarios (smoke / load / soak), endpoint discovery from the generated SDK, realistic Better-Auth login flows, threshold defaults for ~10 concurrent users (p95 < 500ms, error rate < 1%, http_req_failed < 1%), result interpretation, and the optimisation ladder when the system fails (DB indices, query rewrites, caching, connection pool sizing, rate-limit relaxation, payload trimming). Activates whenever an agent or command needs to validate that the API is stable for ~10 concurrent users performing many actions in short time, or to detect performance regressions via k6. Currently used by /lt-dev:production-ready, lt-dev:production-readiness-orchestrator, and lt-dev:performance-reviewer. NOT for Lighthouse frontend performance (use a11y-reviewer). NOT for unit performance assertions (use the test runner directly).

Web Dev

What this skill does


# Running Load Tests with k6

This skill is the **single source of truth** for k6 load tests in the lenne.tech stack. It exists so every consumer (command, agent, reviewer) produces comparable scenarios with the same thresholds, the same auth flow, and the same optimisation ladder.

> **Goal:** Prove the API is stable for **10 concurrent users performing many actions in a short time window** — and surface concrete optimisation steps when it is not.

## When to Use This Skill

| Caller | Phase | Trigger |
|--------|-------|---------|
| `/lt-dev:production-ready` | Phase 3 | Mandatory load-test gate before sign-off |
| `lt-dev:production-readiness-orchestrator` | Phase 3 | Runs the scenarios, owns the optimisation loop |
| `lt-dev:performance-reviewer` | Phase 7 (optional) | Regression check vs baseline JSON |
| Manual user invocation | Ad-hoc | Reproducing a customer-reported slowness |

## Step 1 — Ensure k6 is available

Detect first, install only when missing.

```bash
command -v k6 >/dev/null 2>&1 && k6 version || echo "missing"
```

Install paths in order of preference:

| Platform | Command |
|----------|---------|
| macOS | `brew install k6` |
| Linux (deb) | `sudo gpg -k; sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69; echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list; sudo apt-get update; sudo apt-get install k6` |
| Any (no install) | `docker run --rm -i --network host grafana/k6:latest run -` (pipes script via stdin) |
| CI / no sudo | Download static binary from `https://github.com/grafana/k6/releases/latest` and place on `$PATH` |

Never call `npm install -g k6` — that package is a placeholder and not the real binary.

If k6 cannot be installed at all, the consumer must classify Phase 3 as **BLOCKED — k6 unavailable** and stop. Never silently skip the load test.

## Step 2 — Resolve the API base URL and warm-up the server

The base URL must come from a deterministic source — pick the first that resolves:

1. `$API_BASE_URL` environment variable
2. `lt.config.json` → `ports.api` → `http://localhost:<port>`
3. `~/.lenneTech/ports.json` registry entry for the current project slug
4. `projects/api/src/config.env.ts` default (`PORT`)
5. Fallback `http://localhost:3000`

The API must already be running when k6 starts. If the consumer detects no listening server, start it via the `managing-dev-servers` skill (`lt dev up` preferred, otherwise `pnpm run dev` with `run_in_background: true`) and wait for `GET /meta` (or `/health`) to return 200 before calling k6.

## Step 3 — Discover the endpoint surface

Pull the realistic endpoint list from the generated SDK so the load test exercises actual code paths, not invented URLs.

```bash
# Backend (NestJS) — Swagger contract
curl -s "${API_BASE_URL}/api-json" | jq -r '.paths | keys[]'

# Frontend SDK — generated REST client
test -f projects/app/app/api/sdk.gen.ts && grep -E "url: '/" projects/app/app/api/sdk.gen.ts | sort -u
```

Pick a representative subset for the scenario (typically 5–8 endpoints). Always include:

- One auth endpoint (`POST /auth/sign-in`)
- One read-heavy list endpoint (e.g. `GET /users` with paging)
- One read-heavy detail endpoint (`GET /users/:id` or similar)
- One write endpoint (POST/PATCH) that exercises a realistic mutation
- One filter/search endpoint if available

## Step 4 — Authenticate via Better-Auth (the realistic path)

The lt-stack ships Better-Auth in cookie mode. Don't bypass it — load tests that hit `/auth/sign-in` and propagate the session cookie find real bugs (DB pool exhaustion under login storm, refresh-token contention, etc).

Test users come from the seed script. Either reuse the deterministic seeded credentials or call the seed script before the run:

```bash
test -f projects/api/scripts/seed.ts && pnpm --filter @projects/api run seed
```

In the k6 script, log in once per VU in `setup()` and pass the cookie via shared state to keep auth out of the per-iteration timing. Default test users follow the seed convention (`[email protected][email protected]`, password `Password!23`) — verify against the actual seed script before relying on this.

## Step 5 — Three canonical scenarios

Use exactly these three scenarios. The default for `/lt-dev:production-ready` Phase 3 is **load**; **smoke** is the pre-check; **soak** runs only when the user passes `--include-soak`.

### Smoke (1 VU, 1 minute) — sanity check before the real run

```js
// k6/scenarios/smoke.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  vus: 1,
  duration: '1m',
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<500'],
  },
};
```

### Load (10 VUs ramp + steady, ~5 minutes) — the production-ready gate

```js
// k6/scenarios/load.js
export const options = {
  stages: [
    { duration: '30s', target: 10 },  // ramp up
    { duration: '4m',  target: 10 },  // steady state
    { duration: '30s', target: 0  },  // ramp down
  ],
  thresholds: {
    http_req_failed:   ['rate<0.01'],
    http_req_duration: ['p(95)<500', 'p(99)<1500'],
    checks:            ['rate>0.99'],
  },
};
```

### Soak (10 VUs, 30 minutes) — long-running stability check

```js
// k6/scenarios/soak.js
export const options = {
  vus: 10,
  duration: '30m',
  thresholds: {
    http_req_failed:   ['rate<0.01'],
    http_req_duration: ['p(95)<500'],
  },
};
```

The **soak** scenario detects memory leaks, connection-pool exhaustion, and slow-growing GC pressure that load misses. Run it as a long-running background process via `run_in_background: true` and re-poll its summary file at the end.

## Step 6 — A complete script template

Place all k6 scripts under `tests/load/` (create the directory if missing). One scenario per file plus a shared `helpers.js`.

```js
// tests/load/helpers.js
import http from 'k6/http';
import { check } from 'k6';

const BASE_URL = __ENV.API_BASE_URL || 'http://localhost:3000';

export function login(email, password) {
  const res = http.post(`${BASE_URL}/auth/sign-in/email`, JSON.stringify({ email, password }), {
    headers: { 'Content-Type': 'application/json' },
  });
  check(res, { 'login 200': (r) => r.status === 200 });
  return res.cookies; // propagate as Cookie header in subsequent calls
}

export function authedHeaders(cookies) {
  const cookieHeader = Object.entries(cookies)
    .flatMap(([name, arr]) => arr.map((c) => `${name}=${c.value}`))
    .join('; ');
  return { 'Content-Type': 'application/json', Cookie: cookieHeader };
}

export { BASE_URL };
```

```js
// tests/load/load.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { BASE_URL, login, authedHeaders } from './helpers.js';

export const options = {
  stages: [
    { duration: '30s', target: 10 },
    { duration: '4m',  target: 10 },
    { duration: '30s', target: 0  },
  ],
  thresholds: {
    http_req_failed:   ['rate<0.01'],
    http_req_duration: ['p(95)<500', 'p(99)<1500'],
    checks:            ['rate>0.99'],
  },
};

export function setup() {
  const cookies = login(`user-${(__VU % 10) + 1}@example.com`, 'Password!23');
  return { cookies };
}

export default function (data) {
  const headers = authedHeaders(data.cookies);

  group('list users', () => {
    const r = http.get(`${BASE_URL}/users?take=20`, { headers });
    check(r, { 'list 200': (x) => x.status === 200 });
  });

  group('user detail', () => {
    const r = http.get(`${BASE_URL}/users/me`, { headers });
    check(r, { 'me 200': (x) => x.status === 200 });
  });

  group('write action', () => {
    const r = http.patch(
      `${BASE_URL}/users/me`,
      JSON.stringify({ lastSeen: new Date().toISOString() }),
      { headers },
    );
    check(r, { 'patch 200/204': (x) => x.status === 200 || x.status === 204 });
  });

  s

Related in Web Dev