Module 5: Thresholds Pass Fail And Slos

6. The performance gate, like coverage gates

Overview

Up to here we built a performance gate piece by piece: a metric (p95), a limit (200 ms), a verdict (pass/fail), an exit code (0 or ≠ 0) that gates the deploy. In this lesson we step back and see something liberating: that pattern isn't exclusive to performance. It's the anatomy of all quality gates, and you already met another member of the family in the Testing ecosystem —the coverage gate—. A coverage gate measures test coverage (a %), compares it with a limit (80%), and fails the build if it falls short, exiting with a code ≠ 0. It's, line by line, the same machine as your performance gate, applied to another dimension of quality. Seeing the two side by side gives you an intuition that transcends this guide: any measurable property of your software can become a gate —coverage, latency, bundle size, number of vulnerabilities, accessibility debt—, all with the same recipe. By the end you'll understand performance not as an isolated topic, but as just another gate on your pipeline's quality dashboard, and you'll know where it fits in the testing pyramid.

Connection to the module: lessons 2–5 built and justified the performance gate in the concrete. This one generalizes it: it shows it's an instance of a pattern you already use in other test families, and links explicitly with the testing-fundamentals-and-tdd-guide, where the coverage gate is taught in depth. It's the module's most conceptual lesson and the one that connects this guide with the rest of the Testing ecosystem. What comes next (lesson 7) returns to the concrete of k6 with abortOnFail and per-scenario thresholds; module 7 installs this gate in the complete CI pipeline.

The same scale, different things to weigh

Go back to the factory's quality station —the one that weighs bottles—. Now imagine the same line produces, besides bottles, cardboard boxes. At the end of the line you put another station identical in its mechanics: it measures a property (this time the cardboard's thickness, not the liquid's weight), compares it with a rule ("at least 3 mm"), and turns on a green or red light that triggers the mechanical arm. The bottle station and the box station are the same machine —scale, rule, light, arm—; the only difference is which property they measure and what limit they use. If you understand one, you understand the other, because they share the complete anatomy.

Your pipeline's quality gates are stations like this. The coverage gate measures "what percentage of the code the tests execute"; your performance gate measures "how long the p95 takes under load." Different properties, different limits, identical machine: measure → compare → binary verdict → exit code → the pipeline acts. This lesson is about recognizing that shared machine, so you don't have to relearn it every time you want to gate a new dimension of quality.

The coverage gate, up close

In the testing fundamentals guide you learned about coverage: what percentage of your lines of code your tests execute. And you learned an important warning —coverage is a tool, not a goal: 100% coverage doesn't mean 0 bugs, and chasing the number for the number's sake leads to useless tests—. With that maturity, coverage can become a gate: not to worship the number, but to prevent regressions —a change leaving untested code that used to be tested—.

In pytest with the coverage plugin, that gate is a single flag:

# CONTENT (reference to the Testing ecosystem): the coverage gate.
$ pytest --cov=reservo --cov-fail-under=80
...
Required test coverage of 80% not reached. Total coverage: 73.20%
$ echo $?
1

Look at it with this module's eyes. --cov=reservo says what to measure (the coverage of the reservo package) —it's the SLI—. --cov-fail-under=80 is the limit —"the coverage must be above 80%," an SLO—. The measured coverage (73.20%) doesn't reach it, so pytest emits the verdict (failed) and exits with code 1 —the same "held" stamp from lesson 4—. A pipeline that runs this command turns the build red exactly like with a broken test or a failed k6 threshold. It's your performance gate with another metric and another limit.

The mapping, side by side

Let's put the two stations next to each other, with the anatomy you already know:

Part of the gateCoverage gate (Testing)Performance gate (this guide)
What's measured (SLI)Test coverage (%)p95 latency, error rate, checks
The limit (SLO)--cov-fail-under=80p(95)<200, rate<0.01, rate>0.99
How it's measuredpytest --covk6 (content) / Python generator (executed)
The verdictcoverage ≥ 80% ? pass : failthresholds met ? pass : fail
The exit codepytest exits with ≠ 0 if shortk6 exits with 99 / the Python gate with 1
Consequencered build, merge blockedred build, deploy blocked
The warninghigh coverage ≠ 0 bugslow latency ≠ app good for everything

