API Testing & Quality Engineering
Hands-onIntermediate 35 min· Beyond functional testing · 13 of 19

Performance and load testing with k6

Load test the coffee shop API with k6: write a smoke test and a ramping load test, set pass/fail thresholds on p95 latency and error rate, and learn to read the results.

In this tutorial you will load test the Brew & Bean API with k6, a free, open-source load testing tool. You will learn what to measure, write a smoke test and a load test in JavaScript, set pass/fail limits called thresholds and read the results.

You need: the coffee shop API from *Automating API tests in code* running at http://localhost:8000 (or any API of your own), a terminal and very basic JavaScript. You do not need to know Node.js: k6 scripts run inside the k6 program, not Node.

The example numbers below are illustrative. Your results depend on your laptop, so do not compare them with anyone else's.

What performance testing answers

A functional test asks "is the answer correct?". A performance test asks "is the answer still correct and fast when many people use it at once?". Imagine Brew & Bean at 9 am on a Monday: dozens of people order at the same moment. An API that is perfect for one user can become slow or start returning errors under that crowd.

Kinds of performance test

TypeQuestionTypical shape
SmokeDoes the script and the API work at all under minimal load?1 user, a few seconds
LoadDoes it cope with the normal expected traffic?Ramp up to expected users, hold, ramp down
StressWhere does it start to break?Keep raising users beyond normal
SpikeDoes it survive a sudden jump and recover?Jump quickly to a high number, then drop
SoakDoes it stay healthy for hours (memory leaks, full disks)?Normal load for a long time

The plan for this lesson

  1. 1Install k6 and start the API locally.
  2. 2Write and run a one-user smoke test.
  3. 3Write a ramping load test for a real journey with thresholds.
  4. 4Read the summary and raise the load to find the limit.

The numbers you watch are few. Response time (also called latency), best read as percentiles: p95 = 250 ms means 95 out of 100 requests were faster than 250 ms. Averages hide slow requests, so teams use p95 or p99. Throughput is requests per second. Error rate is the share of failed requests. Virtual users (VUs) are the simulated people.

Step 1: Install k6

Install options (check the official k6 documentation if one fails)

SystemCommand
Windowswinget install k6 --source winget or choco install k6
macOSbrew install k6
LinuxUse the official package repository steps from the k6 docs (Debian, Ubuntu, Fedora and others)
Any system with Dockerdocker run --rm -i grafana/k6 run - <script.js
Terminal
k6 version

It prints the installed version. If the command is not found, open a new terminal after installing.

Only load test systems you own

A load test can slow down or take down a server. Run it against your own app on your own machine or a test environment you have permission to use. Never point it at someone else's website, a college portal or a production system without written permission. Doing so can be illegal and will certainly get you blocked.

Step 2: Make sure the API is running

Terminal 1
uvicorn main:app --port 8000

Run it in the folder with main.py. Check http://localhost:8000/menu in a browser before you start the test.

Keep this terminal open and use another one for k6. A local single-process server is perfect for learning. Remember that a number measured on your laptop says little about production; what matters is comparing before and after a change on the same setup.

Step 3: A smoke test

Create smoke-test.js. Every k6 script has three ideas. options says how much load to apply. The default function is what one virtual user does in one loop; k6 runs it again and again for every VU. check records pass/fail for a condition without stopping the test (unlike an assertion in unit tests). sleep(1) is "think time": real customers pause between actions.

smoke-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

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

export const options = {
  vus: 1,
  duration: '10s',
};

export default function () {
  const res = http.get(`${BASE_URL}/menu`);

  check(res, {
    'status is 200': (r) => r.status === 200,
    'menu has items': (r) => r.json().length > 0,
  });

  sleep(1);
}
Terminal
k6 run smoke-test.js

If the API is on a different address: k6 run -e BASE_URL=http://192.168.1.10:8000 smoke-test.js. The -e flag sets an environment variable read through __ENV.

k6 prints a summary when it finishes. Look first at checks (should be 100%), then http_req_failed, then http_req_duration with its avg, med, p(90) and p(95).

Step 4: A load test with thresholds

Now simulate a realistic journey: browse the menu, place an order, check the order. The stages ramp users up slowly, hold them, then ramp down; a sudden jump to full load is a spike test, not a load test. Thresholds are the pass/fail rules. If any threshold is broken, k6 finishes with a failed result (exit code 99), so a CI pipeline can fail the build.

load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

const BASE_URL = __ENV.BASE_URL || 'http://localhost:8000';
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };

export const options = {
  stages: [
    { duration: '20s', target: 10 }, // ramp up to 10 virtual users
    { duration: '40s', target: 10 }, // hold
    { duration: '10s', target: 0 },  // ramp down
  ],
  thresholds: {
    http_req_failed: ['rate<0.01'],    // fewer than 1% of requests fail
    http_req_duration: ['p(95)<500'],  // 95% of requests finish under 500 ms
    checks: ['rate>0.99'],             // more than 99% of checks pass
  },
};

export default function () {
  // 1. Browse the menu
  const menu = http.get(`${BASE_URL}/menu`);
  check(menu, { 'menu 200': (r) => r.status === 200 });
  sleep(1);

  // 2. Place an order
  const payload = JSON.stringify({
    customer_name: 'Load Test',
    phone: '9876543210',
    items: [{ item_id: 'espresso', qty: 1 }],
  });
  const created = http.post(`${BASE_URL}/orders`, payload, JSON_HEADERS);
  const ok = check(created, {
    'order created (201)': (r) => r.status === 201,
    'total is 120': (r) => r.json('total') === 120,
  });
  sleep(1);

  // 3. Check the order status (only if it was created)
  if (ok) {
    const id = created.json('id');
    const status = http.get(`${BASE_URL}/orders/${id}`);
    check(status, { 'order found': (r) => r.status === 200 });
  }
  sleep(1);
}
Terminal
k6 run load-test.js

Takes about 70 seconds. While it runs, k6 shows a live progress line with the current VUs.

Example of the end of the summary (your numbers will differ)
         /\      |‾‾| /‾‾/   /‾‾/
    /\  /  \     |  |/  /   /  /
   /  \/    \    |     (   /   ‾‾\
  /          \   |  |\  \ |  (‾)  |
 / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: load-test.js

     ✓ checks.........................: 100.00% 920 out of 920
       http_req_duration..............: avg=6.2ms  min=1.9ms  med=5.1ms  max=48ms  p(90)=9.8ms  p(95)=12ms
     ✓ { expected_response:true }...: avg=6.2ms ...
     ✓ http_req_failed................: 0.00%   0 out of 690
       http_reqs......................: 690     9.9/s
       iterations.....................: 230     3.3/s
       vus............................: 1       min=1  max=10

running (1m10.0s), 00/10 VUs, 230 complete and 0 interrupted iterations

Each green tick next to a metric means its threshold passed; a red cross means it failed. If http_req_duration shows a red cross for p(95)<500, your API is too slow at this load by the rule you set. Rules like these are your service level objective; agree them with your team instead of inventing them.

Step 5: Find the limit (stress test)

To find where the API starts to hurt, copy the script and replace stages with steadily higher targets. Watch where http_req_duration p(95) climbs or errors appear.

When results go bad, ask what is the bottleneck. Is the CPU of the server at 100%? Is the database slow (look for missing indexes and queries in a loop)? Is the app single-threaded? A load test shows that something is slow; profiling and monitoring tools show why.

stress-stages.js (replace the stages in options)
stages: [
  { duration: '30s', target: 20 },
  { duration: '30s', target: 50 },
  { duration: '30s', target: 100 },
  { duration: '30s', target: 0 },
],

Remove the thresholds for a stress run, or loosen them: the goal is to see the breaking point, not to pass.

Quick runs without editing the file

k6 can override options from the command line, for example k6 run --vus 20 --duration 30s smoke-test.js. Good for a quick check; keep the real test in the file so it is repeatable and can go into Git and CI.

Common mistakes

Before you trust a result

  1. The test machine is not the bottleneck: k6 and the API fighting for the same CPU can distort numbers. Close other heavy programs.
  2. Responses are checked (check): a fast 500 error is not a good result. Always look at the error rate with the timing.
  3. Think time (sleep) is realistic. Without it, 10 VUs can generate far more traffic than 10 real people.
  4. Test data is varied where it matters. If every user orders the same item, caching can make the result look better than real life.
  5. You run the test more than once and compare like with like (same machine, same data, same script).
  6. You tested an environment that looks like production; a laptop with an empty database is only a starting point.

For CI, a small smoke-level load test with strict thresholds is cheap and catches big regressions. Big tests run on a schedule or before a release. The CI setup is covered in *Running tests in CI with GitHub Actions*. Related performance topics (caching, database indexes) are in the Backend Development track.

Prompt · Explain my k6 results
You are a performance engineer. Explain these k6 results to me in plain English as I am a beginner.

Context: I load tested [describe the API and endpoints]. The test used [number] virtual users for [duration] on [laptop / test server, CPU and RAM].

Results:
<results>
[paste the k6 summary]
</results>

Tell me: 1) is this healthy, 2) which numbers matter most and why, 3) the 3 most likely bottlenecks to investigate first, 4) what I should change in my test to make it more realistic.

What to expect: A good answer reads p95 and error rate, not only the average, and suggests concrete checks such as database queries, CPU use and connection limits. Treat its guesses as hypotheses to measure, not conclusions.

How this comes up in interviews

Typical questions: "What is the difference between load, stress and spike testing?", "Why do we look at p95 instead of the average?", "What is a virtual user?", "How would you find a bottleneck?", "What tools have you used: JMeter, k6, Locust?". Answer with a small real example ("I load tested my own order API with k6 using 10 users and a p95 threshold of 500 ms"). JMeter is widely used in Indian service companies, so say that k6 and JMeter test the same ideas, k6 with code and JMeter with a GUI.

Try it: Compare before and after

1. Run load-test.js three times and note the p(95) of http_req_duration each time. 2. In main.py, add import time and a time.sleep(0.3) at the start of get_menu() to simulate a slow query. Restart the API. 3. Run the test again. What happens to p(95), to http_reqs and to the thresholds? 4. Remove the sleep and restart.

Try it: Write a spike test

Create spike-test.js that goes from 1 to 50 virtual users in 5 seconds, holds for 20 seconds, drops back to 1 and holds for 20 more. Run it. Does the error rate stay near zero? Does latency return to normal after the drop?

Key takeaways

  1. Performance tests ask whether the API stays correct and fast under many users: smoke, load, stress, spike and soak answer different questions.
  2. Watch percentiles (p95, p99), throughput and error rate; averages hide slow requests.
  3. In k6, the default function is one user's loop, check records pass/fail and sleep models think time.
  4. Thresholds turn results into pass/fail (exit code 99 when broken), so CI can use them.
  5. Test only systems you own or have permission for, and compare results on the same setup.
  6. A load test shows that something is slow; monitoring and profiling find why.

Check yourself

Finishing the quiz marks the lesson as done.

1. p95 response time of 250 ms means what?

2. You want to learn at what number of users the API starts to fail. Which test fits?

3. What does a k6 threshold such as http_req_duration: ['p(95)<500'] do?

4. Why should a k6 script include sleep(1) between actions?

5. Your load test shows excellent response times but 40% of requests returned HTTP 500. What is the conclusion?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free