REST vs GraphQL vs gRPC: How Testing Differs (2026)
Quick answer
All three are contract-described, but the contract, the failure surface and the tooling differ. REST asserts status codes and JSON Schema from an OpenAPI document. GraphQL always returns 200, so tests assert on the errors array, on partial data, and on query depth and complexity limits. gRPC asserts status codes from the gRPC status set against a protobuf schema, with streaming and backward-compatibility rules of its own. The layers stay the same; the assertions do not.
Reviewed by Parveen Kumari
Teams usually discover this the hard way: the test suite that worked for REST is ported to a GraphQL or gRPC service, everything passes, and nothing is actually being checked. The test layers transfer perfectly. The assertions do not.
For the layer model itself, see 9 types of API testing.
What changes across the three
| Dimension | REST | GraphQL | gRPC |
|---|---|---|---|
| Contract format | OpenAPI document | SDL schema | .proto files |
| Transport | HTTP/1.1 or HTTP/2 | HTTP, usually one POST endpoint | HTTP/2 |
| Payload | JSON (usually) | JSON | Protobuf binary |
| Success signal | HTTP status code | HTTP 200 plus an absent errors array | gRPC status code |
| Partial failure | Rare | Normal — data and errors together | No |
| Endpoint surface | Many paths and methods | Usually one endpoint, many operations | Many methods on many services |
| Breaking-change unit | Path, method, schema field | Type and field in the schema | Field number, not field name |
| Streaming | SSE or WebSocket, separately | Subscriptions | First class (uni- and bidirectional) |
| Human-readable on the wire | Yes | Yes | No — needs the schema to decode |
| Fuzzing / generation | Mature (Schemathesis, Dredd) | Good (Schemathesis, graphql-cop) | Thinner |
| Unique security surface | BOLA, mass assignment, rate limits | Query depth, complexity, batching, introspection | Reflection exposure, message size limits |
The same check, three ways
"Fetching order 42 returns a pending order, and asking for one that does not exist fails cleanly."
REST — the status code carries the outcome:
def test_get_order(api):
r = api.get("/v1/orders/42")
assert r.status_code == 200
body = r.json()
assert body["status"] == "pending"
def test_missing_order_is_404(api):
assert api.get("/v1/orders/does-not-exist").status_code == 404
GraphQL — the status code carries nothing, so the first assertion is on errors:
QUERY = """
query GetOrder($id: ID!) {
order(id: $id) { id sku qty status }
}
"""
def test_get_order(gql):
r = gql.post("/graphql", json={"query": QUERY, "variables": {"id": "42"}})
assert r.status_code == 200
body = r.json()
assert "errors" not in body, body.get("errors") # the assertion people forget
assert body["data"]["order"]["status"] == "pending"
def test_missing_order_returns_a_typed_error(gql):
r = gql.post("/graphql", json={"query": QUERY, "variables": {"id": "nope"}})
assert r.status_code == 200 # still 200
body = r.json()
assert body["data"]["order"] is None
assert body["errors"][0]["extensions"]["code"] == "NOT_FOUND"
gRPC — the status comes from the gRPC status set, not HTTP:
import grpc, pytest
from orders_pb2 import GetOrderRequest
from orders_pb2_grpc import OrderServiceStub
def test_get_order(channel):
stub = OrderServiceStub(channel)
order = stub.GetOrder(GetOrderRequest(id="42"))
assert order.status == "pending"
def test_missing_order_is_not_found(channel):
stub = OrderServiceStub(channel)
with pytest.raises(grpc.RpcError) as e:
stub.GetOrder(GetOrderRequest(id="nope"))
assert e.value.code() == grpc.StatusCode.NOT_FOUND
The failure modes each one adds
GraphQL: a query that costs the server everything. One endpoint means one place to abuse. These two checks have no REST equivalent and both should be in the suite:
Ready to shift left with your API testing?
Try our no-code API test automation platform free. Generate tests from OpenAPI, run in CI/CD, and scale quality.
def test_deeply_nested_query_is_rejected(gql):
# each level multiplies the work; without a depth limit this is a DoS
q = "query { order(id:\"42\") " + "{ customer { orders " * 12 + "{ id }" + " } }" * 12 + " }"
body = gql.post("/graphql", json={"query": q}).json()
assert "errors" in body, "no depth limit — arbitrarily deep queries are accepted"
def test_introspection_is_disabled_in_production(gql_prod):
body = gql_prod.post("/graphql", json={"query": "{ __schema { types { name } } }"}).json()
assert "errors" in body, "introspection is enabled in production"
gRPC: field numbers are the contract. Protobuf identifies fields by number on the wire, so renaming sku to product_sku is safe and reusing field number 3 for a different type is a silent, catastrophic breaking change. That means schema review, not runtime testing, is the real gate:
message Order {
string id = 1;
string sku = 2; // renaming this is safe — the number is the contract
int32 qty = 3;
string status = 4;
reserved 5, 6; // never reuse a retired number
reserved "legacy_total"; // nor a retired name
}
Add a schema-compatibility check to CI the same way you would add oasdiff for OpenAPI, and pair it with server reflection so tests can discover the surface:
# what does this service expose?
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 describe orders.OrderService
# call a method without a local proto copy
grpcurl -plaintext -d '{"id":"42"}' localhost:50051 orders.OrderService/GetOrder
REST: the surface is wide, so coverage is the problem. Many paths and methods means many operations nobody wrote a test for — which is why generation from the spec matters more here than anywhere else:
schemathesis run openapi.yaml --url "$STAGING_URL" --checks all
What stays the same
Every layer transfers. Unit tests still test logic with the transport stubbed. Integration tests still use real dependencies via containers. Contract testing still means the provider and consumer agree independently. End-to-end still means the whole journey, kept small.
And the security questions transfer almost entirely: object-level authorization, function-level authorization, mass assignment and resource consumption limits are protocol-independent. A GraphQL resolver that returns another tenant's order is the same BOLA finding as a REST endpoint that does, and neither the schema nor the proto file will catch it.
Test data and mocking across the three
Each protocol has a different natural source for generated payloads, which changes how test data is produced.
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Schema for generation | OpenAPI components | SDL types | protobuf messages |
| Generator | json-schema-faker, Faker | graphql-faker, schema-driven | protobuf-faker, or hand-built factories |
| Mock server | Prism, WireMock | Apollo Server mocks, graphql-faker | grpc-mock, or a stub implementation |
| Contract-derived mock | Yes, from OpenAPI | Yes, from SDL | Yes, from the proto |
Free Interactive spreadsheet + guide
Test Automation ROI Calculator
Quantify the ROI of test automation for your team. Input your team size, bug rates, and fix times — get projected savings in hours and dollars.
Download Free# REST: a mock of every operation, straight from the contract
npx @stoplight/prism-cli mock openapi.yaml --port 4010 --dynamic
# GraphQL: a mock resolver set generated from the SDL
npx graphql-faker ./schema.graphql --port 4011
# gRPC: generate a stub server from the proto, then fill in the responses you need
protoc --go_out=. --go-grpc_out=. proto/orders.proto
The practical difference is discoverability. A REST or GraphQL mock is browsable — you can curl it or open a playground. A gRPC mock is opaque without the schema, so keeping .proto files accessible to the test environment is not optional the way an OpenAPI document sometimes is.
Observability differences that affect debugging
When a test fails, how quickly you can see why differs sharply by protocol.
REST is the easiest: the request and response are text, any proxy can show them, and curl -v reproduces the exchange in one line.
GraphQL is text too, but everything arrives at one endpoint, so per-operation visibility depends on instrumentation. Without an operation name in the request, traces and logs collapse into a single undifferentiated POST /graphql:
// always send an operation name — it is what makes traces and logs readable
await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
operationName: 'GetOrder', // <- this line is the whole difference
query: 'query GetOrder($id: ID!) { order(id:$id) { id status } }',
variables: { id: '42' },
}),
});
gRPC is binary, so a packet capture tells you nothing without the schema. Two things make it debuggable: enabling server reflection in non-production environments so grpcurl works without local protos, and turning on verbose logging when a test fails:
# make a failing gRPC test explain itself
export GRPC_GO_LOG_VERBOSITY_LEVEL=99
export GRPC_GO_LOG_SEVERITY_LEVEL=info
go test ./... -run TestGetOrder -v
# or reproduce the exact call by hand
grpcurl -plaintext -d '{"id":"42"}' localhost:50051 orders.OrderService/GetOrder
Budget for this when planning a gRPC test suite. The tests are no harder to write; the failures are harder to read, and that shows up as slower triage rather than as a missing test.
Sources and further reading
- GraphQL specification — the normative execution and error semantics.
- OWASP GraphQL Cheat Sheet — depth limiting, batching and introspection risks.
- Protocol Buffers language guide — the field-number compatibility rules gRPC contracts depend on.
- OpenAPI Specification — the REST contract format.
Key takeaways
- The test layers are identical across all three protocols; the assertion vocabulary is not.
- GraphQL returns 200 for application failures, so every test must assert that
errorsis absent — otherwise the suite is green while the query failed. - gRPC's breaking-change unit is the protobuf field number, not the field name, which makes schema review the real contract gate.
- Each protocol adds its own security surface: depth and complexity limits for GraphQL, reflection and message limits for gRPC, and the wide operation surface for REST.
- Authorization bugs are protocol-independent. BOLA and BFLA tests transfer unchanged, and no schema of any kind catches them.
Related articles
9 Types of API Testing | How to Test GraphQL APIs | gRPC Testing Tools | REST API Testing Best Practices | API Testing: The Complete Guide
Ready to shift left with your API testing?
Try our no-code API test automation platform free.