Testing GraphQL Queries and Mutations with Vitest

Testing GraphQL Queries and Mutations with Vitest

GraphQL APIs have a different failure surface than REST. A resolver can return the wrong shape. A mutation can succeed at the transport layer but fail to persist. Error responses are embedded in a 200 OK body under an errors key. Variables can coerce silently, or fail loudly, depending on schema configuration.

Standard HTTP testing doesn't catch most of this. You need tests that understand the GraphQL execution model — resolvers, the schema, the context object, and how variables flow through a query document.

This post covers how to test all of that with Vitest: unit testing individual resolvers, validating variable handling, testing mutation side effects, asserting on error responses, and integration testing against a real server instance.


Resolver Unit Testing

A resolver is just a function. It takes (parent, args, context, info) and returns a value or a promise. Testing it in isolation means calling it directly — no HTTP, no schema execution, no network.

Consider a resolver that fetches a user by ID:

// resolvers/userResolver.ts
import { db } from '../db'

export const userResolver = {
  Query: {
    user: async (_parent: unknown, args: { id: string }, context: { userId: string }) => {
      if (!context.userId) {
        throw new Error('Unauthorized')
      }
      return db.users.findById(args.id)
    }
  }
}

Testing this directly with Vitest:

// resolvers/userResolver.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { userResolver } from './userResolver'
import { db } from '../db'

vi.mock('../db', () => ({
  db: {
    users: {
      findById: vi.fn()
    }
  }
}))

describe('userResolver.Query.user', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('returns a user when context has userId', async () => {
    const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com' }
    vi.mocked(db.users.findById).mockResolvedValue(mockUser)

    const result = await userResolver.Query.user(
      null,
      { id: '1' },
      { userId: 'requester-123' }
    )

    expect(result).toEqual(mockUser)
    expect(db.users.findById).toHaveBeenCalledWith('1')
  })

  it('throws when context.userId is missing', async () => {
    await expect(
      userResolver.Query.user(null, { id: '1' }, { userId: '' })
    ).rejects.toThrow('Unauthorized')

    expect(db.users.findById).not.toHaveBeenCalled()
  })
})

This pattern keeps resolver tests fast. No server setup, no query parsing, no schema validation overhead. The mock lets you assert on what the resolver actually called — not just what it returned.

For resolvers with complex context objects, define a factory:

// test-utils/makeContext.ts
export function makeContext(overrides: Partial<AppContext> = {}): AppContext {
  return {
    userId: 'test-user',
    role: 'user',
    dataLoaders: createTestLoaders(),
    ...overrides
  }
}

Use it across all resolver tests to avoid diverging context shapes.


Testing Query Variables and Arguments

Variables are validated by the GraphQL runtime before resolvers run. If a required variable is missing or the wrong type, the request errors before touching your code. But argument-level logic — default values, optional fields, custom scalars — lives in the resolver and needs explicit coverage.

Set up a lightweight test executor using graphql-js directly:

// test-utils/execute.ts
import { graphql, buildSchema } from 'graphql'
import { makeExecutableSchema } from '@graphql-tools/schema'
import { typeDefs } from '../schema'
import { resolvers } from '../resolvers'

export const schema = makeExecutableSchema({ typeDefs, resolvers })

export async function execute(
  query: string,
  variables?: Record<string, unknown>,
  contextValue?: unknown
) {
  return graphql({
    schema,
    source: query,
    variableValues: variables,
    contextValue: contextValue ?? { userId: 'test-user' }
  })
}

Now test variable handling at the schema execution level:

// schema.test.ts
import { describe, it, expect } from 'vitest'
import { execute } from './test-utils/execute'

const GET_USER = `
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
    }
  }
`

describe('query variables', () => {
  it('executes with valid variables', async () => {
    const result = await execute(GET_USER, { id: '1' })
    expect(result.errors).toBeUndefined()
    expect(result.data?.user).toHaveProperty('id')
  })

  it('returns a variable coercion error when id is missing', async () => {
    const result = await execute(GET_USER, {})
    expect(result.errors).toBeDefined()
    expect(result.errors![0].message).toMatch(/id/)
  })

  it('handles optional arguments with defaults', async () => {
    const LIST_USERS = `
      query ListUsers($limit: Int) {
        users(limit: $limit) {
          id
        }
      }
    `
    // No limit variable — resolver should apply default
    const result = await execute(LIST_USERS, {})
    expect(result.errors).toBeUndefined()
    expect(result.data?.users).toBeInstanceOf(Array)
  })
})

For custom scalars like Date or JSON, test coercion explicitly:

it('coerces ISO date string to Date scalar', async () => {
  const result = await execute(
    `query { eventsBefore(date: "2024-01-01T00:00:00Z") { id } }`,
    {}
  )
  expect(result.errors).toBeUndefined()
})

