How to Test GraphQL APIs with Bruno: Queries, Mutations & Assertions

Picture of Anthony Dombrowski
How to Test GraphQL APIs with Bruno: Queries, Mutations & Assertions

 

Here's a fact that catches almost everyone the first time: a GraphQL query can fail while the API still responds with 200 OK. The request didn't error out at the network level, so the status code looks perfectly healthy. But the actual problem (a resolver that threw, a null where there shouldn't be one, a permission you don't have, a malformed request) is tucked away inside the response body. If you test GraphQL the way you'd test a typical REST endpoint ("did I get a 200?"), you'll wave broken responses right through.

That quirk is a big reason why GraphQL deserves its own testing playbook. The good news is that once you know what to look for, testing GraphQL is genuinely pleasant, and arguably more predictable than REST, because the schema tells you exactly what to expect.

{
  "data": null,
  "errors": [
    {
      "message": "You must be logged in to do that.",
      "extensions": { "code": "UNAUTHENTICATED" }
    }
  ]
}

The HTTP status is still 200 OK, so a status-only check passes right over this. The real failure lives in the errors array. The lesson that runs through this whole guide: test the body, not just the status line.

In this guide we'll walk through it hands-on: setting up your first GraphQL request, writing assertions that actually catch failures, working with variables and environments, testing mutations, checking error handling, and finally running the whole thing in CI. We'll do all of it in Bruno, a fast, open-source API client where your requests live as plain files right alongside your code.

Why GraphQL Testing Is a Little Different

If your instincts come from REST, a few of them will lead you astray with GraphQL. It's worth spotting the main differences up front, because every technique later in this guide comes back to these.

REST: many endpoints

Requests are routed by URL and method:

  • GET /users
  • POST /orders
  • DELETE /sessions/42

GraphQL: one endpoint

Every operation is a POST to one URL; the query body says what you want:

  • query users
  • mutation order
  • mutation logout

There's usually just one endpoint. Instead of GET /users, POST /orders, and DELETE /sessions/42, a GraphQL API typically exposes a single URL that you always POST to. You can't tell requests apart by their path or method. What you asked for lives entirely in the query body, so "which operation is this?" becomes a question about the payload, not the URL.

data and errors can show up together. A GraphQL response can partially succeed. You might get the fields that resolved fine and an errors entry for the one that didn't, all in the same payload. Your tests need to handle "some data plus some errors," not just "success or failure."

Errors hide in the response body, not the status code. A query can throw an error and still come back 200, with the real problem tucked into an errors array. (Some failures, like a malformed query, do get a 4xx instead, which we'll see shortly.) Either way, the status alone won't tell you whether the query worked. Checking only the status is the single most common GraphQL testing mistake.

The schema is strongly typed and introspectable. On the upside, a GraphQL API can describe itself. You can ask it what types, queries, and mutations exist and validate your requests against that. Instead of guessing the response shape, you can lean on the schema.

Here's the quick mental translation:

REST reflex GraphQL reality What to test
Route tests by URL and method One endpoint, always POST The operation in the query body
A 4xx status means failure Many failures still return 200, with an errors array in the body Presence/absence of errors
Response is either data or an error data and errors can coexist Both, together
Unknown response shape The schema declares it The shape, against the schema

If your team is already thinking about catching breaking changes over time, this pairs naturally with API contract testing. GraphQL's schema gives you a lot to assert against.

Setting Up Your First GraphQL Request

Let's get something running. You won't need to build an API to follow along. We'll use a free, public GraphQL API (the Rick and Morty API is a great one: no signup, no key, just data about the show's characters, locations, and episodes). Nothing here is specific to that API, though. Any public GraphQL endpoint should work similarly, so if this one is ever unavailable, pick another from this list and follow along with identical steps.

Open Bruno and create a new collection, which is just a folder for a related set of requests. Add a new request inside it and set its type to GraphQL. Point it at:

https://rickandmortyapi.com/graphql

