Senior Automation · Non-Functional Testing

Performance Test Automation

Slow is the new broken. Learn how to automate performance tests that catch regressions before customers feel them.

Senior Automation ISTQB CTAL-TAE v2.0 — Chapters 3 & 5 ~15 min read + exercise

1 The Hook — Why This Matters

In 2023, a NZ travel booking site launched a "black Friday" sale campaign. Their checkout API, which handled 50 requests per second in normal operations, spiked to 800 RPS. Response times went from 200ms to 12 seconds. The database connection pool exhausted. Customers abandoned carts. The company lost an estimated $2.3M in revenue in four hours. They had load-tested the previous year, but no one had automated the tests in CI. The performance regression had crept in over six months of "small" releases.

Performance that isn't continuously tested is performance that will eventually fail. One-off load tests are theatre. Automated performance gates in CI are engineering.

2 The Rule — The One-Sentence Version

Every performance test must define thresholds that fail the build when breached, compare results against a baseline, and run on a schedule that catches regressions before release.

A performance test without a threshold is just a benchmark. A benchmark without a baseline comparison is just a number. Numbers don't protect production.

3 The Analogy — Think Of It Like...

Analogy

Weighing yourself once a year and being surprised you gained weight.

If you only load-test before major releases, you're managing performance by crisis. Automated performance tests in CI are like a daily weigh-in: small deviations are caught early, before they become emergencies. The threshold is your target weight. The baseline is last week's measurement. The schedule is the habit.

Senior engineer insight

The first time I wired k6 into a CI pipeline for a NZ insurance client, their engineering lead pushed back hard — "load tests take 20 minutes, nobody will wait for that on a PR." He was right, and that's where most teams stop. The shift that changed everything: a 90-second smoke test on every PR (5 VUs, 500ms p95 threshold, done), full ramp-up load test only on merge to main. Suddenly the team had a two-tier system: fast gates that gave confidence, deep tests that caught regressions overnight.

The most common mistake: teams write a load test once, run it manually before go-live, and call it performance testing — missing every regression that creeps in across the 50 PRs before the next release.

From the field

A Wellington-based team building a rates-calculation service for a regional council assumed their API could handle election night traffic — they'd tested at 200 concurrent users and everything looked green. What they hadn't accounted for was that their test data had 500 properties; the production database had 380,000. On election night, a query that ran in 18ms on staging took 4.2 seconds in production because a composite index that worked fine on a small table became a full scan at scale. The site fell over at 9 PM with the whole country watching results come in. After the incident, the team provisioned a performance environment with a production-sized database snapshot (anonymised) and rewrote their k6 scenarios to generate realistic data distributions rather than hitting the same five property IDs in a loop. Every subsequent election cycle ran without incident — because the tests finally matched reality.

4 Watch Me Do It — Step by Step

Here is how to build a production-grade performance automation suite with k6.

  1. Write a smoke test (fast CI gate)
    import http from "k6/http";
    import { check, sleep } from "k6";
    
    export const options = {
      vus: 5, duration: "1m",
      thresholds: {
        http_req_duration: ["p(95)<500"],
        http_req_failed: ["rate<0.01"],
      },
    };
    
    export default function () {
      const res = http.get("https://api.example.com/health");
      check(res, {
        "status is 200": (r) => r.status === 200,
        "response time < 200ms": (r) => r.timings.duration < 200,
      });
      sleep(1);
    }
  2. Write a load test with stages
    export const options = {
      scenarios: {
        ramp_up: {
          executor: "ramping-vus",
          stages: [
            { duration: "2m", target: 50 },
            { duration: "5m", target: 50 },
            { duration: "2m", target: 100 },
            { duration: "5m", target: 100 },
            { duration: "2m", target: 0 },
          ],
        },
      },
      thresholds: {
        http_req_duration: ["p(95)<500", "p(99)<1000"],
        http_req_failed: ["rate<0.01"],
      },
    };
  3. Integrate into CI with baseline comparison
    # .github/workflows/performance.yml
    name: Performance Regression
    on:
      push: { branches: [main] }
      schedule:
        - cron: "0 6 * * 1-5"
    
    jobs:
      perf-test:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: grafana/setup-k6-action@v1
          - run: k6 run --env BASE_URL=${{ secrets.STAGING_URL }} tests/performance/load.js
          - run: python scripts/compare_perf_baseline.py results.json