it('rejects invalid date format', async () => {
  const result = await execute(
    `query { eventsBefore(date: "not-a-date") { id } }`,
    {}
  )
  expect(result.errors).toBeDefined()
})

This tests the boundary between your custom scalar's parseValue/parseLiteral implementation and the resolver — which pure resolver tests can't reach.


Testing Mutation Side Effects

Mutations should be tested for two things: the return value and the side effect. A mutation that returns the correct shape but fails to write to the database is broken. A mutation that persists correctly but returns stale data is also broken.

// resolvers/createPostResolver.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mutationResolvers } from './mutationResolvers'
import { db } from '../db'
import { eventBus } from '../events'

vi.mock('../db')
vi.mock('../events')

describe('Mutation.createPost', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('persists the post and returns it', async () => {
    const input = { title: 'Hello', body: 'World', authorId: 'user-1' }
    const saved = { id: 'post-99', ...input, createdAt: new Date().toISOString() }

    vi.mocked(db.posts.create).mockResolvedValue(saved)
    vi.mocked(eventBus.emit).mockResolvedValue(undefined)

    const result = await mutationResolvers.createPost(
      null,
      { input },
      { userId: 'user-1' }
    )

    expect(db.posts.create).toHaveBeenCalledWith(
      expect.objectContaining({ title: 'Hello', authorId: 'user-1' })
    )
    expect(result).toEqual(saved)
  })

  it('emits a post.created event after successful creation', async () => {
    const input = { title: 'Test', body: 'Content', authorId: 'user-1' }
    const saved = { id: 'post-42', ...input, createdAt: new Date().toISOString() }

    vi.mocked(db.posts.create).mockResolvedValue(saved)
    vi.mocked(eventBus.emit).mockResolvedValue(undefined)

    await mutationResolvers.createPost(null, { input }, { userId: 'user-1' })

    expect(eventBus.emit).toHaveBeenCalledWith('post.created', { postId: 'post-42' })
  })

  it('does not emit event when db write fails', async () => {
    vi.mocked(db.posts.create).mockRejectedValue(new Error('DB timeout'))

    await expect(
      mutationResolvers.createPost(null, { input: { title: 'Fail' } }, { userId: 'user-1' })
    ).rejects.toThrow('DB timeout')

    expect(eventBus.emit).not.toHaveBeenCalled()
  })
})

The third test is the critical one. If your mutation catches the DB error, emits the event anyway, and then re-throws — your event consumers will process an item that was never saved. This kind of bug is invisible without explicit side-effect assertions.

For mutations that modify existing records, assert the shape of the update call:

it('only updates provided fields (partial update)', async () => {
  vi.mocked(db.posts.update).mockResolvedValue({ id: '1', title: 'New Title', body: 'Old Body' })

  await mutationResolvers.updatePost(null, { id: '1', input: { title: 'New Title' } }, { userId: 'user-1' })

  expect(db.posts.update).toHaveBeenCalledWith('1', { title: 'New Title' })
  // body should NOT be in the update call
  expect(db.posts.update).not.toHaveBeenCalledWith('1', expect.objectContaining({ body: expect.anything() }))
})

Error Response Testing

GraphQL errors come in two forms: execution errors (resolver throws) and field errors (null propagation through non-null types). Both end up in the errors array of the response, but they have different shapes and different causes.

Test execution errors through the schema executor, not the raw resolver:

// errors.test.ts
import { describe, it, expect, vi } from 'vitest'
import { execute } from './test-utils/execute'
import { db } from '../db'

vi.mock('../db')

describe('error responses', () => {
  it('returns a GraphQL error on resolver throw', async () => {
    vi.mocked(db.users.findById).mockRejectedValue(new Error('Not found'))

    const result = await execute(`query { user(id: "missing") { id } }`)

    expect(result.errors).toHaveLength(1)
    expect(result.errors![0].message).toBe('Not found')
    expect(result.data).toEqual({ user: null })
  })

  it('exposes path information in errors', async () => {
    vi.mocked(db.users.findById).mockRejectedValue(new Error('DB error'))

    const result = await execute(`query { user(id: "1") { id name } }`)

    expect(result.errors![0].path).toEqual(['user'])
  })

  it('does not expose internal error details to clients', async () => {
    // Your error formatter should mask internal errors
    vi.mocked(db.users.findById).mockRejectedValue(new Error('Internal: connection string exposed'))

    const result = await execute(`query { user(id: "1") { id } }`, {}, { userId: 'guest' })

    // Assuming error masking is in place
    expect(result.errors![0].message).not.toContain('connection string')
  })
})

For testing your error formatting plugin (if using Apollo Server or Yoga):

// errorFormatter.test.ts
import { describe, it, expect } from 'vitest'
import { formatError } from '../plugins/errorFormatter'
import { GraphQLError } from 'graphql'

