Performance testing often feels intimidating for teams that have only worked with functional tests. UI clicks and API validations feel familiar, but simulating real-world traffic is a different challenge. The good news is that you do not need a specialized background or expensive tools to get started. By focusing on what really matters such as clear performance goals, critical user journeys, and realistic test scenarios, you can build a foundation for performance testing from scratch. This guide walks you step by step through that journey, showing how any QA automation engineer can evolve their testing practice to include meaningful performance checks.
The first step in performance testing is deciding what success looks like. Without clear goals, your tests produce numbers but no meaningful insight. Service Level Objectives, or SLOs, give you that clarity. They translate business and user expectations into measurable targets that your tests can validate.
Here are some of the most common metrics you should consider when defining SLOs:
Once you define these objectives, write them as concrete and testable rules. In most tools, you can encode them directly as thresholds that make your tests fail if the system does not meet expectations. This way, performance testing becomes more than gathering data—it becomes an automated check that enforces quality standards.
Not every part of your application needs performance testing. To get meaningful results, start with the endpoints that matter most for users and the business. These are usually the features that generate revenue or carry the heaviest traffic. By prioritizing these “money paths,” you ensure your tests focus on the areas with the biggest impact.
A typical first selection might include:
After choosing endpoints, decide how to simulate traffic. This is called your traffic model, and it shapes how realistic your tests will be.
By combining the right endpoints with a thoughtful traffic model, you create a performance testing setup that reflects real-world conditions and uncovers weaknesses before your users do.
It is tempting to fire thousands of requests at an endpoint and call it a performance test, but that rarely reflects how users actually interact with your application. A more valuable approach is to simulate realistic user journeys, mixing different actions in the same way your real traffic would.
For example, your application might see:
This kind of distribution gives you a truer picture of performance under everyday use.
Realism also means respecting pacing. Actual users pause to read, scroll, or think before clicking the next button. You can mimic this by adding short waits between actions, often implemented with a sleep() call in scripts. This keeps the load pattern natural rather than artificial.
Finally, reuse authentication tokens or session data where appropriate. Logging in for every single request can distort test results and overload systems in ways real users never would. Unless login performance is your focus, set up tokens once and share them across simulated users.
By grounding your scenarios in real user behavior, you produce test results that are both credible and actionable.
With goals, endpoints, and scenarios in place, the next step is to translate them into an executable test. Below is an example script written in k6. It is structured to be modular and easy to extend as your test coverage grows. Each part of the script has a clear purpose, which we will walk through step by step.
The script starts with configuration. This is where scenarios and thresholds are defined. Scenarios describe how the virtual users behave, and thresholds set the performance rules that must be met. In this example, the baseline scenario ramps traffic up and down gradually, while the spike scenario sends a sudden burst of requests. Thresholds make the test fail if response times or error rates exceed agreed limits.
export const options = {
scenarios: {
baseline: {
executor: "ramping-vus", // gradually add/remove users
startVUs: 0,
stages: [
{ duration: "30s", target: 5 }, // warm up
{ duration: "2m", target: 20 }, // hold steady
{ duration: "30s", target: 0 }, // ramp down
],
exec: "baselineScenario", // link to function below
},
spike: {
executor: "constant-vus", // keep a fixed number of users
vus: 100,
duration: "30s", // short burst of load
exec: "spikeScenario", // link to function below
startTime: "3m0s", // run after baseline
},
},
thresholds: {
"http_req_duration{scenario:baseline}": ["p(95)<500"], // 95% under 500ms
"http_req_duration{scenario:spike}": ["p(95)<2000"], // 95% under 2s during spike
errors: ["rate<0.01"], // error rate under 1%
},
};
Before any virtual user actions begin, the script performs a setup step. This is useful for tasks like logging in or preparing test data. Here, the setup function makes a single login request, checks that it succeeds, and extracts an authentication token. That token is then shared with all virtual users, preventing unnecessary repeated logins and keeping the test realistic.
import http from "k6/http";
import { check } from "k6";
import { Trend, Rate } from "k6/metrics";
// Custom metrics for additional insight
const LoginDuration = new Trend("login_duration_ms");
const errors = new Rate("errors");
export function setup() {
const url = `${__ENV.BASE_URL}/api/login`;
const payload = JSON.stringify({
username: __ENV.USER,
password: __ENV.PASS,
});
const params = { headers: { "Content-Type": "application/json" } };
const res = http.post(url, payload, params);
check(res, { "login ok": (r) => r.status === 200 });
LoginDuration.add(res.timings.duration);
const token = res.json("token");
return { token };
}
The baseline scenario represents a common user journey: a search request followed by a detail page request. By grouping the actions together, you get clear visibility in reports. A short pause is added between requests to simulate the time a user might spend reading results before clicking further.
import { group, sleep } from "k6";
export function baselineScenario(data) {
const authHeaders = { headers: { Authorization: `Bearer ${data.token}` } };
group("Search -> Detail flow", function () {
const searchRes = http.get(
`${__ENV.BASE_URL}/api/search?q=k6`,
authHeaders
);
const searchOk = check(searchRes, {
"search status 200": (r) => r.status === 200,
});
if (!searchOk) errors.add(1);
let id = 1;
try {
id = searchRes.json("results[0].id") || 1;
} catch (e) {
// fallback id if parsing fails
}
const detailRes = http.get(
`${__ENV.BASE_URL}/api/items/${id}`,
authHeaders
);
const detailOk = check(detailRes, {
"detail status 200": (r) => r.status === 200,
});
if (!detailOk) errors.add(1);
});
// Simulate think-time between user actions
sleep(Math.random() * 2 + 0.5);
}
The spike scenario is simpler. It focuses on a single endpoint, sending a heavy and sudden wave of requests. This helps reveal how the system responds when faced with unexpected surges of traffic.
export function spikeScenario(data) {
const authHeaders = { headers: { Authorization: `Bearer ${data.token}` } };
const res = http.get(`${__ENV.BASE_URL}/api/search?q=loadtest`, authHeaders);
check(res, { "search 200": (r) => r.status === 200 });
}
This script ties everything together. Configuration enforces thresholds, the setup step ensures efficient authentication, and the scenarios simulate both steady user flows and sudden bursts. With this foundation, you can expand by adding more journeys, longer tests, or additional metrics, gradually building a performance suite that reflects how your application behaves in the real world.
At this point you should have your scenarios and metrics defined, and now it is time to execute the tests against a staging or production-like environment. In practice, you can install and use any performance testing tool of your choice. For this demonstration we used k6, so the following examples show how to run the script with it.
If you have k6 installed on your system, you can run the test by providing the environment variables for your base URL and credentials:
BASE_URL=https://staging.example.com USER=demo PASS=demo k6 run performanceBasics.js
During execution, live metrics will be displayed in the terminal, and a summary will appear at the end of the run. To capture results for later analysis, export them to a machine-readable format such as JSON:
BASE_URL=https://staging.example.com USER=demo PASS=demo k6 run --summary-export=summary.json performanceBasics.js
This creates a summary.json file that records response times, throughput, error rates, and threshold results. Saving these files allows you to track performance over time and integrate them into reporting dashboards or CI pipelines.
While the exact commands will differ if you choose another tool, the overall principle remains the same: execute tests in an environment that mirrors production as closely as possible and persist the results so they can be compared across runs.
Running performance tests once gives you a snapshot, but the real value comes from observing trends over time. To achieve this, you need a way to persist results and make them accessible for analysis. A minimal approach is to export summaries after each run and store them as build artifacts in your CI system. This allows you to compare current results against historical baselines and catch regressions early.
As your setup matures, you can introduce a metrics backend and dashboarding tool to visualize results as time-series data. This enables you to track request durations, throughput, and error rates across many runs, and to set up alerts when thresholds are crossed. Whether you use a managed service or run something lightweight alongside your tests, the goal is to ensure you have both historical context and real-time visibility.
Integrating performance tests into CI workflows is a natural next step. You can run quick, lightweight tests on pull requests to validate that new changes do not immediately harm performance, and schedule heavier, more realistic loads on a nightly or weekly basis. Persisted metrics and reports from these runs provide a feedback loop that turns performance testing from an ad-hoc exercise into a continuous, automated practice.
Once your tests have run and metrics are persisted, it's time to analyze the data. Focus on whether your key performance indicators, such as p95 response times, exceed the defined SLOs and which endpoints are affected. Examine error rates and the types of errors returned to determine whether they are client-side or server-side issues. Look for patterns in throughput versus latency, identifying any sharp increases in response time under moderate load. Correlate application and database resource metrics — CPU, memory, and query times — using timestamps to pinpoint bottlenecks.
Based on your findings, plan the next steps. Move tests to a staging environment that mirrors production more closely, and expand your scenarios to cover additional flows such as transactions with database writes or long-duration soak tests. Set up automated alerts for sustained SLO breaches so issues are detected early. For very large load tests, consider distributed runners or a cloud-based service to scale beyond local resources. This approach ensures performance testing evolves from an initial experiment into a continuous, actionable practice.
Starting performance testing from scratch may seem daunting, but by defining clear goals, modeling realistic traffic, and gradually building your automation workflow, you can gain actionable insights into your system's behavior. Persisting metrics, integrating tests into CI, and analyzing results over time turns ad-hoc testing into a continuous practice that helps prevent regressions and ensures your application can handle real-world load.
The complete example code, including the k6 test scripts and a mock JSON server with instructions, is available on our GitHub page for reference and experimentation. Bye and see you next time!