123 Testing gqlgen Resolvers with `httptest` and Mocks
123 Testing gqlgen Resolvers with httptest and Mocks
GraphQL is growing increasingly popular in modern backend ecosystems. One of the main reasons is the flexibility and efficiency of its queries. In Golang, gqlgen
offers powerful tools for managing a GraphQL server, one of them being generating resolvers from a schema. But without good testing, we risk introducing bugs and unexpected behavior. So, what is an effective way to test GraphQL resolvers in a gqlgen project? In this article, I’ll share practices for testing gqlgen resolvers: from httptest all the way to mocking dependencies.
Why Testing Resolvers Matters
Before diving into the technical details, let’s discuss why testing resolvers is essential.
| Reason | Brief Explanation |
|---|---|
| Validating Business Logic | Ensures all business logic runs according to requirements, especially when changes are made to a resolver. |
| Safe Refactoring | Guarantees that code changes (refactoring/optimization) do not alter the intended behavior of the resolver. |
| Tested Error Handling | Ensures error scenarios, such as a dependency error or data not being found, are handled correctly. |
| Living Documentation | Test cases serve as living documentation of how a resolver works, especially for engineers new to the project. |
GraphQL Architecture Diagram with gqlgen
Let’s do a quick refresher on the architecture:
flowchart TD
Client -->|GraphQL Query| HTTPHandler
HTTPHandler -->|GraphQL Request| Resolver
Resolver -->|Access| Service
Service -->|Access| DataSource[Database / API / etc]
1. The Client sends a GraphQL Query over HTTP
2. The gqlgen HTTP Handler receives and parses the request
3. The Resolver resolves data by calling the Service
4. The Service fetches data from the DataSource
Case Study: The books Query Resolver
Imagine we have the following schema:
1type Book {
2 id: ID!
3 title: String!
4 author: String!
5}
6
7type Query {
8 books: [Book!]!
9}And its resolver fetches data from a service:
1type BookService interface {
2 GetBooks(ctx context.Context) ([]*Book, error)
3}The resolver implementation:
1func (r *queryResolver) Books(ctx context.Context) ([]*model.Book, error) {
2 return r.BookService.GetBooks(ctx)
3}Testing Challenges
- The resolver depends on a service (BookService)
- We want to test without accessing the DB (unit test isolation)
- We want to test end-to-end (integration between components)
Testing gqlgen Resolvers in Golang
We’ll cover the 3 most effective levels of testing:
- Unit Testing the Resolver with a Mock (Service Isolation)
- Integration Testing the Handler using
httptest - End-to-End Testing (optional, out of scope, but I’ll mention it)
1. Unit Testing the Resolver with a Mock
Focuses on the resolver’s business logic and isolating dependencies.
Simulation: Mocking the Service
We’ll create a mock BookService using stretchr/testify/mock .
1// book_service_mock.go
2type BookServiceMock struct {
3 mock.Mock
4}
5
6func (m *BookServiceMock) GetBooks(ctx context.Context) ([]*model.Book, error) {
7 args := m.Called(ctx)
8 return args.Get(0).([]*model.Book), args.Error(1)
9}Unit Testing the Resolver
1func TestBooksResolver(t *testing.T) {
2 mockSvc := new(BookServiceMock)
3 dummyBooks := []*model.Book{
4 {ID: "1", Title: "Clean Code", Author: "Robert Martin"},
5 {ID: "2", Title: "The Pragmatic Programmer", Author: "Andy Hunt"},
6 }
7 mockSvc.On("GetBooks", mock.Anything).Return(dummyBooks, nil)
8
9 resolver := &Resolver{BookService: mockSvc}
10
11 ctx := context.Background()
12 result, err := resolver.Query().Books(ctx)
13
14 assert.NoError(t, err)
15 assert.Equal(t, dummyBooks, result)
16 mockSvc.AssertExpectations(t)
17}Advantages:
- Very fast, no infrastructure dependencies.
- Isolates the resolver logic.
Disadvantages:
- Doesn’t test serialization (the GraphQL handler).
- Doesn’t test the interaction behavior between handlers.
2. Integration Testing the GraphQL Handler with httptest
Focuses on the entire pipeline: HTTP handler, parsing, serialization, resolver, and service.
1func TestGraphQLBooksQuery(t *testing.T) {
2 // Set up the mock service
3 mockSvc := new(BookServiceMock)
4 expectedBooks := []*model.Book{
5 {ID: "1", Title: "Domain-Driven Design", Author: "Eric Evans"},
6 {ID: "2", Title: "Go in Action", Author: "William Kennedy"},
7 }
8 mockSvc.On("GetBooks", mock.Anything).Return(expectedBooks, nil)
9
10 // Inject the mock into the resolver
11 resolver := &graph.Resolver{BookService: mockSvc}
12
13 // Set up the GraphQL server using the gqlgen handler
14 srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: resolver}))
15
16 // Compose the query
17 query := `{"query": "{ books { id title author } }"}`
18 req := httptest.NewRequest(http.MethodPost, "/query", strings.NewReader(query))
19 req.Header.Set("Content-Type", "application/json")
20 w := httptest.NewRecorder()
21
22 srv.ServeHTTP(w, req)
23
24 resp := w.Result()
25 body, _ := io.ReadAll(resp.Body)
26 assert.Equal(t, http.StatusOK, resp.StatusCode)
27
28 // Parse the JSON response
29 var gqlResp struct {
30 Data struct {
31 Books []model.Book `json:"books"`
32 } `json:"data"`
33 }
34 json.Unmarshal(body, &gqlResp)
35
36 assert.Equal(t, len(expectedBooks), len(gqlResp.Data.Books))
37 for i, b := range gqlResp.Data.Books {
38 assert.Equal(t, expectedBooks[i].ID, b.ID)
39 assert.Equal(t, expectedBooks[i].Title, b.Title)
40 assert.Equal(t, expectedBooks[i].Author, b.Author)
41 }
42 mockSvc.AssertExpectations(t)
43}Flow Overview with mermaid
sequenceDiagram
participant TestClient as Test (httptest)
participant HTTPHandler
participant Resolver
participant BookServiceMock
TestClient->>HTTPHandler: POST /query (GraphQL)
HTTPHandler->>Resolver: resolve Query.books
Resolver->>BookServiceMock: GetBooks(ctx)
BookServiceMock-->>Resolver: Dummy Data
Resolver-->>HTTPHandler: Data
HTTPHandler-->>TestClient: JSON response
Advantages:
- Tests the entire GraphQL handler stack.
- Validates JSON marshalling/unmarshalling.
- Can test more realistic scenarios (error cases, e.g., GetBooks returning an error).
Disadvantages:
- Slower than unit tests.
- The mock needs to be maintained if the interface changes.
3. End-to-End Testing (Optional)
If we want to test integration with other systems (e.g., a real DB, Redis, etc.), end-to-end testing can be done. This usually involves additional tooling such as docker-compose to spin up the entire stack. However, this section is out of scope for this article.
Quick Comparison Table
| Unit Test Resolver | Integration Test (httptest) | |
|---|---|---|
| Speed | Very fast | Medium |
| Isolation | Yes | Partial (Dependency mock) |
| Coverage | Resolver logic | End-to-end handler pipeline |
| Test Data | Dummy/mock | Dummy/mock |
| Realistic | Medium | High |
Best Practices & Tips
- Mock all external dependencies: Use mocks for services so that tests are deterministic.
- Use table-driven tests: To test many scenarios in a single file.
- Test error cases: Make sure there’s a test for when a dependency errors out and returns an error.
- Don’t mix unit and integration tests: Keep them separate for maintainability.
- Use a coverage tool: Check coverage for test quality.
Conclusion
Testing in gqlgen is no longer a nightmare. With a combination of httptest and mocks, we can easily test resolvers in isolation or end-to-end without the hassle of setting up a heavy environment. Start with unit testing the resolver, then move up to integration testing with the handler. Make sure dependencies are mocked, use table-driven tests, and always test both the happy path and error cases!
Happy testing! 🚀
References:
I hope this article helps you start your journey writing tests in a gqlgen project. Don’t forget to share it if you found it useful!