TestingAPIs is still being built. If something breaks, email support@testingapis.com.

Reading the schema

Types, the exclamation mark, lists, and asking the server to describe itself.

Every GraphQL API has a schema: a list of every type, every field on each type, and the type of each field. The server checks each query against it before running anything, which is why a typo in a field name gets a precise error instead of a silent null. For a tester, the schema is the closest thing to a complete specification you're likely to get. This lesson is about reading it.

Reading a type

Schemas are usually written in a short notation called SDL, the schema definition language. Here is the Book type on this API:

Schema
type Book {
  id: Int!
  title: String!
  author: String!
  isbn: String!
  genre: String!
  publishedYear: Int!
  pages: Int!
  language: String!
  price: Decimal!
  rating: Float!
  available: Boolean!
  tags: [String!]!
}

Each line is a field name, a colon and a type. Int, Float, String and Boolean are built into GraphQL, along with ID, which this API doesn't use. The rest are defined by the API. Decimal and Longare custom scalars here: a decimal number for money and a 64-bit integer for big counts like a film's box office takings.

Custom scalars need care, because the schema only gives the name. It doesn't say how the value is written in JSON. On this API both are plain JSON numbers ("price": 37.99), so each one turns into a JavaScript Numberwhen parsed, which is a 64-bit float. That's fine for 37.99 and for a box office of 58,300,000. It isn't fine for a Long above 9,007,199,254,740,991, where JavaScript starts rounding. If an API you test has Long fields that can grow that large, send one through and compare the digits.

The exclamation mark

A ! after a type means the value can never be null. Without it, null is allowed. That single character decides a lot, so here is exactly what it promises.

TypeCan the field be null?Can items in the list be null?
StringYesNot a list
String!NoNot a list
[String]YesYes
[String!]YesNo
[String]!No, but it can be an empty listYes
[String!]!No, but it can be an empty listNo

The tags on a book are [String!]!: always a list, never null, and every tag a real string. An empty list is still allowed. A test for tags should accept [] and should fail on null or [null].

Nullable fields are where the interesting cases are. Look at the Address type:

Schema
type Address {
  street: String!
  city: String!
  state: String!
  zip: String!
  country: String!
  geo: GeoLocation
}

Every field is required except geo. On this API, every employee in the Engineering department comes back with "geo": null. A client that reads address.geo.lat without checking will crash on the first one. When you see a nullable field, find a record where it actually is null and check that the app copes.

Arguments have types too

Entry points are fields on a special type called Query:

Schema
type Query {
  book(id: Int!): Book
  books(genre: String): [Book!]!
  country(code: String!): Country
  employees(department: String): [Employee!]!
  # ...and 16 more
}

Read the two book fields closely. book(id: Int!): Book takes a required integer and returns a Bookthat may be null. That's how not-found is expressed here: ask for book 999 and you get null, with no error. books(genre: String): [Book!]! takes an optional string and always returns a list. A filter that matches nothing returns [], never null.

These promises are checked by the server on the way out. If a resolver tried to return null for a ! field, the server would replace the null with an error and null out the parent object instead. The errors lesson shows that happening.

Asking the server for its schema

GraphQL servers can describe themselves. Fields starting with two underscores, like __schema and __type, return the schema as data. This is called introspection, and it's how IDEs offer autocomplete and how tools generate client code.

Query
{
  __type(name: "Book") {
    name
    fields {
      name
      type { kind name ofType { kind name } }
    }
  }
}
Response (trimmed)
{
  "data": {
    "__type": {
      "name": "Book",
      "fields": [
        {
          "name": "id",
          "type": {
            "kind": "NON_NULL",
            "name": null,
            "ofType": { "kind": "SCALAR", "name": "Int" }
          }
        },
        {
          "name": "title",
          "type": {
            "kind": "NON_NULL",
            "name": null,
            "ofType": { "kind": "SCALAR", "name": "String" }
          }
        },
        ...
      ]
    }
  }
}
POST/graphql
{"query":"{ __type(name: \"Book\") { name fields { name type { kind name ofType { kind name } } } } }"}
This request uses an endpoint for signed-in members.Sign inCreate a free account

Int! appears as a NON_NULL wrapper around the Int scalar, which is why the query has to ask for ofType. A list of non-null strings is a NON_NULL around a LIST around another NON_NULL around String, three levels deep. You rarely read this by hand. Tools turn it back into SDL.

What the schema doesn't tell you

A schema says what shape the data has. It says very little about which values are allowed. Adding a todo on this API takes a priority of type String!, so as far as the schema is concerned, any string will do. Send "urgent" and the server says otherwise:

Response (200 OK)
{
  "errors": [
    {
      "message": "Field 'priority' must be one of: high, medium, low.",
      "locations": [{ "line": 1, "column": 12 }],
      "path": ["addTodo"]
    }
  ],
  "data": null
}

The real rule (high, medium or low) exists only in the server code. It doesn't show in the IDE, a code generator can't know it, and you only find it by sending a bad value. Had the field been declared as an enum, the schema itself would carry the rule:

Schema (with an enum)
enum Priority {
  HIGH
  MEDIUM
  LOW
}

type Mutation {
  addTodo(title: String!, userId: Int!, priority: Priority!): Todo!
}

Then "urgent" would be rejected during validation, like any other type error, and every client would see the three options. When you find a Stringfield that only accepts a handful of values, raise it. It isn't a bug, but it is a gap in the contract.

Relationships are another gap on this particular API. A Post has a userId, but no author field that returns the user, so a client has to make a second query to fetch the author. Many GraphQL APIs do link types to each other, and on those the schema is also a map of how deep a query can go. That matters for performance, as lesson 8 explains.

The schema as a contract

Because the schema is a file, you can keep a copy and compare it with the next release. The changes that break existing clients are fairly predictable:

  • A field or type is removed or renamed. Every query that asks for it now fails validation.
  • A field's return type loses its !. Clients that never checked for null start crashing.
  • An argument becomes required, or a new required argument is added.
  • An argument's type changes, say from Int to String.
  • A value is removed from an enum that clients send or receive.

Adding a new type, a new field or a new optional argument is safe, because no existing query asks for it. Tools such as GraphQL Inspector compare two schemas and sort the changes into breaking and safe. Running one in CI against the schema of the release you're replacing catches most accidental breaks before anyone writes a test for them. The REST version of this idea is in schemas and contract testing.

Exercise

Use introspection to find the type of the managerId field on Employee and the boxOffice field on Movie. Which one can be null? Then query all employees and find one whose managerIdreally is null. What does that person's job title suggest about why?

Practise and go further