Skip to content

Testing

FraiseQL applications have three distinct testing layers, each validating a different concern:

LayerWhat it testsSpeed
Schema testsType definitions and schema compilationInstant
Database testsSQL views and PostgreSQL functionsFast (real DB, transaction rollback)
Integration testsEnd-to-end GraphQL API behaviourSlower (real server)

Start with database tests — they give the most coverage at the best speed.

Terminal window
# Install test dependencies
uv add --dev pytest pytest-asyncio httpx testcontainers factory-boy
pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.uv]
dev-dependencies = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"httpx>=0.27",
"testcontainers[postgres]>=4.0",
"factory-boy>=3.3",
]
tests/conftest.py
import pytest
import psycopg
import subprocess
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="session")
def postgres():
"""Spin up a throwaway PostgreSQL container for the test session."""
with PostgresContainer("postgres:16-alpine") as pg:
yield pg
@pytest.fixture(scope="session")
def db_url(postgres):
return postgres.get_connection_url().replace("postgresql+psycopg2", "postgresql")
@pytest.fixture(scope="session")
def migrated_db(db_url):
"""Apply your schema (tables, views, functions) to the test database."""
subprocess.run(
["fraiseql", "db", "build", "--database-url", db_url],
check=True,
)
return db_url
@pytest.fixture
def db(migrated_db):
"""Per-test database connection that rolls back after each test."""
with psycopg.connect(migrated_db) as conn:
conn.autocommit = False
yield conn
conn.rollback()

Verify that your type definitions compile to the expected GraphQL schema:

tests/test_schema.py
import fraiseql
from schema import User, Post, Comment
def test_user_type_has_expected_fields():
schema = fraiseql.compile_schema()
user_type = schema.types["User"]
assert "id" in user_type.fields
assert "email" in user_type.fields
assert "username" in user_type.fields
assert user_type.fields["id"].scalar == "ID"
def test_post_has_author_relationship():
schema = fraiseql.compile_schema()
post_type = schema.types["Post"]
assert "author" in post_type.fields
assert post_type.fields["author"].type_name == "User"
def test_mutations_are_registered():
schema = fraiseql.compile_schema()
assert "createPost" in schema.mutations
assert "publishPost" in schema.mutations
assert "deletePost" in schema.mutations
def test_schema_compiles_without_error():
"""Full compilation should produce valid schema JSON."""
schema_json = fraiseql.export_schema_json()
assert "types" in schema_json
assert "queries" in schema_json
assert "mutations" in schema_json

Test SQL views and PostgreSQL functions directly. These tests skip the HTTP layer and call the database layer that FraiseQL actually uses at runtime.