Each row is identical in structure; only the content of the two columns changes. And notice the last row —the warning—, because it reveals that the family shares even its traps. Just as 100% coverage doesn't guarantee absence of bugs (you can execute every line without verifying anything useful), a low p95 doesn't guarantee a good app (it can be fast and return errors, or fast only on the endpoint you measured). In both cases, the gate protects against one dimension of quality, and confusing "it passed the gate" with "the software is good" is the same mistake in both families. A gate is a necessary condition, not a sufficient one.

Why performance deserves its own gate

If the pattern is the same, why aren't the gates you already have (tests, coverage, linter) enough? Because each gate protects a different dimension, and none covers the others. Your unit tests verify that the logic is correct —that price_cents(Focus, basic, 3) gives 7500—, but they run with one request at a time: they say nothing about what happens with 120 concurrent requests. The coverage gate verifies that your tests touch the code, but not that the code is fast. The linter verifies the style. None looks at the latency under load. Performance is a property orthogonal to correctness, coverage, and style: code can be correct, well tested, well formatted —and slow under load—. That's why it needs its own station on the line.

This connects with the testing pyramid that structures the whole Testing ecosystem. At the base are the unit tests (many, fast, on units of logic); in the middle, the integration ones; on top, the E2E ones (few, slow, on complete flows through the browser —the Playwright guide—). Load tests are the other tip of the pyramid, alongside the E2E: few, expensive to run, on the whole system —but asking something no other layer asks: not "is it correct?" but "does it hold up?"—. The performance gate is how that tip of the pyramid becomes automatic, just as the coverage gate automates the watching of the base.

The generalization: anything measurable can be a gate

Once you see the machine, you see it everywhere. These are all gates with the same anatomy, each protecting a different dimension:

  • Bundle size: measure the KB of JavaScript shipped to the browser, gate at "< 250 KB", fail the build if it bloats. (Protects load speed.)
  • Vulnerabilities: measure how many dependencies have known CVEs, gate at "0 critical", fail if one appears. (Protects security.)
  • Accessibility: measure how many WCAG rule violations the page has, gate at "0 serious", fail if there are any. (Protects inclusion.)
  • Performance (this guide): measure the p95 under load, gate at "< 200 ms", fail if it degrades. (Protects the experience under traffic.)

They're all: measure a property → compare it with a limit → binary verdict → exit code → the pipeline acts. You learned that recipe by building a performance gate with your own hands in Python; now you know it's transferable to any property you can measure and for which you can defend a limit. That's the module's conceptual gift: you didn't learn "how to set a threshold in k6," you learned how any dimension of quality becomes an automatic gate.

Common mistakes

Believing that passing a gate means "the software is good." What happens: the build is all green (tests, coverage, performance) and someone concludes "it's perfect." Why it happens: "it passed the necessary conditions" gets confused with "it meets all the desirable properties." How to detect it: if you treat the green as a total guarantee instead of as "it didn't break these concrete dimensions," you'll get surprises. How to fix it: understand each gate as protection for one dimension; the set of gates covers what you decided to watch, not everything imaginable. A gate is necessary, not sufficient.

Duplicating in one gate what another already covers (and leaving gaps). What happens: a team has three gates that verify variants of the same thing (correctness) and none that verifies performance. Why it happens: gates get added out of inertia without mapping which dimension each one covers. How to detect it: list your gates and the dimension each protects; if there are important dimensions with no gate (like performance), you have a gap. How to fix it: think in orthogonal dimensions —correctness, coverage, performance, security— and set a gate for the one that matters to you, without redundancy.

Chasing the gate's number instead of what it represents. What happens: just as someone inflates coverage to 100% with tests that verify nothing, someone "optimizes" to pass the performance gate by measuring only the fast endpoint and avoiding the slow one. Why it happens: the gate becomes the goal, not the means. How to detect it: if your load test deliberately avoids the paths you suspect are slow, you're gaming the gate. How to fix it: remember the family's shared warning —the number is a tool, not the goal—; measure the paths that really matter to the user, even if they come out ugly.