Load test types
TypeGoalDuration
SmokeVerify system works under minimal load1-5 min
LoadValidate at expected capacity10-30 min
StressFind breaking pointUntil failure
SpikeTest sudden traffic surge5-10 min
SoakDetect memory leaks1-8+ hours
Pro tip: Never use averages for latency SLOs. Averages hide tail latency. If your average is 200ms but your p99 is 5 seconds, 1% of your users are having a terrible experience. Always use percentiles.

Second example: Endurance (soak) test for memory leaks

export const options = {
  scenarios: {
    soak: {
      executor: "constant-vus",
      vus: 50,
      duration: "4h",  // Run for 4 hours to detect memory leaks
      env: { SOAK_TEST: "true" }
    },
  },
  thresholds: {
    "vus": ["value<=50"],  // Verify VUs don't crash
    "http_req_failed": ["rate<0.01"],
    "http_req_duration": ["p(99)<5000"],  // Watch for degradation over time
  },
};

// Script logs memory metrics every 15 min
let lastMemory = 0;
export default function () {
  // Simulate realistic user flow
  http.get("https://api.example.com/user/profile");
  http.post("https://api.example.com/cart/add", payload);

  // Every 50 requests, check if memory is degrading
  if (__VU % 50 === 0) {
    let currentMemory = metrics.systemMemory.value;
    if (currentMemory > lastMemory * 1.1) {
      console.warn(`Memory spike detected: ${lastMemory}MB -> ${currentMemory}MB`);
    }
    lastMemory = currentMemory;
  }
}

5 When to Use It / When NOT to Use It

✅ Automate performance when...

  • API has latency SLOs
  • Traffic patterns are predictable
  • Regression detection is critical
  • CI can support scheduled jobs

❌ Skip automated perf when...

  • No dedicated performance environment
  • Results are too noisy (shared staging)
  • Team lacks time to investigate failures

Before setting performance thresholds, ask:

  • What is your p95 latency baseline today? (Required to define regression threshold.)
  • Do you have a dedicated performance environment? (Shared staging = useless results.)
  • Who will investigate when performance degrades? (If nobody has time, automating is pointless.)

6 Common Mistakes — Don't Do This

🚫 Averages for SLOs

I used to think: Average response time tells me how fast the API is.
Actually: Averages hide tail latency. If 95% of requests are 200ms and 5% are 10 seconds, the average looks fine but users are suffering. Use p95 and p99, never averages.

🚫 Testing in shared environments

I used to think: Staging is close enough to production for performance testing.
Actually: Shared staging has noisy neighbours, different data volumes, and inconsistent load. Performance tests need dedicated environments with production-like data and isolation. Otherwise, you're measuring noise.

🚫 One-off load tests

I used to think: We load-test before each major release; that's enough.
Actually: Performance regressions creep in between releases. A "small" PR that adds an N+1 query won't be caught by pre-release testing if it was merged three weeks ago. Automate smoke tests in CI and schedule full load tests nightly.

When this technique fails

You automate performance tests against staging, but the database is 1/100th the size of production. A query that runs in 50ms on staging takes 500ms on production. The tests pass, you deploy, and production melts. Real performance testing requires production-scale data or at least a realistic simulation. If you can't provision it, you can't test performance reliably.

7 Now You Try — Interview Warm-Up

🎯 Interactive Exercise

Scenario: Your k6 load test shows p95 latency of 450ms (under your 500ms threshold) but p99 is 3.2 seconds. The product owner says "p95 is green, ship it."

What do you tell them?

Your response:

"The p99 of 3.2 seconds means 1% of our users — potentially thousands per day — are waiting over 3 seconds for a response. That's a churn risk. The p95 being green doesn't make the p99 acceptable. We need to investigate tail latency via APM traces before release." Then check: DB slow query log, GC pauses, connection pool exhaustion, and cache hit rates.

