API Protocols: GraphQL, SOAP, WebSocket, MCP

REST vs GraphQL vs gRPC Testing (and Where SOAP Fits)

Smeet GohelUpdated Aug 20, 202618 min read

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

Share:
Three columns comparing REST, GraphQL and gRPC by transport, where a failure surfaces, and what a test has to assert on for each.

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

  1. What changes across the three
  2. The same check, three ways
  3. The failure modes each one adds
  4. What stays the same
  5. Test data and mocking across the three
  6. Observability differences that affect debugging
  7. Where each protocol wants its tests
  8. What carries over when the protocol changes
  9. Where SOAP fits
  10. Which protocol to choose, and migrating between them
  11. Common mistakes across all three
  12. Protocol testing guides and tools
  13. FAQ

What changes across the three

DimensionRESTGraphQLgRPC
Contract formatOpenAPI documentSDL schema.proto files
TransportHTTP/1.1 or HTTP/2HTTP, usually one POST endpointHTTP/2
PayloadJSON (usually)JSONProtobuf binary
Success signalHTTP status codeHTTP 200 plus an absent errors arraygRPC status code
Partial failureRareNormal — data and errors togetherNo
Endpoint surfaceMany paths and methodsUsually one endpoint, many operationsMany methods on many services
Breaking-change unitPath, method, schema fieldType and field in the schemaField number, not field name
StreamingSSE or WebSocket, separatelySubscriptionsFirst class (uni- and bidirectional)
Human-readable on the wireYesYesNo — needs the schema to decode
Fuzzing / generationMature (Schemathesis, Dredd)Good (Schemathesis, graphql-cop)Thinner
Unique security surfaceBOLA, mass assignment, rate limitsQuery depth, complexity, batching, introspectionReflection 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.

RESTGraphQLgRPC
Schema for generationOpenAPI componentsSDL typesprotobuf messages
Generatorjson-schema-faker, Fakergraphql-faker, schema-drivenprotobuf-faker, or hand-built factories
Mock serverPrism, WireMockApollo Server mocks, graphql-fakergrpc-mock, or a stub implementation
Contract-derived mockYes, from OpenAPIYes, from SDLYes, 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.

LayerRESTGraphQLgRPC
Contract / schemaHigh value — the spec is the contractHighest value — schema diffing catches most breakageMostly free — .proto mismatches fail at build
Field-level authorizationPer endpointHighest value — one route, many fieldsPer method, via metadata
Request validationHigh — hand-built payloads vary wildlyLower — the schema constrains inputLow — types are enforced by generated code
Serialization edge casesMedium — nulls, dates, numbersMediumLow, except around optional and defaults
Streaming / terminationNot applicableSubscriptions onlyHighest 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 Free
REST
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 concernRESTGraphQLSOAP
ContractOpenAPI (optional in practice)SDL schema (required)WSDL (required)
Endpoint modelMany paths and methodsOne endpointOne endpoint per service, many operations
Success / failure signalHTTP status200 plus errors arrayHTTP 500 plus a SOAP Fault for errors
Shape assertionJSON pathJSON path on dataXPath, with namespaces
Negative cases400/401/403/404/409/422errors[].extensions.codeFault code and fault detail
Contract driftOpenAPI diffSchema diffWSDL diff
Typical performance bugSlow endpointsN+1 resolversLarge 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:

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

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 errors is 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.

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.