it('renders this post', () => {
October 3, 2026 9 min read

Testing REST and gRPC with k6, Part 1: setup and functional tests

In this demo you will see one Docker container, two protocols, and one repo: this two-part series walks through how I tested REST and gRPC with k6 against Grafana’s QuickPizza demo app. Part 1 covers setup and the functional tests; Part 2 covers performance, reporting, the Playwright layer and CI.

Why I built this

Most API test tutorials stop at one REST endpoint and one load script. Real services rarely look like that: the same backend often speaks REST to browsers and gRPC to other services, and both need functional checks and performance numbers.

I wanted one repository that answers three questions against the same running service:

  1. Does the REST API behave correctly (auth, CRUD, contracts, error cases)?
  2. Does the gRPC API behave correctly (status, unary calls, invalid input)?
  3. How do both hold up under load, stress, soak, spike and breakpoint traffic?

The system under test is QuickPizza, Grafana’s demo app, which exposes both a REST API and a gRPC service from a single Docker image. The full project is on GitHub: KulsumSiddique/k6-RestGRPC-Demo.

What’s in the box:

LayerToolWhat it tests
REST functionalk6Auth, ratings CRUD, response contracts, health, negative cases
gRPC functionalk6 (k6/net/grpc)Status and RatePizza RPCs, invalid payloads
Performancek6Load, stress, soak, spike, breakpoint and synthetic profiles for both protocols
BrowserPlaywrightLogin, ratings UI, API CRUD, accessibility, page timing
ReportingNode scriptk6 JSON summary to an HTML dashboard

Architecture at a glance

All three layers target the same container, so a REST rating created by k6 and a page rendered by Playwright come from the same backend state.

Steps 1–3: Set up and start QuickPizza

Step 1: Install the prerequisites

You need three tools on your machine:

  • Node.js and npm — for Playwright, linting and the report script (CI uses Node 22)
  • Docker Desktop — to run QuickPizza locally
  • k6 — the load-testing CLI that runs every REST and gRPC test

Step 2: Clone and install

git clone https://github.com/KulsumSiddique/k6-RestGRPC-Demo.git
cd k6-RestGRPC-Demo
npm install
npx playwright install

k6 itself needs no npm packages. Its scripts are plain ES modules that k6 runs with its own engine, so npm install only pulls in Playwright, TypeScript, Zod, ESLint and Prettier.

Step 3: Start QuickPizza in Docker

The whole backend is one service in docker-compose.yml:

services:
  quickpizza:
    image: ghcr.io/grafana/quickpizza-local:0.15.30
    container_name: crud-playwright-quickpizza
    ports:
      - '8080:3333'   # REST
      - '5051:3334'   # gRPC
    environment:
      QUICKPIZZA_ENABLE_ALL_SERVICES: '1'
      QUICKPIZZA_DB: ''
    restart: unless-stopped

Pinning the image tag (0.15.30) keeps runs reproducible. QUICKPIZZA_ENABLE_ALL_SERVICES turns on the gRPC server alongside REST.

Start it and confirm both ports are listening:

docker compose up -d
docker compose ps
lsof -nP -iTCP:8080 -sTCP:LISTEN
lsof -nP -iTCP:5051 -sTCP:LISTEN

If a test later reports connection refused, this step is the first place to look.

Step 4: Shared config and test data

Every k6 script, REST or gRPC, reads its target from one file, shared/config.js. That keeps environment switching out of the tests:

const environment = __ENV.TEST_ENV || 'local';

const defaults = {
  local: {
    restBaseUrl: 'http://localhost:8080',
    grpcAddress: 'localhost:5051',
    grpcTls: false,
  },
  public: {
    restBaseUrl: 'https://quickpizza.grafana.com',
    grpcAddress: 'grpc-quickpizza.grafana.com:443',
    grpcTls: true,
  },
};

const selected = defaults[environment] || defaults.public;

export const config = {
  environment,
  restBaseUrl: __ENV.REST_BASE_URL || selected.restBaseUrl,
  grpcAddress: __ENV.GRPC_ADDR || selected.grpcAddress,
  grpcTls: __ENV.GRPC_TLS ? __ENV.GRPC_TLS !== 'false' : selected.grpcTls,
};

Local is the default. To point the same tests elsewhere, pass environment variables on the command line:

k6 run -e TEST_ENV=public rest/tests/suite.js
k6 run -e REST_BASE_URL=http://my-host:8080 rest/tests/health.js

Test data lives in shared/data.js. Two tiny helpers cover everything: uniqueValue() builds collision-free usernames from a timestamp plus a random number, and randomItem() picks a random pizza payload. Because every iteration creates its own user, tests never share state and can run in parallel without stepping on each other.

Steps 5–6: The REST layer

Step 5: Build thin clients, not fat tests

The REST code is split into four folders, each with one job:

FolderJob
rest/clients/One function per endpoint: createUser, login, createRating, getRating, updateRating, deleteRating, getHealth
rest/data/Payload builders: buildRatingPayload(), buildUpdatedRatingPayload()
rest/schemas/Lightweight response validators for users, ratings and errors
rest/assertions/Reusable check() wrappers for status codes, timing and rating bodies

Everything goes through one wrapper, rest/clients/http-client.js. It adds the JSON header and auth token, tags each request, and records a custom metric:

export const restOperationDuration = new Trend('rest_operation_duration', true);

export function request(method, path, body = null, token = null, tags = {}) {
  const { expectedStatuses, ...metricTags } = tags;
  const headers = { 'Content-Type': 'application/json' };
  if (token) headers.Authorization = `Token ${token}`;

  const params = { headers, tags: { protocol: 'rest', ...metricTags } };
  if (expectedStatuses) params.responseCallback = http.expectedStatuses(...expectedStatuses);
  const payload = body === null ? null : JSON.stringify(body);
  const started = Date.now();
  const response = http.request(method, `${config.restBaseUrl}${path}`, payload, params);
  restOperationDuration.add(Date.now() - started, params.tags);
  return response;
}

Two details matter here:

  • Operation tags. Every client passes { operation: 'create_rating' } and so on. In the results you can then filter latency per endpoint instead of staring at one blended average.
  • expectedStatuses. k6 counts any 4xx as a failed request by default. When a test expects a 404 (reading a deleted rating), passing expectedStatuses: [404] keeps that call out of http_req_failed, so the error-rate threshold stays honest.

Step 6: Write the functional tests

The core test is rest/tests/ratings-crud.js. It walks one rating through its whole life with a fresh user:

export default function () {
  const user = buildUser();
  createUser(user);
  const auth = login(user);
  check(auth, { 'auth: token exists': (value) => Boolean(value.token) });

  const created = createRating(buildRatingPayload(), auth.token);
  check(created.response, { 'rating: created': (res) => res.status >= 200 && res.status < 300 });
  assertRating(created.body, 'rating create');

  const id = created.body?.id;
  if (id === undefined || id === null) return;

  assertRating(getRating(id, auth.token).body, 'rating get');

  const updated = updateRating(id, buildUpdatedRatingPayload(), auth.token);
  assertRating(updated.body, 'rating update');

  const removed = deleteRating(id, auth.token);
  check(removed.response, { 'rating: deleted': (res) => res.status >= 200 && res.status < 300 });

  const afterDelete = getRating(id, auth.token, { expectedStatuses: [404] });
  check(afterDelete.response, { 'rating: no longer exists': (res) => res.status === 404 });
}

The last check is the one people skip: a delete that returns 200 but leaves the record readable is still a bug.

The other REST tests follow the same pattern: auth.js (signup and login), contract.js (response shape), health.js (/healthz) and negative.js (unauthorized and invalid requests). rest/tests/suite.js imports all five and runs them in one iteration with strict thresholds:

export const options = {
  vus: 1,
  iterations: 1,
  thresholds: {
    checks: ['rate>0.99'],
    http_req_failed: ['rate<0.01'],
  },
};

export default function () {
  auth();
  contract();
  health();
  negative();
  ratingsCrud();
}

Run a single file or the whole suite:

k6 run rest/tests/ratings-crud.js
k6 run rest/tests/suite.js

If any check fails, the checks threshold fails and k6 exits non-zero, which is exactly what a CI pipeline needs.

Steps 7–8: The gRPC layer

Step 7: Start from the proto contract

gRPC tests begin with the .proto file, not with code. QuickPizza’s contract is small, two unary methods:

syntax = "proto3";

package quickpizza;

service GRPC {
  rpc Status(StatusRequest) returns (StatusResponse);
  rpc RatePizza(PizzaRatingRequest) returns (PizzaRatingResponse);
}

message StatusRequest {}
message StatusResponse { bool ready = 1; }

message PizzaRatingRequest {
  repeated string ingredients = 1;
  string dough = 2;
}
message PizzaRatingResponse { int32 stars_rating = 1; }

k6 loads this file at startup, so no code generation step is needed. The client in grpc/clients/quickpizza-client.js wraps every call and records three custom metrics:

import grpc from 'k6/net/grpc';
import { Trend, Rate, Counter } from 'k6/metrics';
import { config } from '../../shared/config.js';

export const grpcRpcDuration = new Trend('grpc_rpc_duration', true);
export const grpcRpcErrors = new Rate('grpc_rpc_errors');
export const grpcStatusCodes = new Counter('grpc_status_codes');

export const client = new grpc.Client();
client.load(['../../proto'], 'quickpizza.proto');

export function connect() {
  client.connect(config.grpcAddress, { plaintext: !config.grpcTls });
  return client;
}

export function invoke(method, payload, operation) {
  const started = Date.now();
  const response = client.invoke(method, payload, { tags: { protocol: 'grpc', operation } });
  const tags = { protocol: 'grpc', operation, status: String(response.status) };
  grpcRpcDuration.add(Date.now() - started, tags);
  grpcRpcErrors.add(response.status !== grpc.StatusOK, tags);
  grpcStatusCodes.add(1, tags);
  return response;
}

export const status = () => invoke('quickpizza.GRPC/Status', {}, 'status');
export const ratePizza = (payload) => invoke('quickpizza.GRPC/RatePizza', payload, 'rate_pizza');

Why custom metrics? HTTP gives you http_req_failed for free. gRPC errors arrive as status codes inside a successful transport call, so grpc_rpc_errors is what lets you write an error-rate threshold for gRPC at all.

Note the plaintext: !config.grpcTls switch: locally the container speaks plain gRPC on port 5051, while the public QuickPizza endpoint needs TLS on 443. The same test file works for both.

Step 8: Write the gRPC functional tests

The smoke test checks readiness first, then rates a pizza:

export const options = {
  vus: 1,
  iterations: 1,
  thresholds: { checks: ['rate>0.99'], grpc_rpc_errors: ['rate<0.01'] },
};

export default function () {
  connect();
  const health = status();
  assertGrpcOk(health, grpc, 'status');
  assertReady(health);

  const rating = ratePizza(buildPizzaPayload());
  assertGrpcOk(rating, grpc, 'rate pizza');
  assertRating(rating);
  close();
}

The rest of the gRPC suite:

  • status.js and rate-pizza.js cover each RPC on its own.
  • negative.js sends { ingredients: [], dough: '' } and checks the server answers with InvalidArgument or OK rather than crashing.
  • streaming.js records that the contract exposes no streaming RPCs, so the gap is visible in every report instead of silently missing.

grpc/tests/suite.js runs them all in one go:

k6 run grpc/tests/smoke.js
k6 run grpc/tests/suite.js

Coming up in Part 2

You now have a working functional suite for both protocols. Part 2 (Part 2: Performance, reporting and CI) reuses the same clients to run six performance profiles (load, stress, soak, spike, breakpoint and synthetic), turns the k6 output into an HTML report, and adds the Playwright layer and CI.

Resources

These are the official docs behind each step in this part, in reading order.

ResourceWhy read it
Install k6Install commands for macOS, Linux, Windows and Docker
QuickPizzaThe demo app under test, with its REST and gRPC services
Introduction to gRPCHow services, methods and stubs fit together, if gRPC is new to you
Protocol Buffers proto3 guideThe syntax behind quickpizza.proto: messages, field numbers, repeated, services
gRPC status codesWhat OK, INVALID_ARGUMENT and the other codes mean, for writing negative tests
k6 gRPC guideLoading .proto files or using reflection, unary and streaming calls in k6
k6/net/grpc APIReference for Client.load(), connect(), invoke() and the response object
k6 checksWhy a failed check does not fail the run until you pair it with a threshold
Tags and groupsAdding operation and protocol tags to requests and filtering results by them
Create custom metricsTrend, Rate and Counter, as used for grpc_rpc_errors and rest_operation_duration
k6-RestGRPC-DemoThe full source code for both parts of this series

← Back to all posts