Why teams fail here

  • Synthetic traffic that doesn't reflect real user flows: teams script k6 to hammer one endpoint at a fixed rate, missing the session-based patterns (login → search → add-to-cart → checkout) that expose connection pool exhaustion and session cache contention. When Gatling or k6 scenarios use realistic think time and varied user journeys, completely different failure modes emerge.
  • Threshold numbers pulled from thin air: p95 < 500ms sounds reasonable, but without a measured baseline it's meaningless — some endpoints genuinely need 800ms and others should be under 80ms. SLOs set without historical data either block legitimate PRs constantly (too tight) or never catch real regressions (too loose). Always establish a baseline before setting a threshold.
  • Ignoring infrastructure metrics alongside latency: a k6 run that shows p95 at 420ms looks fine until you correlate it with Grafana showing CPU pegged at 98% and DB connection pool at capacity. The test passes; production still melts at 2× load. Performance automation that doesn't pull infra metrics (CPU, memory, DB connections, GC pause times) is measuring only half the system.
  • Running load tests against shared staging environments: noisy-neighbour deploys, lighter datasets, and background jobs that don't exist in production all distort results. Revenue NZ and CoverNZ both learned this the hard way — their staging clusters were shared across six teams, so a load test during another team's deployment was measuring chaos, not performance. Dedicated, isolated performance environments are not optional; they are the prerequisite.

Key takeaway

A performance test without a committed threshold and a scheduled CI run is not quality assurance — it is optimistic hope dressed up as engineering.

8 Self-Check — Can You Actually Do This?

Click each question to reveal the answer. If you got all three, you're ready to practice.

Q1. What is the difference between load, stress, and soak testing?

Load = validate performance at expected capacity. Stress = push beyond capacity to find breaking point. Soak = sustained load over hours to detect memory leaks and degradation.

Q2. Why should performance tests use percentiles instead of averages?

Averages hide tail latency problems. Users experience individual requests, not averages. p95 tells you 95% of users are within budget; p99 catches the worst experiences that drive churn.

Q3. How do you prevent performance tests from making every PR take 30 minutes?

Run smoke tests (1-2 min) on PR. Run full load tests on main merge or scheduled nightly. Fail PR only on catastrophic thresholds (e.g., p95 > 5 seconds or error rate > 5%).

9 Interview Prep — Senior Q&A

Performance engineers ask about SLO design and regression detection. Questions from NZ fintech teams.

Q. "Your baseline p95 is 200ms. A PR brings it to 220ms (10% regression). Do you fail the build?"

Context matters. If the p99 is still under 500ms and error rate hasn't moved, I'd allow it and schedule a spike root cause analysis. If your SLO is "p95 <= 200ms always," then yes, fail it and require the team to explain. The key: define your threshold before the build, not after. "Pretty close" is not acceptable. Either p95 <= 200ms or <= 250ms, not both.

Q. "You detect a performance regression two weeks after deployment. How do you find the culprit?"

1) Narrow the date range using binary search (bisect) of main branch commits. 2) Run the k6 test against each suspect commit. 3) Once you've identified the offending commit, look for N+1 queries, new external API calls, or unoptimised loops. 4) Correlate with infrastructure changes (database indices dropped, cache eviction policy changed). The automation of this—the bisect and test loop—is what separates senior engineers from those who shrug.

Q. "You notice tests pass but production p99 latency is 2x staging. What's wrong?"

Data scale. Your staging database has 10,000 records; production has 100M. Indexes that work on staging don't help when the table is 100x larger. Solutions: 1) Production-scale staging environment (expensive), 2) Profiling on production with APM tools, 3) Load tests that simulate realistic data distributions, 4) Query execution plan analysis on both environments.

Q. "What metric do you care more about: p95 latency or error rate?"

Both, but in sequence. First, error rate must be near zero (< 0.1%). A high error rate means the system is broken, and latency is meaningless. Once error rate is healthy, watch p95 and p99. If p95 is high but p99 is reasonable, you might have a few slow requests that don't affect most users. If p99 is high, you have a tail latency problem that drives churn. Most teams should track: error rate first, then p95, then p99.