REST vs GraphQL vs gRPC Testing (and Where SOAP Fits)
Quick answer
REST, GraphQL and gRPC all have machine-readable contracts, but tests assert different things. REST checks HTTP status codes and JSON against OpenAPI. GraphQL returns 200 even on failure, so tests assert the errors array, partial data and query limits. gRPC checks gRPC status codes against protobuf, where field numbers are the contract. SOAP adds XML envelopes, WSDL and SOAP Faults.
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.
This is the hub page for our protocol testing guides. It compares REST, GraphQL and gRPC in depth, adds SOAP where it differs, and links to a dedicated guide for each: GraphQL API testing, SOAP API testing, gRPC testing tools, WebSocket testing, JSON-RPC testing, MCP server testing and MCP server security testing. For the layer model itself, see 9 types of API testing.
In this guide
- What changes across the three
- The same check, three ways
- The failure modes each one adds
- What stays the same
- Test data and mocking across the three
- Observability differences that affect debugging
- Where each protocol wants its tests
- What carries over when the protocol changes
- Where SOAP fits
- Which protocol to choose, and migrating between them
- Common mistakes across all three
- Protocol testing guides and tools
- FAQ
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 — how to test GraphQL APIs covers depth, complexity and batching in full:
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. The best gRPC testing tools covers the clients and harnesses that make this workable:
# 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, and why REST API testing best practices leans so heavily on the OpenAPI document:
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.
Generate test cases from your spec (free)
Free, runs in your browser, no sign-up needed.
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 — see how to test each OWASP API Security Top 10 risk.
Tooling increasingly reflects that. Shift-Left API's API security testing add-on, for example, generates the same authentication, BOLA, mass-assignment and injection checks across REST, SOAP and GraphQL (and JSON-RPC/MCP and WebSocket-RPC on Trial and Enterprise), adding protocol-specific ones such as GraphQL introspection where they apply. It does not cover gRPC, which the platform does not test. The add-on is paid on Professional, Custom or Enterprise licences and included in the Trial.
Test data and mocking across the three
Each protocol has a different natural source for generated payloads, which changes how test data is produced. The generation techniques themselves are covered in how to generate test data for API testing, and the mock servers in the best API mocking tools.
| 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 |
# 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.
Where each protocol wants its tests
The three protocols do not just need different assertions — they shift which layer catches the most bugs per unit of effort. Putting the weight in the wrong layer is the most common reason a suite feels expensive.
| Layer | REST | GraphQL | gRPC |
|---|---|---|---|
| Contract / schema | High value — the spec is the contract | Highest value — schema diffing catches most breakage | Mostly free — .proto mismatches fail at build |
| Field-level authorization | Per endpoint | Highest value — one route, many fields | Per method, via metadata |
| Request validation | High — hand-built payloads vary wildly | Lower — the schema constrains input | Low — types are enforced by generated code |
| Serialization edge cases | Medium — nulls, dates, numbers | Medium | Low, except around optional and defaults |
| Streaming / termination | Not applicable | Subscriptions only | Highest value — three of four call types |
Read down your protocol's column and spend effort where the value is high. A REST suite that skips contract testing and a gRPC suite that skips streaming termination are both under-testing the thing most likely to break, while probably over-testing type validation the compiler already handles.
What carries over when the protocol changes
Teams migrating between protocols usually expect to rewrite everything, and usually rewrite more than they need to.
Carries over almost unchanged: the test data strategy, the fixtures that create and clean up state, the environment and credential handling, the CI wiring, and — most importantly — the list of behaviours worth asserting. "An order below the minimum value is rejected" is a business rule, not a protocol detail.
Needs rewriting: the transport layer, the assertion style, and the error-checking. These are mechanical and usually the smallest part of the work.
Needs rethinking: where authorization is enforced, and therefore where it is tested. This is the one that catches people. Moving REST to GraphQL moves authorization from the route to the field, and a direct port of the old tests will leave most fields unverified.
The practical approach is to keep the behaviour list in a form that is not protocol-specific — a traceability matrix, or simply well-named test functions — so a migration is re-plumbing rather than rediscovery.
In Shift-Left API that list is the requirements traceability matrix: requirements extracted from business documents are linked to the tests and workflows that verify them, with gaps attributed per endpoint. When a REST endpoint is replaced by a GraphQL operation, the requirement stays put, and the matrix shows whether any test on the new surface verifies it yet.
Where SOAP fits
SOAP is older than all three and still carries a large share of banking, insurance, healthcare and government integrations. It is worth comparing directly, because SOAP testing habits transfer to REST even less well than GraphQL ones. Here is the same operation, "get user 1", in REST, GraphQL and SOAP:
Free PDF + code examples
OpenAPI to Test Generation Template Pack
Go from OpenAPI spec to full test coverage. Includes sample specs, example generated tests, edge case patterns, and CI/CD integration guides.
Download FreeREST
GET /api/v1/users/1
-> 200 OK
-> { "id": "1", "name": "Alice", "email": "alice@example.com" }
GraphQL
POST /graphql
{ "query": "{ user(id: \"1\") { id name email } }" }
-> 200 OK
-> { "data": { "user": { "id": "1", "name": "Alice", "email": "alice@example.com" } } }
SOAP
POST /soap
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body><GetUser xmlns="http://example.com/users"><Id>1</Id></GetUser></soap:Body>
</soap:Envelope>
-> 200 OK
-> <soap:Envelope>...<GetUserResponse><User>...</User></GetUserResponse>...</soap:Envelope>
Same result, very different mechanics, and very different tests:
| Test concern | REST | GraphQL | SOAP |
|---|---|---|---|
| Contract | OpenAPI (optional in practice) | SDL schema (required) | WSDL (required) |
| Endpoint model | Many paths and methods | One endpoint | One endpoint per service, many operations |
| Success / failure signal | HTTP status | 200 plus errors array | HTTP 500 plus a SOAP Fault for errors |
| Shape assertion | JSON path | JSON path on data | XPath, with namespaces |
| Negative cases | 400/401/403/404/409/422 | errors[].extensions.code | Fault code and fault detail |
| Contract drift | OpenAPI diff | Schema diff | WSDL diff |
| Typical performance bug | Slow endpoints | N+1 resolvers | Large envelope parsing |
Two SOAP habits cause most missed bugs: treating every HTTP 500 as "server broken" when it is usually a well-formed fault that should be asserted on, and writing XPath that ignores namespaces, which passes against one service and silently matches nothing against the next. The full guide is SOAP API testing (WSDL).
Which protocol to choose, and migrating between them
Testing cost is rarely the deciding factor, but the choice shapes the suite, so it helps to know why each exists.
- REST fits resource-oriented APIs, public APIs, and anything that benefits from HTTP caching and the lowest learning curve. It is the default for most APIs.
- GraphQL fits many clients (web, iOS, Android) needing different slices of relationship-heavy data, often aggregated from several services. It needs investment in query limits, batching and N+1 detection.
- gRPC fits internal service-to-service calls where latency, strict typing and streaming matter, and both ends are under your control.
- SOAP is usually not chosen today but inherited: core banking, policy administration, government service buses, where formal contracts and the cost of replacement keep it alive.
Migration patterns, roughly: SOAP to REST is common and pays off when the legacy system is not deeply tied to WS-* extensions; REST to GraphQL is common in large organisations and usually means both run side by side for years; REST to gRPC happens for internal hot paths while the public edge stays REST. In every case, keep the list of behaviours you assert protocol-neutral (see above) so the migration is re-plumbing rather than rediscovery.
Common mistakes across all three
Porting REST habits wholesale. Asserting on HTTP status codes works for REST, is nearly useless for GraphQL, and does not apply to gRPC. Each protocol signals failure its own way, and a suite that checks the wrong signal passes on real failures.
Testing only the protocol you added most recently. Services frequently expose two surfaces — gRPC internally and REST or GraphQL externally, often with transcoding between them. The paths do not always enforce the same authorization, and the untested one is usually the older one.
Assuming the schema removes the need for negative tests. A typed schema constrains shape, not meaning. It will not stop a quantity of -5, a date in the wrong era, or an identifier belonging to another tenant.
Letting the protocol decide the test pyramid. The right proportion of contract, integration and end-to-end tests follows from your architecture and risk, not from whether you chose GraphQL. The protocol changes what each layer asserts, not how many of each you need.
Ignoring the client. All three protocols generate or shape client code, and a surprising share of production incidents are client-side handling of a correct response — an unhandled partial GraphQL result, an ignored gRPC status, a REST client that treats every non-200 identically.
Choosing a protocol on testing grounds alone. Testing cost is a real input, but it is rarely the deciding one — latency budgets, client diversity, streaming needs and team familiarity usually matter more. The useful version of this comparison is not "which is easiest to test" but "given the protocol we need, where should the testing effort go", which is what the table above answers.
Protocol testing guides and tools
Each protocol has a dedicated guide in this hub:
- How to test GraphQL APIs: errors arrays, depth and complexity limits, field-level authorization.
- SOAP API testing (WSDL): envelopes, faults, XPath and WSDL drift.
- gRPC testing tools: clients, harnesses and contract checks for protobuf services.
- WebSocket testing tools: connection lifecycle and message ordering.
- JSON-RPC 2.0 testing and MCP vs REST APIs for testers.
- How to test MCP servers and MCP server security testing.
What Shift-Left API covers. Shift-Left API tests REST, SOAP and GraphQL on every plan, and JSON-RPC 2.0 and MCP servers and WebSocket-RPC on Trial and Enterprise, generating tests from an OpenAPI spec, a WSDL or a GraphQL schema. It does not test gRPC; for gRPC services use the tools in the gRPC guide above. To try it on REST, SOAP or GraphQL, start with a free developer licence.
REST vs GraphQL vs gRPC testing FAQ
Why does a failing GraphQL request still return 200?
Because transport succeeded. GraphQL reports application-level problems in an errors array inside the response body, so a test that only asserts on the HTTP status will pass while the query failed. Every GraphQL test needs to assert that errors is absent.
What replaces OpenAPI for GraphQL and gRPC?
The GraphQL SDL schema and the protobuf .proto files respectively. Both are machine-readable contracts, so schema diffing and generated clients work the same way OpenAPI tooling does for REST.
Which is hardest to test?
gRPC, usually — not because the protocol is hard, but because tooling and observability are thinner, streaming needs different assertions, and you often need the proto files or server reflection to call anything at all.
What is unique to GraphQL security testing?
Query depth and complexity limiting, batching abuse, and introspection being left enabled in production. None of these have a REST equivalent and all three are trivially exploitable when missing.
How is testing a SOAP API different from REST?
SOAP sends XML envelopes to one endpoint per service, described by a WSDL. Failures usually arrive as HTTP 500 with a SOAP Fault, so tests assert on the fault code and detail rather than the status alone, use XPath instead of JSON paths, and diff the WSDL to catch breaking changes.
What is unique to gRPC contract testing?
Protobuf's compatibility rules — field numbers are the contract, not field names. Renaming a field is safe; reusing or changing a field number is a breaking change that no runtime error will announce.
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.
- W3C SOAP 1.2 Part 1 and WSDL 1.1 — the SOAP envelope, fault and service-description formats.
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.
- SOAP adds XML envelopes, namespaced XPath and SOAP Faults: assert on the fault, not just the HTTP 500.
Continue learning
Go deeper in the Learning Center
Hands-on lessons with runnable code against our live sandbox.
REST is the default API style on the web. Here's what it actually means — stripped of jargon and with runnable examples.
GraphQL lets the client decide what data to fetch. Here's how it works and when it beats REST.
SOAP isn't dead — it runs banks, insurance, and government. Here's how it actually works.
Ready to shift left with your API testing?
Import your OpenAPI spec and get a runnable test suite, in our cloud or on your own infrastructure.