Testing Type-Safe GraphQL: Pothos (TypeScript) and Strawberry (Python)
Code-first GraphQL frameworks like Pothos (TypeScript) and Strawberry (Python) generate schemas from type-annotated code, which fundamentally changes the testing approach. Your types become the schema, so testing the types tests the schema. This post covers testing strategies specific to code-first GraphQL — plugin testing, type resolution, auth guards, Relay pagination, and the comparison with SDL-first testing.
Key Takeaways
Code-first schemas make type errors impossible to ignore. TypeScript and Python type annotations catch schema mismatches at build time, reducing a whole class of runtime bugs before tests even run.
Test plugin behavior separately from resolver logic. Auth plugins, Relay plugins, and Prisma plugins add behavior that should be verified independently — don't assume plugins work correctly by testing the resolvers that use them.
Pothos's SchemaBuilder is testable without a running server. You can build the schema, execute queries against it directly, and assert on results — no HTTP setup required.
Strawberry's test client wraps schema execution cleanly. Use schema.execute_sync or the async schema.execute to run queries in tests without a web framework.
SDL-first testing focuses on document parsing; code-first testing focuses on type correctness. The tradeoffs are real — choose based on your team's workflow, not hype.
Schema Definition Language (SDL) has been the traditional way to define GraphQL schemas: write .graphql files, wire up resolvers separately, and hope the types match at runtime. Code-first frameworks flip this: you define types using the host language's type system, and the framework generates the SDL for you.
Pothos (formerly GiraphQL) does this for TypeScript. Strawberry does it for Python. Both produce schemas that are correct by construction — if the TypeScript or Python types compile, the schema is valid. This changes what you need to test.
Pothos: Testing TypeScript Code-First GraphQL
Pothos uses a SchemaBuilder to define types, queries, and mutations. The schema is built programmatically, which means you can build it in tests and execute queries directly.
Setup
npm install @pothos/core graphql
npm install --save-dev jest ts-jest @types/jest graphqlA basic Pothos schema:
// src/schema/builder.ts
import SchemaBuilder from '@pothos/core';
interface User {
id: string;
name: string;
email: string;
role: 'ADMIN' | 'USER';
}
interface Post {
id: string;
title: string;
content: string;
authorId: string;
status: 'DRAFT' | 'PUBLISHED';
}
interface Context {
currentUser: User | null;
db: {
getUser: (id: string) => Promise<User | null>;
getPost: (id: string) => Promise<Post | null>;
listPosts: (authorId?: string) => Promise<Post[]>;
};
}
const builder = new SchemaBuilder<{
Context: Context;
Objects: { User: User; Post: Post };
}>({});
builder.objectType('User', {
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
email: t.exposeString('email'),
role: t.exposeString('role'),
}),
});
builder.objectType('Post', {
fields: (t) => ({
id: t.exposeID('id'),
title: t.exposeString('title'),
content: t.exposeString('content'),
status: t.exposeString('status'),
author: t.field({
type: 'User',
resolve: async (post, _args, ctx) => {
const user = await ctx.db.getUser(post.authorId);
if (!user) throw new Error(`Author ${post.authorId} not found`);
return user;
},
}),
}),
});
builder.queryType({
fields: (t) => ({
user: t.field({
type: 'User',
nullable: true,
args: { id: t.arg.id({ required: true }) },
resolve: (_root, args, ctx) => ctx.db.getUser(args.id),
}),
post: t.field({
type: 'Post',
nullable: true,
args: { id: t.arg.id({ required: true }) },
resolve: (_root, args, ctx) => ctx.db.getPost(args.id),
}),
}),
});
export const schema = builder.toSchema();
export type { Context };Testing the Schema Without a Server
Pothos schemas are standard GraphQL schemas — use graphql's execute function directly:
// src/schema/schema.test.ts
import { execute, parse } from 'graphql';
import { schema } from './builder';
import type { Context } from './builder';
function createContext(overrides: Partial<Context> = {}): Context {
return {
currentUser: null,
db: {
getUser: jest.fn().mockResolvedValue(null),
getPost: jest.fn().mockResolvedValue(null),
listPosts: jest.fn().mockResolvedValue([]),
},
...overrides,
};
}
describe('user query', () => {
it('returns a user by ID', async () => {
const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com', role: 'USER' as const };
const ctx = createContext({
db: {
getUser: jest.fn().mockResolvedValue(mockUser),
getPost: jest.fn(),
listPosts: jest.fn(),
}
});
const result = await execute({
schema,
document: parse(`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`),
variableValues: { id: '1' },
contextValue: ctx,
});
expect(result.errors).toBeUndefined();
expect(result.data?.user).toEqual({
id: '1',
name: 'Alice',
email: 'alice@example.com',
});
});
it('returns null for non-existent user', async () => {
const ctx = createContext();
const result = await execute({
schema,
document: parse(`query { user(id: "missing") { id } }`),
contextValue: ctx,
});
expect(result.errors).toBeUndefined();
expect(result.data?.user).toBeNull();
});
});
describe('post.author field resolver', () => {
it('resolves the author relationship', async () => {
const mockPost = {
id: 'p1', title: 'Hello', content: 'World',
authorId: 'user-1', status: 'PUBLISHED' as const
};
const mockUser = { id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'USER' as const };
const ctx = createContext({
db: {
getUser: jest.fn().mockResolvedValue(mockUser),
getPost: jest.fn().mockResolvedValue(mockPost),
listPosts: jest.fn(),
}
});
const result = await execute({
schema,
document: parse(`
query {
post(id: "p1") {
title
author { name email }
}
}
`),
contextValue: ctx,
});
expect(result.errors).toBeUndefined();
expect(result.data?.post?.author).toEqual({ name: 'Alice', email: 'alice@example.com' });
});
});Testing Pothos Auth Plugin
The @pothos/plugin-auth plugin adds field-level and type-level authorization rules. Test these in isolation to verify the auth logic without mocking your entire application:
// src/schema/builder-with-auth.ts
import SchemaBuilder from '@pothos/core';
import AuthPlugin from '@pothos/plugin-auth';
const builder = new SchemaBuilder<{
Context: { currentUser: { id: string; role: string } | null };
AuthScopes: { isAuthenticated: boolean; isAdmin: boolean };
}>({
plugins: [AuthPlugin],
authScopes: async (ctx) => ({
isAuthenticated: !!ctx.currentUser,
isAdmin: ctx.currentUser?.role === 'ADMIN',
}),
});
builder.queryType({
fields: (t) => ({
publicData: t.string({
resolve: () => 'anyone can see this',
}),
privateData: t.string({
authScopes: { isAuthenticated: true },
resolve: () => 'authenticated users only',
}),
adminData: t.string({
authScopes: { isAdmin: true },
resolve: () => 'admins only',
}),
}),
});
export const schemaWithAuth = builder.toSchema();// src/schema/auth.test.ts
import { execute, parse } from 'graphql';
import { schemaWithAuth } from './builder-with-auth';
describe('Auth plugin', () => {
const PRIVATE_QUERY = parse(`query { privateData }`);
const ADMIN_QUERY = parse(`query { adminData }`);
const PUBLIC_QUERY = parse(`query { publicData }`);
it('allows unauthenticated access to public fields', async () => {
const result = await execute({
schema: schemaWithAuth,
document: PUBLIC_QUERY,
contextValue: { currentUser: null },
});
expect(result.errors).toBeUndefined();
expect(result.data?.publicData).toBe('anyone can see this');
});
it('blocks unauthenticated access to private fields', async () => {
const result = await execute({
schema: schemaWithAuth,
document: PRIVATE_QUERY,
contextValue: { currentUser: null },
});
expect(result.errors).toBeDefined();
expect(result.errors![0].message).toMatch(/not authorized/i);
});
it('allows authenticated access to private fields', async () => {
const result = await execute({
schema: schemaWithAuth,
document: PRIVATE_QUERY,
contextValue: { currentUser: { id: 'u1', role: 'USER' } },
});
expect(result.errors).toBeUndefined();
expect(result.data?.privateData).toBe('authenticated users only');
});
it('blocks non-admin users from admin fields', async () => {
const result = await execute({
schema: schemaWithAuth,
document: ADMIN_QUERY,
contextValue: { currentUser: { id: 'u1', role: 'USER' } },
});
expect(result.errors).toBeDefined();
});
it('allows admin users to access admin fields', async () => {
const result = await execute({
schema: schemaWithAuth,
document: ADMIN_QUERY,
contextValue: { currentUser: { id: 'u1', role: 'ADMIN' } },
});
expect(result.errors).toBeUndefined();
expect(result.data?.adminData).toBe('admins only');
});
});Testing Relay Pagination with Pothos
The @pothos/plugin-relay plugin adds Relay-compliant pagination. Test cursor encoding, page boundaries, and connection structure:
// src/schema/relay.test.ts
import { execute, parse } from 'graphql';
import { relaySchema } from './builder-with-relay';
const LIST_POSTS = parse(`
query ListPosts($first: Int, $after: String) {
posts(first: $first, after: $after) {
edges {
node { id title }
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`);
describe('Relay pagination', () => {
const allPosts = Array.from({ length: 10 }, (_, i) => ({
id: `post-${i + 1}`,
title: `Post ${i + 1}`,
authorId: 'user-1',
status: 'PUBLISHED' as const,
content: '',
}));
it('returns first page with hasNextPage=true', async () => {
const result = await execute({
schema: relaySchema,
document: LIST_POSTS,
variableValues: { first: 3 },
contextValue: { db: { listPosts: jest.fn().mockResolvedValue(allPosts) } },
});
expect(result.errors).toBeUndefined();
const posts = result.data?.posts;
expect(posts.edges).toHaveLength(3);
expect(posts.pageInfo.hasNextPage).toBe(true);
expect(posts.pageInfo.hasPreviousPage).toBe(false);
expect(posts.totalCount).toBe(10);
});
it('returns last page with hasNextPage=false', async () => {
const result = await execute({
schema: relaySchema,
document: LIST_POSTS,
variableValues: { first: 10 },
contextValue: { db: { listPosts: jest.fn().mockResolvedValue(allPosts) } },
});
expect(result.data?.posts.pageInfo.hasNextPage).toBe(false);
expect(result.data?.posts.edges).toHaveLength(10);
});
});Strawberry: Testing Python Code-First GraphQL
Strawberry uses Python dataclasses and type annotations to define GraphQL schemas. The schema.execute_sync method runs queries synchronously in tests.
Setup
pip install strawberry-graphql pytest pytest-asyncioA basic Strawberry schema:
# src/schema.py
import strawberry
from typing import Optional, List
from dataclasses import dataclass
@dataclass
class UserDB:
id: str
name: str
email: str
role: str
@strawberry.type
class User:
id: strawberry.ID
name: str
email: str
role: str
@strawberry.type
class Post:
id: strawberry.ID
title: str
status: str
author_id: str
@strawberry.field
async def author(self, info: strawberry.types.Info) -> Optional[User]:
user = await info.context["db"].get_user(self.author_id)
if user is None:
return None
return User(id=user.id, name=user.name, email=user.email, role=user.role)
@strawberry.type
class Query:
@strawberry.field
async def user(self, id: strawberry.ID, info: strawberry.types.Info) -> Optional[User]:
user = await info.context["db"].get_user(id)
if user is None:
return None
return User(id=user.id, name=user.name, email=user.email, role=user.role)
@strawberry.field
async def posts(self, info: strawberry.types.Info) -> List[Post]:
posts = await info.context["db"].list_posts()
return [
Post(id=p["id"], title=p["title"], status=p["status"], author_id=p["author_id"])
for p in posts
]
schema = strawberry.Schema(query=Query)Basic Query Tests
# tests/test_schema.py
import pytest
from unittest.mock import AsyncMock, MagicMock
from src.schema import schema
def make_context(overrides=None):
"""Build a test context with mock database."""
db = MagicMock()
db.get_user = AsyncMock(return_value=None)
db.list_posts = AsyncMock(return_value=[])
ctx = {"db": db, "current_user": None}
if overrides:
ctx.update(overrides)
return ctx
@pytest.mark.asyncio
async def test_user_query_returns_user():
mock_user = MagicMock()
mock_user.id = "user-1"
mock_user.name = "Alice"
mock_user.email = "alice@example.com"
mock_user.role = "USER"
db = MagicMock()
db.get_user = AsyncMock(return_value=mock_user)
result = await schema.execute(
"""
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
""",
variable_values={"id": "user-1"},
context_value={"db": db, "current_user": None}
)
assert result.errors is None
assert result.data["user"] == {
"id": "user-1",
"name": "Alice",
"email": "alice@example.com"
}
db.get_user.assert_called_once_with("user-1")
@pytest.mark.asyncio
async def test_user_query_returns_none_when_not_found():
db = MagicMock()
db.get_user = AsyncMock(return_value=None)
result = await schema.execute(
"query { user(id: \"missing\") { id } }",
context_value={"db": db, "current_user": None}
)
assert result.errors is None
assert result.data["user"] is NoneTesting Strawberry Permission Classes
Strawberry uses IsAuthenticated and custom BasePermission classes for authorization:
# src/permissions.py
import strawberry
from strawberry.permission import BasePermission
from strawberry.types import Info
from typing import Any
class IsAuthenticated(BasePermission):
message = "User is not authenticated"
def has_permission(self, source: Any, info: Info, **kwargs) -> bool:
return info.context.get("current_user") is not None
class IsAdmin(BasePermission):
message = "User does not have admin privileges"
def has_permission(self, source: Any, info: Info, **kwargs) -> bool:
user = info.context.get("current_user")
return user is not None and user.get("role") == "ADMIN"
class IsOwner(BasePermission):
message = "User does not own this resource"
def has_permission(self, source: Any, info: Info, **kwargs) -> bool:
user = info.context.get("current_user")
if user is None:
return False
# source is the parent object — check ownership
return hasattr(source, "author_id") and source.author_id == user["id"]# tests/test_permissions.py
import pytest
from src.permissions import IsAuthenticated, IsAdmin
from unittest.mock import MagicMock
def make_info(current_user=None):
info = MagicMock()
info.context = {"current_user": current_user}
return info
class TestIsAuthenticated:
def test_denies_unauthenticated(self):
permission = IsAuthenticated()
result = permission.has_permission(None, make_info(current_user=None))
assert result is False
def test_allows_authenticated_user(self):
permission = IsAuthenticated()
result = permission.has_permission(
None,
make_info(current_user={"id": "user-1", "role": "USER"})
)
assert result is True
class TestIsAdmin:
def test_denies_regular_user(self):
permission = IsAdmin()
result = permission.has_permission(
None,
make_info(current_user={"id": "user-1", "role": "USER"})
)
assert result is False
def test_allows_admin_user(self):
permission = IsAdmin()
result = permission.has_permission(
None,
make_info(current_user={"id": "user-1", "role": "ADMIN"})
)
assert result is TrueTesting Strawberry Types and Resolvers
For type-level tests, test the resolver methods directly as Python methods:
# src/types.py
import strawberry
from typing import Optional
@strawberry.type
class Post:
id: strawberry.ID
title: str
content: str
status: str
@strawberry.field
def reading_time(self) -> int:
word_count = len(self.content.split())
return max(1, -(-word_count // 200)) # ceiling division
@strawberry.field
def excerpt(self, max_chars: int = 150) -> str:
if len(self.content) <= max_chars:
return self.content
return self.content[:max_chars].rsplit(' ', 1)[0] + '...'# tests/test_types.py
from src.types import Post
class TestPostReadingTime:
def make_post(self, content: str) -> Post:
return Post(id="1", title="Test", content=content, status="PUBLISHED")
def test_short_content_returns_one_minute(self):
post = self.make_post("word " * 100)
assert post.reading_time() == 1
def test_exactly_200_words_returns_one_minute(self):
post = self.make_post("word " * 200)
assert post.reading_time() == 1
def test_201_words_returns_two_minutes(self):
post = self.make_post("word " * 201)
assert post.reading_time() == 2
def test_zero_content_returns_one_minute(self):
post = self.make_post("")
assert post.reading_time() == 1
class TestPostExcerpt:
def make_post(self, content: str) -> Post:
return Post(id="1", title="Test", content=content, status="PUBLISHED")
def test_short_content_returns_unchanged(self):
post = self.make_post("Short content")
assert post.excerpt() == "Short content"
def test_long_content_is_truncated_at_word_boundary(self):
post = self.make_post("word " * 50)
result = post.excerpt(max_chars=20)
assert result.endswith("...")
assert len(result) <= 23 # max_chars + "..."SDL-First vs Code-First Testing: The Real Tradeoffs
The testing approach differs meaningfully between SDL-first and code-first schemas.
SDL-first testing:
- Schema and resolvers are defined separately — you can test them independently or together
- Type mismatches between schema and resolvers are runtime errors, caught only by tests
- Easier to share schemas across teams (the
.graphqlfile is self-documenting) - Snapshot testing the SDL output is the primary contract validation tool
Code-first testing:
- Types are the schema — TypeScript/Python type errors catch mismatches before tests run
- Resolver logic is co-located with type definitions, making it easier to trace what to test
- Generated SDL can be snapshot-tested to catch unintended schema changes
- Plugin behavior (auth, pagination, data loading) needs specific test patterns
To snapshot the generated SDL in a Pothos project:
// schema.test.ts
import { lexicographicSortSchema, printSchema } from 'graphql';
import { schema } from './builder';
it('schema SDL matches snapshot', () => {
const sdl = printSchema(lexicographicSortSchema(schema));
expect(sdl).toMatchSnapshot();
});In Strawberry:
def test_schema_sdl_matches_snapshot(snapshot):
sdl = str(schema)
snapshot.assert_match(sdl, "schema.graphql")When the SDL snapshot fails, it means a type annotation or resolver definition changed the generated schema — exactly the kind of change that could break clients. Treat it as a contract check, not just a snapshot to update.
Code-first GraphQL testing ultimately rewards the same principles as any typed language: push errors as early as possible, test the behavior not the implementation, and make contracts explicit. The type system does a lot of work for you — let your tests focus on the logic that types can't verify.