Testing APIs for production-readiness is a crucial step people tend to overlook in developing software.
Imagine you are a ticket sales company which sells tickets for the next Taylor Swift concert tomorrow.
You tested your features if they work as expected: Register an account, select the seat places, put tickets into the basket, pay with PayPal, sent confirmation mail, etc.
The next day comes, and ten thousands of people try to access your site to buy tickets.
But your website keeps crashing.
People give you bad ratings on the internet and will not try to buy something from your site again.
This is why Load Testing is important.
It tests if your system still behaves as expected under different kind of loads.
In this post, I will show you how to implement load testing by yourself with K6.
Types of Load Testing
There are 6 different types of load tests to consider:
Smoke Tests
Just to verify that your app works. Usually a small load is used with ~5 simulated users.
Average Load Test
Evaluates how your app behaves under average usage like a normal day in production.
Soak Test
A variation of the average load test with a difference in the duration of the test. Soak tests extend the load duration to several hours or days. Checks the availability of the application during longer periods of use.
Stress Test
A variation of the average load test with a difference in the load. A Stress Test uses a higher load by having a longer ramp-up period.
Spike Test
Determines whether your application will survive a sudden and extremely high load. If you launch a new and popular product (like GTA 6) this type of test becomes important. Your app will encounter very high loads in a very short time
Breakpoint Tests
Tests the limits of your apps, even if the simulated load is unrealistic. But it‘s good to see when and how your app starts to fail.
Even if it sounds tedious to implement these 6 types of load tests, with **k6**, this becomes a piece of cake.
K6 for Load Testing
K6, developed by Grafana Labs, is an open-source load testing tool for JavaScript.
Even if you have never written any code in JavaScript - trust me, it's easy to get started and write powerful load tests.
K6 also have a SaaS-solution but we don't look into it in this post.
The next steps assumes you have NodeJS on your machine installed.
First Steps with K6
Install K6
You need to install K6 on your machine.
On Debian/Ubuntu
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
On MacOS
brew install k6
On Windows
winget install k6 --source winget
On Docker
docker pull grafana/k6
Write Your First Test
Create a mytest.js file and insert the first code block:
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
iterations: 10,
};
export default function () {
http.get('https://google.com');
// Sleep for 1 second to simulate real-world usage
sleep(1);
}
1. We need to import http and sleep for making HTTP requests and sleep to simulate real-world usage.
2. We create an options object where we define how our test suite should run. In this case we say that our test should run for 10 iterations.
3. We define our main function where we make a GET request and wait for 1 second Here comes our test logic.
That's it!
(Note: We could even generate a basic script by K6 with k6 new)
In your terminal, run
k6 run mytest.js
You will get an output by K6 looking like this:
To simulate more users, you can add a `vus` option to your `options` with a duration on how long the test should take place.
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
vus: 5,
duration: '30s'
};
export default function () {
http.get('https://google.com');
// Sleep for 1 second to simulate real-world usage
sleep(1);
}
To simulate more complex scenarios, you can use the `stages` option. Here, you can define how to ramp VUs up and down.
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
stages: [ { duration: '1m', target: 50 }, // Ramp up to 50 users over 1 minute{ duration: '3m', target: 50 }, // Stay at 50 users for 3 minutes { duration: '1m', target: 0 }, // Ramp down to 0 users over 1 minute
],
};
export default function () {
http.get('https://google.com');
// Sleep for 1 second to simulate real-world usage
sleep(1);
}
This lets you simulate more realistic traffic.
If you want to have a bit more tolerance, you can also set a threshold to say how many requests should go through:
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
stages: [ { duration: '1m', target: 50 }, // Ramp up to 50 users over 1 minute
{ duration: '3m', target: 50 }, // Stay at 50 users for 3 minutes
{ duration: '1m', target: 0 }, // Ramp down to 0 users over 1 minute
],
thresholds: {
http_req_failed: ['rate<0.1'], // Less than 10% of requests can fail
},
};
export default function () {
// Test the GET endpoint
const getResponse = http.get('https//google.com');
check(getResponse, {
'GET status is 200': (r) => r.status === 200,
'GET response contains welcome message': (r) => r.json().message === 'Some message',
});
// Sleep for 1 second to simulate real-world usage
sleep(1);
}
And, if you want to check the responses of your API, you can also do that:
6 Types of Load Tests with K6
Now, we will implement the described 6 types of load tests with K6.
Preparation
To load test an API, we need an API.
For this tutorial, we just create a simple FastAPI app which we can abuse hehe :=)
from fastapi import FastAPI
from pydantic import BaseModel
import uvicorn
app = FastAPI()
class Item(BaseModel):
name: str
description: str
price: float
tax: float = 0.0
@app.get("/")
def read_root():
return {"message": "Welcome to the FastAPI app!"}
@app.post("/items/")
def create_item(item: Item):
return {"item": item}
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8000)
Smoke Test
Remember: A smoke test is a minimal test to verify that the system works under minimal load.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 3, // Virtual Users
duration: '30s',
};
export default function () {
// Test the GET endpoint
const getResponse = http.get('http://localhost:8000/');
check(getResponse, {
'GET status is 200': (r) => r.status === 200,
'GET response contains welcome message': (r) => r.json().message === 'Welcome to the FastAPI app!',
});
// Test the POST endpoint
const payload = JSON.stringify({
name: 'Test Item',
description: 'This is a test item',
price: 10.5,
tax: 1.5
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
const postResponse = http.post('http://localhost:8000/items/', payload, params);
check(postResponse, {
'POST status is 200': (r) => r.status === 200,
'POST response contains item data': (r) => r.json().item.name === 'Test Item',
});
}
Average Load Test
Remember: An average load test simulates the typical production traffic that your application normally experiences.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 50 }, // Ramp up to 50 users over 1 minute
{ duration: '3m', target: 50 }, // Stay at 50 users for 3 minutes
{ duration: '1m', target: 0 }, // Ramp down to 0 users over 1 minute
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests must finish within 500ms
'http_req_duration{status:200}': ['max<1000'], // Max duration for 200 responses must be below 1s
},
};
export default function () {
const getResponse = http.get('http://localhost:8000/');
check(getResponse, {
'GET status is 200': (r) => r.status === 200,
});
const payload = JSON.stringify({
name: 'Average Load Item',
description: 'Testing with average load',
price: 15.99,
tax: 1.99
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
const postResponse = http.post('http://localhost:8000/items/', payload, params);
check(postResponse, {
'POST status is 200': (r) => r.status === 200,
});
}
Soak Test
Remember: A soak test verifies that your application can handle the expected load over an extended period, helping to identify memory leaks, resource depletion, or other issues that might occur over time.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 100 }, // Ramp up to 100 users over 2 minutes
{ duration: '1h', target: 100 }, // Stay at 100 users for 1 hour
{ duration: '2m', target: 0 }, // Ramp down to 0 users
],
thresholds: {
http_req_duration: ['p(99)<1500'], // 99% of requests must complete below 1.5s
'http_req_failed': ['rate<0.01'], // Less than 1% of requests can fail
},
};
export default function () {
const getResponse = http.get('http://localhost:8000/');
check(getResponse, {
'GET status is 200': (r) => r.status === 200,
});
const payload = JSON.stringify({
name: 'Soak Test Item',
description: 'Testing system stability over time',
price: 29.99,
tax: 2.99
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
const postResponse = http.post('http://localhost:8000/items/', payload, params);
check(postResponse, {
'POST status is 200': (r) => r.status === 200,
});
}
Stress Test
Remember: A stress test determines the system's robustness under extreme conditions by gradually increasing the load beyond normal usage patterns until it breaks.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 100 }, // Ramp up to normal load
{ duration: '5m', target: 100 }, // Stay at normal load
{ duration: '2m', target: 200 }, // Start ramping up to stress load
{ duration: '5m', target: 200 }, // Stay at higher load
{ duration: '2m', target: 300 }, // Ramp up to breaking point
{ duration: '5m', target: 300 }, // Stay at breaking point
{ duration: '2m', target: 0 }, // Ramp down to 0
],
thresholds: {
http_req_duration: ['p(95)<2000'], // 95% of requests must complete below 2s
},
};
export default function () {
const getResponse = http.get('http://localhost:8000/');
check(getResponse, {
'GET status is 200': (r) => r.status === 200,
});
const payload = JSON.stringify({
name: 'Stress Test Item',
description: 'Testing system under heavy load',
price: 39.99,
tax: 3.99
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
const postResponse = http.post('http://localhost:8000/items/', payload, params);
check(postResponse, {
'POST status is 200': (r) => r.status === 200,
});
}
Spike Test
Remember: A spike test simulates a sudden surge in traffic to verify how the system responds to dramatic and sudden increases and decreases in load.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '10s', target: 100 }, // Baseline
{ duration: '1m', target: 100 }, // Stay at baseline
{ duration: '10s', target: 1000 }, // Spike to 1000 users
{ duration: '1m', target: 1000 }, // Stay at spike
{ duration: '10s', target: 100 }, // Scale back to baseline
{ duration: '1m', target: 100 }, // Stay at baseline
{ duration: '10s', target: 0 }, // Scale down to 0
],
};
export default function () {
const getResponse = http.get('http://localhost:8000/');
check(getResponse, {
'GET status is 200': (r) => r.status === 200,
});
const payload = JSON.stringify({
name: 'Spike Test Item',
description: 'Testing system with traffic spikes',
price: 49.99,
tax: 4.99
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
const postResponse = http.post('http://localhost:8000/items/', payload, params);
check(postResponse, {
'POST status is 200': (r) => r.status === 200,
});
}
Breakpoint Test
Remember: A breakpoint test gradually increases load until the system breaks, helping to identify the maximum capacity of the application.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 100 }, // Normal load
{ duration: '5m', target: 100 }, // Stay at normal load
{ duration: '2m', target: 200 }, // Increase to higher load
{ duration: '5m', target: 200 }, // Stay at higher load
{ duration: '2m', target: 300 }, // Increase to even higher
{ duration: '5m', target: 300 }, // Stay at higher load
{ duration: '2m', target: 400 }, // Keep increasing until breaking point
{ duration: '5m', target: 400 },
{ duration: '2m', target: 500 },
{ duration: '5m', target: 500 },
{ duration: '2m', target: 600 },
{ duration: '5m', target: 600 },
{ duration: '2m', target: 0 }, // Ramp down to 0
],
thresholds: {
http_req_failed: ['rate<0.1'], // Less than 10% of requests can fail
},
};
export default function () {
const getResponse = http.get('http://localhost:8000/');
check(getResponse, {
'GET status is 200': (r) => r.status === 200,
});
const payload = JSON.stringify({
name: 'Breakpoint Test Item',
description: 'Finding the system breaking point',
price: 59.99,
tax: 5.99
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
const postResponse = http.post('http://localhost:8000/items/', payload, params);
check(postResponse, {
'POST status is 200': (r) => r.status === 200,
});
}
Conclusion
In this post you learned about K6, a tool to load test your APIs with minimal JavaScript code easily. You also saw how you can implement 6 types of load testing procedures to prepare your API for different type of load.