tests/test_views.py
import pytest
import psycopg
class TestUserView:
def test_v_user_returns_data_column(self, db):
"""v_user must expose a JSONB data column."""
with db.cursor() as cur:
cur.execute("""
INSERT INTO tb_user (username, email, identifier)
VALUES ('alice', 'alice@example.com', 'alice@example.com')
RETURNING id
""")
user_id = cur.fetchone()[0]
cur.execute("SELECT data FROM v_user WHERE id = %s", (user_id,))
data = cur.fetchone()[0]
assert data["email"] == "alice@example.com"
assert data["username"] == "alice"
assert "id" in data
# Surrogate key must NOT be exposed
assert "pk_user" not in data
def test_v_user_filters_by_id(self, db):
"""v_user supports filtering by id column."""
with db.cursor() as cur:
cur.execute("""
INSERT INTO tb_user (username, email, identifier)
VALUES ('bob', 'bob@example.com', 'bob@example.com')
RETURNING id
""")
user_id = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM v_user WHERE id = %s", (user_id,))
count = cur.fetchone()[0]
assert count == 1
class TestPostView:
def test_v_post_nests_author(self, db):
"""v_post must include nested author data from v_user."""
with db.cursor() as cur:
# Create author
cur.execute("""
INSERT INTO tb_user (username, email, identifier)
VALUES ('carol', 'carol@example.com', 'carol@example.com')
RETURNING pk_user, id
""")
pk_user, user_id = cur.fetchone()
# Create post
cur.execute("""
INSERT INTO tb_post (fk_user, title, content, slug, identifier)
VALUES (%s, 'Hello World', 'Content here', 'hello-world', 'hello-world')
RETURNING id
""", (pk_user,))
post_id = cur.fetchone()[0]
cur.execute("SELECT data FROM v_post WHERE id = %s", (post_id,))
data = cur.fetchone()[0]
assert data["title"] == "Hello World"
assert data["author"]["username"] == "carol"
assert data["author"]["id"] == str(user_id)
def test_v_post_filters_by_is_published(self, db):
"""is_published column available for automatic-where filtering."""
with db.cursor() as cur:
cur.execute("""
INSERT INTO tb_user (username, email, identifier)
VALUES ('dave', 'dave@example.com', 'dave@example.com')
RETURNING pk_user
""")
pk_user = cur.fetchone()[0]
cur.execute("""
INSERT INTO tb_post (fk_user, title, content, slug, identifier, is_published)
VALUES
(%s, 'Draft', 'Draft content', 'draft', 'draft', false),
(%s, 'Published', 'Published content', 'published', 'published', true)
""", (pk_user, pk_user))
cur.execute("SELECT COUNT(*) FROM v_post WHERE is_published = true")
published_count = cur.fetchone()[0]
assert published_count >= 1
tests/test_functions.py
import pytest
import psycopg
class TestCreateUser:
def test_creates_user_and_returns_data(self, db):
"""fn_create_user inserts a row and returns via v_user."""
with db.cursor() as cur:
cur.execute("""
SELECT data FROM fn_create_user(
'{"username": "eve", "email": "eve@example.com"}'::jsonb
)
""")
user = cur.fetchone()[0]
assert user["username"] == "eve"
assert user["email"] == "eve@example.com"
assert "id" in user
def test_rejects_duplicate_email(self, db):
"""fn_create_user raises an error on duplicate email."""
with db.cursor() as cur:
cur.execute("""
INSERT INTO tb_user (username, email, identifier)
VALUES ('frank', 'frank@example.com', 'frank@example.com')
""")
with pytest.raises(psycopg.errors.RaiseException) as exc_info:
cur.execute("""
SELECT data FROM fn_create_user(
'{"username": "frank2", "email": "frank@example.com"}'::jsonb
)
""")
assert "already exists" in str(exc_info.value).lower()
def test_rejects_missing_required_fields(self, db):
"""fn_create_user raises when required fields are absent."""
with db.cursor() as cur:
with pytest.raises(psycopg.errors.RaiseException):
cur.execute("""
SELECT data FROM fn_create_user('{"username": "nomail"}'::jsonb)
""")
class TestPublishPost:
def test_sets_is_published(self, db):
"""fn_publish_post sets is_published = true."""
with db.cursor() as cur:
# Setup
cur.execute("""
INSERT INTO tb_user (username, email, identifier)
VALUES ('grace', 'grace@example.com', 'grace@example.com')
RETURNING pk_user, id
""")
pk_user, user_id = cur.fetchone()
cur.execute("""
INSERT INTO tb_post (fk_user, title, content, slug, identifier)
VALUES (%s, 'Draft Post', 'Content', 'draft-post', 'draft-post')
RETURNING id
""", (pk_user,))
post_id = cur.fetchone()[0]
# Publish
cur.execute(
"SELECT data FROM fn_publish_post(%s::uuid, %s::uuid)",
(post_id, user_id),
)
post = cur.fetchone()[0]
assert post["isPublished"] is True
def test_rejects_wrong_author(self, db):
"""fn_publish_post raises if caller is not the author."""
with db.cursor() as cur:
cur.execute("""
INSERT INTO tb_user (username, email, identifier)
VALUES
('hank', 'hank@example.com', 'hank@example.com'),
('ivy', 'ivy@example.com', 'ivy@example.com')
RETURNING pk_user, id
""")
rows = cur.fetchall()
hank_pk, hank_id = rows[0]
_ivy_pk, ivy_id = rows[1]
cur.execute("""
INSERT INTO tb_post (fk_user, title, content, slug, identifier)
VALUES (%s, 'Hank Post', 'Content', 'hank-post', 'hank-post')
RETURNING id
""", (hank_pk,))
post_id = cur.fetchone()[0]
with pytest.raises(psycopg.errors.RaiseException):
cur.execute(
"SELECT data FROM fn_publish_post(%s::uuid, %s::uuid)",
(post_id, ivy_id), # ivy tries to publish hank's post
)

