Testing REST and gRPC with k6, Part 2: performance, reporting and CI
Part 1 (Part 1: Setup and functional tests) set up QuickPizza in Docker and built functional REST and gRPC tests with k6. Part 2 puts those same clients under load with six performance profiles, turns the results into a shareable HTML report, and adds the browser layer and CI.
Step 9: Six performance profiles in one run
Functional tests prove the API works. Performance tests ask how it behaves when many users arrive at once. Each protocol has six profile files (load.js, stress.js, soak.js, spike.js, breakpoint.js, synthetic.js), and a performance-suite.js that runs all six as k6 scenarios in a single command.
The REST profiles, as configured in rest/tests/performance-suite.js:
| Profile | Executor | Shape | Peak VUs | Pass criteria |
|---|---|---|---|---|
| Load | ramping-vus | Ramp to 2 VUs, hold, ramp down (35 s) | 2 | Errors < 1%, p95 < 500 ms |
| Stress | ramping-vus | Starts at 35 s, climbs to 5 VUs and holds | 5 | Errors < 5%, p95 < 1000 ms |
| Soak | constant-vus | 2 VUs for 15 minutes | 2 | Errors < 1% |
| Spike | ramping-vus | 1 VU, jump to 10 in 5 s, hold 20 s, drop | 10 | Errors < 5% |
| Breakpoint | ramping-vus | Doubles every 10 s: 1, 2, 4, 8, 16 | 16 | Errors < 10% |
| Synthetic | shared-iterations | One health-and-API check | 1 | Zero errors |
The trick that makes one combined run readable is scenario-scoped thresholds. k6 tags every request with its scenario name, so each profile gets its own pass bar:
thresholds: {
'http_req_failed{scenario:load}': ['rate<0.01'],
'http_req_duration{scenario:load}': ['p(95)<500'],
'http_req_failed{scenario:stress}': ['rate<0.05'],
'http_req_duration{scenario:stress}': ['p(95)<1000'],
'http_req_failed{scenario:soak}': ['rate<0.01'],
'http_req_failed{scenario:spike}': ['rate<0.05'],
'http_req_failed{scenario:breakpoint}': ['rate<0.10'],
'http_req_failed{scenario:synthetic}': ['rate==0'],
},
Without the {scenario:...} filter, the spike’s expected errors would leak into the load profile’s 1% budget and fail it.
The standalone breakpoint.js goes one step further and stops the test once the system breaks, rather than hammering a dead service:
thresholds: {
checks: [{ threshold: 'rate>0.90', abortOnFail: true, delayAbortEval: '10s' }],
http_req_failed: [{ threshold: 'rate<0.10', abortOnFail: true, delayAbortEval: '10s' }],
},
delayAbortEval gives the ramp 10 seconds to settle before k6 judges it, so one slow first request does not end the run.
The gRPC profiles mirror the REST ones but judge grpc_rpc_errors and grpc_req_duration instead of the HTTP metrics. Run them while Docker is up:
k6 run rest/tests/performance-suite.js
k6 run grpc/tests/performance-suite.js
Budget about 15 minutes per command, because the soak scenario runs for its full duration. For a quick check, run one profile file on its own, for example k6 run rest/tests/spike.js.
The VU numbers are deliberately small. QuickPizza runs on a laptop, and the goal here is to show the patterns; raise the targets when you point this at a real staging environment.
Step 10: Turn k6 output into an HTML report
k6 prints a summary in the terminal, which is fine for you but hard to share with a team. Step one is to save that summary as JSON:
mkdir -p test-results/k6
k6 run --summary-export=test-results/k6/rest-performance.json rest/tests/performance-suite.js
k6 run --summary-export=test-results/k6/grpc-performance.json grpc/tests/performance-suite.js
Step two is scripts/k6-report.mjs, a dependency-free Node script (only fs and path) that turns the JSON into a single HTML page:
npm run report:k6 -- test-results/k6/rest-performance.json
npm run report:k6 -- test-results/k6/grpc-performance.json
The report opens with four cards that go red when something is wrong: total requests, failed requests, breached thresholds and failed checks. Below them sit a request-metrics table (count, rate, average, max, median, min, p90, p95) and a pass/fail table of every check.
The script works for both protocols because it falls back from HTTP metrics to the gRPC ones:
const requests = metric('http_reqs').count ?? metric('grpc_status_codes').count ?? metric('iterations').count ?? 0;
const failed = Math.round((metric('http_req_failed').rate ?? metric('grpc_rpc_errors').rate ?? 0) * requests);
This is where the custom grpc_status_codes and grpc_rpc_errors metrics from Part 1 (Step 7) pay off.
The one-command version
Once the steps make sense, two npm scripts chain everything together, from Docker up through functional suite, performance suite and report:
npm run test:rest:all
npm run test:grpc:all
The reports land at test-results/k6/rest-performance.html and test-results/k6/grpc-performance.html. Because the steps are joined with &&, a failing functional suite stops the chain before you spend 15 minutes on a soak test against a broken build.
Step 11: Add the browser layer and CI
k6 covers the APIs. Playwright covers what a user actually sees, against the same QuickPizza container. Its structure follows familiar patterns:
- Page objects in
pages/(LoginPage,RatingsPage,BasePage) - Typed API clients in
api/with Zod schemas, so a changed response shape fails loudly - Fixtures in
fixtures/quickpizza.tsthat create a disposable user through the API before each signed-in test, so there is no shared test account and no stored password - Tags (
@smoke,@api,@ui,@regression) that let you run slices of the suite
The tests cover login and logout, the ratings page, API CRUD, accessibility with axe-core, and browser timing (tests/browser-performance.spec.ts):
npm test
npm run test:smoke
BASE_URL=http://localhost:8080 npx playwright test tests/browser-performance.spec.ts --project=chromium
npx playwright show-report --port 9330
A GitHub Actions workflow (.github/workflows/playwright.yml) runs on every push and pull request. It installs dependencies with npm ci, then gates in order: typecheck, lint, format check, smoke tests, full tests. The HTML report and test results are uploaded as artifacts and kept for 14 days, even when the run fails.
The k6 suites are not in CI yet, because the 15-minute soak does not belong on every push. The functional suites (rest/tests/suite.js, grpc/tests/suite.js) take seconds and are the natural next addition, using the official grafana/setup-k6-action and the same Docker service.
When you are done, stop the container:
docker compose down
Lessons learned
- Tag everything. Operation and protocol tags cost one line per client and turn one blended latency number into a per-endpoint breakdown.
- gRPC needs its own error metric. A failed RPC is still a successful transport call, so without
grpc_rpc_errorsyour error-rate threshold for gRPC measures nothing. - Expected errors must be declared.
expectedStatuses: [404]keeps deliberate negative calls out ofhttp_req_failed. - Scope thresholds per scenario. In a combined run, an unscoped threshold lets the spike profile fail the load profile.
- Watch scenario timing. In the combined suite only
stresshas astartTimeoffset; soak, spike, breakpoint and synthetic start at 0 s alongside load. That is fine for a demo, but for clean per-profile numbers, stagger the start times or run the profile files separately. - Disposable data beats shared accounts. Every k6 iteration and every Playwright test creates its own user, so nothing depends on leftover state.
- Record gaps as checks. The streaming test does not test streaming; it records that the contract has none, so the gap stays visible in every report.
Wrap-up
Across the two parts, one Docker container and about a dozen small files per protocol give you functional and performance coverage for both REST and gRPC, plus a shareable HTML report. The order matters: contract and clients first, functional suites second, performance profiles last, and reporting once there is something worth sharing.
Clone the repo, run docker compose up -d and npm run test:rest:all, and you have the whole pipeline running locally in a few minutes, plus the 15-minute soak.
Resources
These are the official docs behind each step in this part, in reading order.
| Resource | Why read it |
|---|---|
| Load test types | Grafana’s definitions of smoke, average-load, stress, soak, spike and breakpoint tests, and when to use each |
| Scenarios | How multiple scenarios share one script, the six executors, and sequencing with startTime |
| Thresholds | Pass/fail criteria, tag-filtered thresholds, and abortOnFail with delayAbortEval |
| End-of-test summary | What the terminal summary shows and how to customize it before exporting |
| setup-k6-action | The official GitHub Action for installing k6 in CI, the next step for this repo |
| Playwright: getting started | Installing Playwright Test, running tests and opening the HTML report |
| k6-RestGRPC-Demo | The full source code for both parts of this series |