Exercises

Exercise 1 — Recognize the machine. For each gate, identify its five parts (what it measures, limit, how it measures, verdict, consequence). (a) pytest --cov-fail-under=80. (b) http_req_duration: ["p(95)<200"] in k6. (c) A bundle-size gate that fails if the JS exceeds 250 KB.

See solution
  • (a) Measures: test coverage (%). Limit: 80%. How: pytest --cov. Verdict: coverage ≥ 80 ? pass : fail. Consequence: pytest exits with ≠ 0 → red build → merge blocked.
  • (b) Measures: p95 latency. Limit: 200 ms. How: k6 (or the Python generator). Verdict: p95 < 200 ? pass : fail. Consequence: k6 exits with 99 → red build → deploy blocked.
  • (c) Measures: KB of the JS bundle. Limit: 250 KB. How: a tool that measures the build. Verdict: KB ≤ 250 ? pass : fail. Consequence: the step exits with ≠ 0 → red build.

All three are the same machine with different content in "what it measures" and "limit."

Exercise 2 — The shared warning. Coverage has the warning "100% ≠ 0 bugs." Formulate the equivalent warning for the performance gate, and give a concrete example of an app that passes the p95 gate but is still bad.

See solution

The equivalent warning: "low p95 ≠ good app" —meeting the latency limit doesn't guarantee a good experience—. Concrete examples of an app that passes p(95)<200 but is bad: (a) it responds in 8 ms but 8% of the responses are 500 errors (fast and broken —that's why we also gate http_req_failed); (b) the endpoint you measured (/quote) is fast, but /book, which you didn't include in the test, takes 3 s (you measured the wrong path); (c) it's fast with the load you tested but collapses with double (you chose a load profile that doesn't reflect the real peak). In all three, the gate is green and the app is bad —the gate protects a concrete dimension, not everything—.

Exercise 3 — Place it in the pyramid. Where does a load test live in the testing pyramid, and what question does it answer that no other layer answers? Contrast it with the unit tests (base) and the E2E (tip).

See solution

A load test lives at the tip of the pyramid, alongside the E2E: they're few, expensive to run, and exercise the whole system (not an isolated unit). But it answers a different question from the E2E: the E2E ask "is it correct?" (the user who clicks Quote, do they see the price right?, through the browser —the Playwright guide—); the load test asks "does it hold up?" (with 120 users at once, does it keep responding fast and without errors?). The base's unit tests verify the logic with one request at a time (price_cents gives 7500) —correct, fast, many— but say nothing about concurrency. No other layer asks about behavior under load; that's the performance test's unique contribution to the pyramid, and this module's gate is how it becomes automatic.

Summary and next step

The performance gate isn't a unique machine: it's an instance of the anatomy shared by all quality gates —measure a property, compare it with a limit, emit a binary verdict, exit with a code the pipeline respects—. You saw it alongside its close relative from the Testing ecosystem, the coverage gate (pytest --cov-fail-under=80): same machine, different metric (coverage vs latency) and different limit (80% vs 200 ms), even the same warning (high coverage ≠ 0 bugs; low p95 ≠ good app). Performance deserves its own gate because it's a dimension orthogonal to correctness, coverage, and style: code can be correct, well tested, and slow under load. In the testing pyramid, load lives at the tip alongside the E2E, but asking "does it hold up?" instead of "is it correct?".

The generalization is the module's gift: any measurable property —bundle size, vulnerabilities, accessibility, performance— can become a gate with the same recipe. You didn't just learn to set a threshold in k6; you learned to turn any dimension of quality into an automatic gate.

Before moving on you should be able to: describe the common anatomy of coverage gate and performance gate; explain why performance needs its own gate (orthogonality); and place the load test in the pyramid. What comes next, in lesson 7, returns to the concrete of k6 with two refinements: abortOnFail (cutting early when a critical limit breaks) and per-scenario thresholds (a different SLO for each part of the system) —which is exactly what you learned to value in lesson 5, now applied piece by piece—.

Resources