End-to-end tests that send GraphQL requests to a running FraiseQL server:

tests/conftest.py
import pytest
import httpx
import subprocess
import time
@pytest.fixture(scope="session")
def fraiseql_server(migrated_db):
"""Start a FraiseQL server against the test database."""
proc = subprocess.Popen([
"fraiseql", "serve",
"--database-url", migrated_db,
"--port", "18080",
"--no-playground",
])
# Wait for server to be ready
for _ in range(20):
try:
httpx.get("http://localhost:18080/health", timeout=1)
break
except httpx.ConnectError:
time.sleep(0.5)
yield proc
proc.terminate()
proc.wait()
@pytest.fixture
def client(fraiseql_server):
return httpx.Client(base_url="http://localhost:18080", timeout=10)
tests/test_api.py
import pytest
class TestUserQueries:
def test_list_users(self, client):
response = client.post("/graphql", json={
"query": "query { users(limit: 10) { id username email } }"
})
assert response.status_code == 200
data = response.json()
assert "errors" not in data
assert isinstance(data["data"]["users"], list)
def test_single_user_by_id(self, client, created_user):
response = client.post("/graphql", json={
"query": """
query GetUser($id: ID!) {
user(id: $id) { id username email }
}
""",
"variables": {"id": created_user["id"]},
})
data = response.json()
assert data["data"]["user"]["id"] == created_user["id"]
class TestUserMutations:
def test_create_user(self, client):
response = client.post("/graphql", json={
"query": """
mutation {
createUser(input: {
username: "newuser"
email: "newuser@example.com"
}) { id username email }
}
"""
})
data = response.json()
assert "errors" not in data
user = data["data"]["createUser"]
assert user["username"] == "newuser"
assert user["id"] is not None
def test_create_user_duplicate_email(self, client, created_user):
response = client.post("/graphql", json={
"query": """
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) { id }
}
""",
"variables": {
"input": {
"username": "other",
"email": created_user["email"],
}
},
})
data = response.json()
assert "errors" in data
assert "already exists" in data["errors"][0]["message"].lower()
class TestPostQueries:
def test_post_includes_nested_author(self, client, created_post):
response = client.post("/graphql", json={
"query": """
query {
posts(limit: 10) {
id
title
author { id username }
}
}
"""
})
data = response.json()
posts = data["data"]["posts"]
assert len(posts) > 0
assert posts[0]["author"]["username"] is not None

tests/factories.py
import factory
import uuid
class UserFactory(factory.DictFactory):
id = factory.LazyFunction(lambda: str(uuid.uuid4()))
username = factory.Sequence(lambda n: f"user{n}")
email = factory.Sequence(lambda n: f"user{n}@example.com")
bio = factory.Faker("sentence")
class PostFactory(factory.DictFactory):
id = factory.LazyFunction(lambda: str(uuid.uuid4()))
title = factory.Faker("sentence", nb_words=5)
content = factory.Faker("paragraph")
slug = factory.Sequence(lambda n: f"post-{n}")
tests/conftest.py
from tests.factories import UserFactory
@pytest.fixture
def created_user(client):
"""Create a user via the API and return the created record."""
data = UserFactory()
response = client.post("/graphql", json={
"query": """
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) { id username email }
}
""",
"variables": {"input": {"username": data["username"], "email": data["email"]}},
})
return response.json()["data"]["createUser"]

Terminal window
# All tests
uv run pytest
# Database tests only (fast)
uv run pytest tests/test_views.py tests/test_functions.py
# With coverage
uv run pytest --cov=schema --cov-report=html
# In parallel
uv run pytest -n auto

.github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Install dependencies
run: uv sync
- name: Build database schema
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
run: uv run fraiseql db build
- name: Run tests
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
run: uv run pytest --cov --cov-report=xml
- uses: codecov/codecov-action@v4