Your first k6 script

A 20-line script that hits /healthz with 10 VUs.

8 mineasy

Time to write a real k6 test. We'll hit your local Grit API's /healthz endpoint with 10 virtual users for 30 seconds, then read the output.

The script

tests/k6/smoke.js
import http from 'k6/http'
import { check, sleep } from 'k6'
// Configuration
export const options = {
vus: 10, // virtual users
duration: '30s', // how long
thresholds: {
http_req_duration: ['p(95)<200'], // 95% of requests under 200ms
http_req_failed: ['rate<0.01'], // <1% errors
},
}
// The test function runs in a loop for each VU
export default function () {
const res = http.get('http://localhost:8080/healthz')
check(res, {
'status is 200': (r) => r.status === 200,
'body has "ok"': (r) => r.body.includes('ok'),
})
sleep(1) // pause 1s between iterations per VU
}

Three sections, each non-negotiable:

  • options — how many VUs, for how long, with what thresholds.
  • The default function — what each VU does repeatedly until time runs out.
  • check — assertions on the response. Failed checks count toward checks metric.

Run it

# Make sure your API is running
go run apps/api/cmd/server/main.go
# In another terminal
k6 run tests/k6/smoke.js

Reading the output

checks.........................: 100.00% ✓ 600 ✗ 0
data_received..................: 240 kB 8.0 kB/s
data_sent......................: 60 kB 2.0 kB/s
http_req_blocked...............: avg=15µs p(95)=24µs
http_req_connecting............: avg=4µs p(95)=0s
http_req_duration..............: avg=12ms p(95)=19ms ← key metric
{ expected_response:true }...: avg=12ms p(95)=19ms
http_req_failed................: 0.00% ✓ 0 ✗ 300
http_req_receiving.............: avg=80µs p(95)=120µs
http_req_sending...............: avg=22µs p(95)=39µs
http_req_tls_handshaking.......: avg=0s p(95)=0s
http_req_waiting...............: avg=11ms p(95)=18ms
http_reqs......................: 300 10/s ← throughput
iteration_duration.............: avg=1.01s p(95)=1.02s
iterations.....................: 300 10/s
vus............................: 10
vus_max........................: 10

Four metrics matter on a smoke test:

  • checks — 100% means every response was healthy.
  • http_req_duration — latency. The p(95) tells you the 95th-percentile latency — the bar most teams set SLOs against.
  • http_req_failed — error rate. Anything > 0% on a healthy smoke test is a red flag.
  • http_reqs — total + rate. Useful for capacity planning.

Threshold pass / fail

At the bottom of the output:

✓ http_req_duration..p(95)<200
✓ http_req_failed....rate<0.01

✓ means the threshold held. ✗ means a breach — and k6 exits with non-zero status. That's how CI knows your PR regressed the API.

VUs vs. iterations — the mental model

VUs are virtual users — independent loops running your default function. With 10 VUs, ten copies of the function are running concurrently. Each VU loops until the duration ends.

An iteration is one trip through the default function. With sleep(1) and a 30s duration, each VU does about 30 iterations, so 10 VUs × 30 iterations = ~300 total. That lines up with what we saw above.

VUs are NOT real concurrent users in a browser sense. 100 VUs in k6 sends about as many requests as 100 humans actively clicking. If your real app has 10,000 casual users browsing, that's probably more like 200-500 VUs in load-testing terms (because most users are idle most of the time).

Adding auth — a more realistic test

tests/k6/load.js
import http from 'k6/http'
import { check, sleep } from 'k6'
const BASE = 'http://localhost:8080'
export const options = {
vus: 50,
duration: '2m',
thresholds: {
http_req_duration: ['p(95)<300'],
http_req_failed: ['rate<0.01'],
},
}
// Runs ONCE per VU at start
export function setup() {
const r = http.post(`${BASE}/api/auth/login`,
JSON.stringify({ email: 'load-test@example.com', password: 'secret123' }),
{ headers: { 'Content-Type': 'application/json' } },
)
return { token: r.json('data.token') }
}
// Runs in a loop per VU; receives setup data as argument
export default function (data) {
const res = http.get(`${BASE}/api/me`, {
headers: { Authorization: `Bearer ${data.token}` },
})
check(res, { 'me 200': (r) => r.status === 200 })
sleep(1)
}

setup() runs once before VUs start; its return value is passed to every iteration as data. Use it for: log in once, share the token; create a fixture user, share the ID.

Quick check

Your smoke test shows p(95) http_req_duration of 47ms. Then you push a refactor and re-run: p(95) is now 230ms. Threshold is `p(95)<200`. What does k6 do?

Try it

Smoke-test your real API:

  1. Save the smoke.js script from this lesson.
  2. Boot your local API: cd apps/api && go run cmd/server/main.go.
  3. Run k6: k6 run tests/k6/smoke.js.
  4. Capture the p(95) for http_req_duration. Paste in notes.md.
  5. Now write a SECOND script for /api/users (requires auth). Use the setup pattern from this lesson.
  6. Run it. Compare p(95) to the smoke test.

What's next

Chapter 2 — The five test types. We'll build a full suite: load, stress, spike, soak, with appropriate thresholds for each.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub