EthicsComputerCourses
Join training +91 90532 10052

Performance Testing (JMeter & k6)

Software Testing · Lesson 24 of 30 Advanced 7 min read Updated

A railway booking site that works for ten testers can collapse when a lakh of people try to book festival tickets at 10 a.m. Performance testing checks how fast, stable and scalable a system is under expected and extreme load. It answers questions functional tests never ask: how many users can we handle, how slow does it get, and what breaks first?

In this lesson you will learn the main types of performance test, the metrics that matter (especially percentiles), and how to write basic load tests with Apache JMeter and Grafana k6.

Types of performance test

TypeQuestionHow
Load testDoes it meet its goals at the expected load?Ramp up to normal peak users and hold
Stress testWhere does it break, and how does it fail?Keep increasing load beyond the expected peak
Spike testCan it survive a sudden jump?Jump from few to many users in seconds (a sale opening)
Soak (endurance) testDoes it degrade over hours?Hold normal load for a long time to find memory leaks
Scalability testDoes adding servers add capacity?Repeat a load test with more resources

A good performance test starts with goals written in the test plan, for example: "with 2,000 concurrent users, 95% of search requests finish within 500 ms and less than 1% fail".

The metrics that matter

  • Response time: how long one request takes, from sending to receiving the full answer.
  • Throughput: how many requests the system completes per second.
  • Error rate: the share of requests that fail or time out.
  • Concurrent users (virtual users, VUs): how many simulated users are active at once.
  • Resource use: CPU, memory, disk and database connections on the servers.

Never judge performance by the average response time alone. A few very slow requests disappear inside an average, but real users feel them. Use percentiles: the 95th percentile (p95) is the time within which 95% of requests finished. Run this calculation on a small set of measurements:

PythonRun it yourself
import statistics

# Response times in milliseconds for 20 requests to the search API
times = [182, 175, 190, 210, 168, 1450, 205, 199, 188, 176,
         240, 185, 172, 2210, 195, 181, 230, 178, 186, 201]
errors = 1          # requests that returned an HTTP error
duration_s = 10     # the test ran for 10 seconds

cuts = statistics.quantiles(times, n=100, method="inclusive")
p50, p90, p95 = cuts[49], cuts[89], cuts[94]

print(f"Requests:   {len(times)}")
print(f"Average:    {statistics.mean(times):.0f} ms")
print(f"Median p50: {p50:.0f} ms")
print(f"p90:        {p90:.0f} ms")
print(f"p95:        {p95:.0f} ms")
print(f"Max:        {max(times)} ms")
print(f"Throughput: {len(times) / duration_s:.1f} requests/s")
print(f"Error rate: {errors / len(times):.1%}")

goal_ms = 500
print("p95 goal met" if p95 < goal_ms else f"p95 goal of {goal_ms} ms FAILED")
Output
Requests:   20
Average:    356 ms
Median p50: 189 ms
p90:        361 ms
p95:        1488 ms
Max:        2210 ms
Throughput: 2.0 requests/s
Error rate: 5.0%
p95 goal of 500 ms FAILED

Most users waited under 200 ms, yet two requests took well over a second, so the p95 goal of 500 ms fails. A team looking only at the 356 ms average might call this "fine". Real tools calculate percentiles from thousands of samples; the small sample here just shows the idea.

Why systems slow down suddenly

Response time usually stays flat while a server has spare capacity, then rises sharply as it approaches its limit, because requests start queueing. This toy model shows the typical curve:

PythonRun it yourself
def response_time_ms(users, capacity=200, base_ms=180):
    """A simple model: fast until the server is busy, then queues build up."""
    load = users / capacity
    if load < 0.7:
        return base_ms
    if load < 1:
        return round(base_ms / (1 - load) * 0.3)
    return None  # overloaded: requests time out

print("Users  Response")
for users in [50, 100, 140, 160, 180, 190, 200, 250]:
    ms = response_time_ms(users)
    print(f"{users:>5}  {str(ms) + ' ms' if ms else 'timeouts'}")
Output
Users  Response
   50  180 ms
  100  180 ms
  140  180 ms
  160  270 ms
  180  540 ms
  190  1080 ms
  200  timeouts
  250  timeouts

Real systems are messier, but the shape is common: everything looks fine until it suddenly is not. That is why you ramp load up in steps and watch where the curve bends, and why a test at "a little below the expected peak" is not enough.

Apache JMeter

Apache JMeter is a long-established, open-source Java tool (the current release is 5.6.3, which needs Java 8 or later). You build a test plan in its graphical interface from elements:

  • Thread Group: the number of virtual users (threads), the ramp-up time and the loop count or duration.
  • Samplers: the requests to send, such as an HTTP Request to /api/search?q=bat.
  • Config elements: shared settings such as HTTP Request Defaults (server and port), a CSV Data Set Config for test data, and a cookie manager.
  • Assertions: checks on responses, such as a Response Assertion for status 200 or a Duration Assertion.
  • Listeners: result viewers and reports.

The plan is saved as a .jmx (XML) file. Use the GUI only to build and debug the plan; run real load tests in non-GUI mode, which uses far less memory, and generate the HTML dashboard report:

Terminal
jmeter -n -t search-load.jmx -l results.jtl -e -o report/

-n means non-GUI, -t is the test plan, -l the raw results file, and -e -o create the HTML report in the report folder.

Grafana k6

k6 is an open-source load testing tool from Grafana Labs. Tests are JavaScript files, which suit developers and fit naturally into Git and CI. k6 itself is a single program written in Go, so it is light enough to generate a lot of load from one machine.

JavaScript
// search-load.js
import http from "k6/http";
import { check, sleep } from "k6";

export const options = {
  stages: [
    { duration: "1m", target: 50 },   // ramp up to 50 virtual users
    { duration: "3m", target: 50 },   // hold
    { duration: "1m", target: 0 },    // ramp down
  ],
  thresholds: {
    http_req_duration: ["p(95)<500"], // 95% of requests under 500 ms
    http_req_failed: ["rate<0.01"],   // less than 1% errors
  },
};

export default function () {
  const res = http.get("https://staging.example.com/api/search?q=bat");
  check(res, { "status is 200": (r) => r.status === 200 });
  sleep(1);                           // think time between user actions
}

Run it with k6 run search-load.js. Each virtual user runs the default function again and again. Two details matter:

  • Thresholds are your pass/fail goals. If any threshold fails, k6 marks the test as failed and exits with a non-zero code, so it can fail a CI pipeline.
  • Checks only count successes and failures. A failed check does not stop or fail the test by itself; add a threshold on the checks rate if you want that.

The sleep(1) is think time: real users pause between clicks, and leaving it out creates an unrealistic flood.

Common mistakes

  • Testing from a weak laptop over Wi-Fi. The load generator becomes the bottleneck. Watch its CPU too.
  • Reporting only averages. Report p95 and p99 alongside the error rate.
  • No think time and one user account for everyone. Realistic data and pacing matter; caches behave very differently otherwise.
  • Testing too late. Run small load tests early and often, not once before launch.

Key points

  • Load, stress, spike, soak and scalability tests answer different performance questions.
  • Write measurable goals, such as "p95 under 500 ms with under 1% errors at 2,000 users".
  • Percentiles reveal the slow requests that averages hide.
  • JMeter builds test plans from thread groups, samplers, assertions and listeners; run it in non-GUI mode for real tests.
  • k6 tests are JavaScript; thresholds turn goals into pass/fail results and a non-zero exit code.
  • Only test systems you own or are authorised to test.

Exercise

Change the percentile program so it also prints p99 and checks two goals: p95 under 500 ms and error rate under 2%. Then remove the two slowest requests (1450 and 2210) from the list, keep the error count at 1, and run it again. Which goals pass now?

Show solution
Python
import statistics

times = [182, 175, 190, 210, 168, 205, 199, 188, 176,
         240, 185, 172, 195, 181, 230, 178, 186, 201]
errors = 1

cuts = statistics.quantiles(times, n=100, method="inclusive")
p95, p99 = cuts[94], cuts[98]
error_rate = errors / len(times)

print(f"p95: {p95:.0f} ms, p99: {p99:.0f} ms, errors: {error_rate:.1%}")
print("p95 goal:", "PASS" if p95 < 500 else "FAIL")
print("error goal:", "PASS" if error_rate < 0.02 else "FAIL")
Output
p95: 232 ms, p99: 238 ms, errors: 5.6%
p95 goal: PASS
error goal: FAIL

Without the two slow requests the response-time goal passes easily, but one error in 18 requests is still 5.6%, far above the 2% goal. Speed and reliability are separate goals and must both be met.

Quick Quiz

1. Which test holds normal load for many hours to find memory leaks?

2. Why report p95 instead of only the average response time?

3. In k6, what happens when a threshold fails?

4. How should JMeter run a real load test?

Learn Software Testing with a mentor

Free lessons get you started. Our Software Testing training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call