describe('formatError', () => {
  it('preserves message for user-facing errors', () => {
    const err = new GraphQLError('Email already taken', {
      extensions: { code: 'BAD_USER_INPUT' }
    })
    const formatted = formatError(err, err)
    expect(formatted.message).toBe('Email already taken')
  })

  it('masks unexpected errors in production', () => {
    const original = { message: 'ECONNREFUSED 127.0.0.1:5432' }
    const err = new GraphQLError('Something went wrong')
    const formatted = formatError(err, original)
    expect(formatted.message).toBe('Internal server error')
  })
})

Integration Testing Patterns

Unit tests verify logic. Integration tests verify the full path — HTTP transport, middleware, auth context building, schema execution, and serialization — all together.

For GraphQL Yoga or Apollo Server, spin up a real server in your test file:

// server.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { createYoga } from 'graphql-yoga'
import { createServer } from 'http'
import { schema } from '../schema'

let server: ReturnType<typeof createServer>
let url: string

beforeAll(async () => {
  const yoga = createYoga({ schema })
  server = createServer(yoga)

  await new Promise<void>((resolve) => {
    server.listen(0, () => resolve())
  })

  const address = server.address() as { port: number }
  url = `http://localhost:${address.port}/graphql`
})

afterAll(async () => {
  await new Promise<void>((resolve, reject) => {
    server.close((err) => (err ? reject(err) : resolve()))
  })
})

async function gql(query: string, variables?: Record<string, unknown>, token?: string) {
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {})
    },
    body: JSON.stringify({ query, variables })
  })
  return res.json()
}

describe('GraphQL server integration', () => {
  it('responds to a valid query', async () => {
    const result = await gql(`query { __typename }`)
    expect(result.data.__typename).toBe('Query')
  })

  it('returns 200 with errors array for invalid query', async () => {
    const result = await gql(`query { nonExistentField }`)
    expect(result.errors).toBeDefined()
    expect(result.data).toBeUndefined()
  })

  it('builds auth context from Authorization header', async () => {
    const token = createTestToken({ userId: 'user-1', role: 'admin' })
    const result = await gql(`query { me { id role } }`, {}, token)

    expect(result.errors).toBeUndefined()
    expect(result.data.me.id).toBe('user-1')
    expect(result.data.me.role).toBe('admin')
  })

  it('returns unauthorized error without token', async () => {
    const result = await gql(`query { me { id } }`)
    expect(result.errors[0].extensions.code).toBe('UNAUTHORIZED')
  })
})

Port 0 lets the OS assign a free port — no hardcoded ports, no conflicts in parallel test runs.

For testing subscriptions, use the async iterator protocol directly rather than spinning up a WebSocket server in tests:

it('subscription emits events', async () => {
  const subscription = await schema.subscribe({
    document: parse(`subscription { postCreated { id title } }`),
    contextValue: { userId: 'test-user' }
  })

  // Trigger the event
  eventBus.emit('post.created', { id: '1', title: 'Breaking' })

  const result = await (subscription as AsyncIterableIterator<ExecutionResult>).next()
  expect(result.value.data?.postCreated).toEqual({ id: '1', title: 'Breaking' })
})

DataLoader Testing

DataLoaders batch and cache database calls. Test them in integration to confirm N+1 queries don't reappear after refactoring:

it('batches user lookups for author fields', async () => {
  const spy = vi.spyOn(db.users, 'findManyByIds')

  await execute(`
    query {
      posts {
        author { id name }
      }
    }
  `)

  // All author IDs should be fetched in a single batched call
  expect(spy).toHaveBeenCalledTimes(1)
  const calledWith = spy.mock.calls[0][0] as string[]
  expect(calledWith.length).toBeGreaterThan(1)
})

Key Takeaways

Test resolvers as functions first. They're just functions with a defined signature. Call them directly, mock dependencies, assert on both the return value and what was called. This is the fastest feedback loop you have.

Use graphql-js execute for variable and schema-level testing. Some behavior — variable coercion, non-null propagation, custom scalar parsing — only manifests during schema execution. A resolver unit test won't catch it.

Assert on side effects, not just return values. Mutations that write to databases, emit events, or invalidate caches need explicit assertions on those operations. Checking only the return value misses half the contract.

GraphQL errors live in the response body. A 200 OK with { errors: [...] } is a failed request. Make sure your tests check result.errors and don't just assert on result.data.

Integration tests need a real server instance. Auth context building, middleware, error formatting — these can't be tested through graphql() directly. Port 0 for dynamic port assignment keeps tests parallel-safe and deterministic.

Keep the layers separate. Resolver tests catch business logic bugs fast. Schema tests catch type and variable handling bugs. Integration tests catch wiring bugs. Running all three gives you confidence at every level without redundancy.

Read more

Start now free