GraphQL requests are sent as an HTTP POST, so make sure the method selector in Bruno shows POST (that's the default for a GraphQL request).

A couple of things are worth noticing here, especially if you're new to Bruno. First, that request you just made is saved as a plain text file on your machine: human-readable YAML that sits in the collection's folder. There's no account to create and nothing syncing to someone else's cloud in the background. Because it's just a file, you can drop your collection into your Git repo, open a pull request, and review API changes in a diff the same way you review code. Your requests travel with your project instead of living in a separate silo.

Second, GraphQL requests in Bruno have two separate panes, one for your query and one for variables. Keeping them separate keeps things tidy and mirrors how GraphQL typically works: the query is the fixed shape, the variables are the values you swap in.

One of the nicest parts of working with GraphQL in Bruno is schema introspection. When your API supports it, Bruno can read the schema and give you autocomplete and inline validation as you type. You can load and refresh introspection from the request's three-dot context menu. Once loaded, start typing a field name and it'll suggest what's actually available, so you catch a typo before you ever hit send.

Getting an empty response or a 204 No Content? First, confirm the request method is POST. A GraphQL query travels in the body of a POST, so if the method is set to GET or anything else, the server receives no query and hands back an empty response. Second, some servers only accept requests carrying a Content-Type: application/json header, which you can add from the request's Headers tab. Bruno normally sets both of these for you, but they're the first two things to check when a request comes back empty.

Let's send a first query. Paste this into the query pane:

query GetCharacter {
  character(id: 1) {
    name
    status
    species
    origin {
      name
    }
  }
}

Hit send, and you'll get back something like:

{
  "data": {
    "character": {
      "name": "Rick Sanchez",
      "status": "Alive",
      "species": "Human",
      "origin": { "name": "Earth (C-137)" }
    }
  }
}

That's the whole loop. Now let's make it trustworthy with some tests.

Testing Queries: Assertions That Actually Catch Failures

Sending a request and eyeballing the result is fine for exploring. But a test is a promise you can re-run. It should fail loudly when something's wrong, without you having to read the response by hand. For GraphQL, a good test checks the three layers below. The middle one is the layer people most often skip, and leaving it out is exactly how a broken query sneaks past a green checkmark.

1

Transport res.getStatus()

The request reached the server and returned status 200. Necessary, but never enough on its own.

2

No errors body.errors

The response has no errors array. This is the check most people forget.

3

Shape & values body.data.*

The data under data has the right fields, types, and expected values.

Bruno gives you two ways to write these checks. For quick, declarative checks there's an Assert tab where you fill in a field path, an operator, and an expected value. For anything with a bit of logic, there's a Tests tab where you write JavaScript using a familiar expect style. We'll use the Tests tab here because GraphQL's "check the errors array" logic reads more clearly in code. Add this to your request's Tests tab:

test("transport: request succeeded at the HTTP level", function () {
  expect(res.getStatus()).to.equal(200);
});

test("no errors: the response has no GraphQL errors", function () {
  const body = res.getBody();
  expect(body.errors).to.be.undefined;
});

test("shape & values: character data is present and correct", function () {
  const character = res.getBody().data.character;
  expect(character).to.be.an("object");
  expect(character).to.have.property("name", "Rick Sanchez");
  expect(character).to.have.property("status");
  expect(character.status).to.be.a("string");
});

Notice the second test. It does the work a status code can't: res.getStatus() can return 200 even when the query didn't really succeed, but expect(body.errors).to.be.undefined fails the moment the API reports a problem in the errors array. As you'll see next, though, it still takes all three checks together to be sure a query really worked.

Want to see why one green check isn't enough? Change the id in your query from 1 to a number that doesn't exist, like 9999, and send again. There's no such character, so the API returns data.character as null, with a 200 OK and no errors array at all. Your transport and "no errors" checks both stay green, yet you clearly didn't get a character back. That's exactly what the "shape & values" test is for: it expects character to be a real object with a real name, so it fails loudly here.

Not every failure looks the same, though. Try a different break: change a field name, say name to naem, and send again. This time the server rejects the malformed query up front with a 400 and a GRAPHQL_VALIDATION_FAILED code, before it runs anything. The status code is sometimes 200 and sometimes 4xx, so you can't route your test logic on it. The one signal that's reliable in both cases is the errors array, which is exactly why the second test checks it directly.

A quick note on status codes: it's tempting to write expect(res.getStatus()).to.equal(200) and call it a day. As we just saw, that assertion is neither reliable (some failures come back 4xx) nor sufficient (some come back 200 with the error hidden in the body). Keep it as a basic sanity check if you like, but never let it be your only check.

Using Variables and Environments

Hardcoding 1 into the query works for one test, but you'll quickly want to reuse the same query with different inputs. That's what GraphQL variables are for. Rewrite the query to declare a variable, then supply its value in the variables pane:

query GetCharacter($id: ID!) {
  character(id: $id) {
    name
    status
  }
}
{
  "id": "1"
}

Same result, but now the query is a reusable template. Change the value in the variables pane and you're testing a different character without touching the query.

The next step up is environments. Most APIs run in more than one place (your local machine, a staging server, production), and each has its own base URL and credentials. Rather than editing your request every time you switch, you define an environment for each and store the values that change as environment variables. You reference an environment variable with double curly braces, so your URL becomes:

{{baseUrl}}

And in a query or header you might use {{token}} for auth. Now switching from staging to local is a single dropdown change, and every request in the collection follows along. Because environments are stored as files too, your team shares the same setup through the repo (while keeping secrets out of version control).

Auth is its own rabbit hole, and GraphQL doesn't change how it works. You're still attaching a token or credential to the request. If you want the full picture, we've got a handbook on API authentication and a deep dive on OAuth 2.0.

Testing Mutations and Chaining Requests

Queries read data. Mutations change it by creating, updating, and deleting records. Testing them takes a little more care, because a mutation can hand back a perfectly nice-looking response and still not have actually persisted anything. So the reliable way to test a mutation isn't just to check what it returns. It's to prove the side effect: make the change, then go look to confirm it really happened.

STEP 1

Create

Run a mutation to add a record

STEP 2

Capture

Save the returned id with bru.setVar()

STEP 3

Confirm

Query that id back and assert it exists

STEP 4

Clean up

Delete the test record

Heads-up before you run these: the public API we've been using is read-only. If you point a mutation at it, you'll get back Schema is not configured for mutations, and that's expected. Most public demo APIs don't allow writes. Mutations are something you test against an API you control, like your app's local or staging server. So treat the requests below as the pattern to use there rather than something to run against the demo endpoint. We'll model them on a simple to-do API.

Step 1: Run the mutation

Create a record:

mutation CreateTodo($title: String!) {
  createTodo(title: $title) {
    id
    title
    completed
  }
}

In the variables pane, feed the title in from a variable so the test can check the response against the same value you sent:

{
  "title": "{{newTodoTitle}}"
}

Set newTodoTitle in your environment (or a pre-request script with bru.setVar("newTodoTitle", "Buy milk")) so both the request and the test below refer to one source of truth.

Step 2: Capture the returned id

In the Tests tab, save the new record's ID into a runtime variable so the next request can use it:

test("mutation created a todo and returned it", function () {
  const body = res.getBody();
  expect(body.errors).to.be.undefined;

  const todo = body.data.createTodo;
  expect(todo).to.have.property("id");
  expect(todo.title).to.equal(bru.getVar("newTodoTitle"));
  expect(todo.completed).to.equal(false);

  // hand the id to the next request in the run
  bru.setVar("createdTodoId", todo.id);
});

Step 3: Confirm it persisted

In a second request, query that ID back and assert the record is really there:

query GetTodo($id: ID!) {
  todo(id: $id) {
    id
    title
  }
}
{
  "id": "{{createdTodoId}}"
}
test("the created todo can be read back", function () {
  const body = res.getBody();
  expect(body.errors).to.be.undefined;
  expect(body.data.todo).to.not.be.null;
  expect(body.data.todo.id).to.equal(bru.getVar("createdTodoId"));
});

Step 4: Clean up

Fire a delete mutation for that same ID so your test doesn't leave litter behind. Run these in order and you've verified the whole lifecycle end to end.

A heads-up on write tests: these create and delete real data, so run them against a test or local environment, never production, and always pair a create with a matching cleanup so repeated runs stay tidy.

One subtle detail worth knowing: GraphQL runs the fields in a query in parallel, but the top-level fields in a mutation run one after another, in order. That's a deliberate part of the spec, and it keeps writes predictable when you send several in one request.

Testing GraphQL Error Handling

Most tutorials stop at the happy path. But a big part of trusting an API is knowing it fails correctly: clear message, right error code, no leaking of things it shouldn't. Since GraphQL puts all of that in the errors array, it's very testable once you know the shape. A GraphQL error entry generally looks like this:

{
  "errors": [
    {
      "message": "You must be logged in to do that.",
      "path": ["createTodo"],
      "extensions": { "code": "UNAUTHENTICATED" }
    }
  ]
}
Field What it is Assert on it?
message Human-readable text Handy, but wording changes
path Which field failed Useful for locating the error
extensions.code Machine-readable code Yes, it's stable

The two most useful things to assert on are message (human-readable) and extensions.code (machine-readable, and far more stable to test against). Deliberately trigger each kind of failure and confirm the API responds the way it should:

test("rejects an unauthenticated write with the right code", function () {
  const body = res.getBody();

  // this SHOULD have errors, that's the point of the test
  expect(body.errors).to.be.an("array").that.is.not.empty;
  expect(body.errors[0].extensions.code).to.equal("UNAUTHENTICATED");
});

A few error cases worth covering:

  • Validation errors: a mistyped field or a variable of the wrong type. The API should reject the request before it even runs, typically with a GRAPHQL_VALIDATION_FAILED code.
  • Execution errors: a resolver throws, or returns null for a field that's declared non-nullable. Check the message is helpful and the path points at the right field.
  • Auth errors: a request without the right permissions. Assert on the code (like UNAUTHENTICATED or FORBIDDEN) rather than the exact wording, which tends to change.

Note how this flips the earlier rule on its head. For happy-path tests you assert errors is undefined; for these negative tests you assert it exists and carries the code you expect. Same field, opposite expectation, and together they prove the API behaves in both directions.

Running Your GraphQL Tests in CI

Tests that only run when you remember to click "send" don't protect you for long. The real payoff comes when they run automatically on every change.

Write locally

Build requests and tests in Bruno

Commit to Git

Files version and review like code

CI runs it

bru run on every change

Pass → merge

Fail → blocked

Because your Bruno collection is just a set of files in your repo, this step is refreshingly direct. There's nothing to export or re-import. The Bruno command-line runner executes the exact same collection you've been building, straight from the terminal:

npm install -g @usebruno/cli

# run every request (and its tests) in the collection, against staging
bru run --env Staging

From there you can wire it into your pipeline. There's an official Bruno Docker image and a GitHub Action so you can run your GraphQL tests on every pull request and block a merge if they fail, and you can output JUnit or HTML reports for your CI dashboard. We covered the setup in detail in Official Bruno Docker Image and GitHub Action. That's the place to go for the full pipeline configuration.

The theme running through all of this: because your requests, tests, and environments are plain files that live with your code, the same collection works on your laptop, in code review, and in CI, with no separate copy that drifts out of sync.

The GraphQL Testing Checklist

Keep this handy when you're writing or reviewing GraphQL tests:

Never trust the status code alone. A 200 proves the request arrived, not that it worked.

Always check the errors array. Assert it's absent on happy paths, and present (with the right code) on negative tests.

Validate the shape, not just presence. Check field types and expected values under data, not merely that a field exists.

Parameterize with variables and environments. No hardcoded IDs, URLs, or secrets in the query.

Test mutations by their side effect. Create, then read the record back to confirm it truly persisted.

Cover the error paths on purpose. Trigger validation, execution, and auth failures and assert on extensions.code.

Keep secrets out of version control. Reference them as variables; don't commit real tokens.

Run it all in CI. Version your collection with your code and let the pipeline run it on every change.

Wrapping Up

The whole shift with GraphQL testing fits in one sentence: test the body, not the status line. Once that clicks, the rest follows naturally: check for an errors array, assert on the data's shape, prove your mutations with a follow-up query, and confirm your API fails gracefully when it should.

None of this has to be heavy. In Bruno you can build the whole suite as plain files that live in your repo, run them locally while you work, and hand the exact same collection to CI, with no accounts, no cloud lock-in, and no separate copy to keep in sync.

Ready to try it? Download Bruno, point a GraphQL request at a public API, and write your first three-layer test. You'll have a real safety net in about ten minutes.

Test the body, not the status line, and GraphQL testing gets a whole lot more trustworthy.

Related posts