Testing Hasura GraphQL: Permissions, Relationships, and Computed Fields
Hasura generates a GraphQL API directly from your PostgreSQL schema, which means your tests must cover things that don't exist in hand-written APIs: role-based permission rules, relationship traversal, computed fields backed by SQL functions, and event triggers. This post shows a complete pytest-based testing strategy for Hasura with real database fixtures and the Hasura Metadata API.
Key Takeaways
Permissions are the most critical thing to test in Hasura. A misconfigured row-level permission can leak data to every user — test every role against every table, not just the happy path. Use separate test roles that mirror production roles. Create test-specific Hasura roles in your fixtures rather than testing with the admin role, which bypasses all permission checks. The Metadata API lets you inspect and modify permissions programmatically. Use it in test setup to apply temporary permission rules without touching production metadata. Computed fields are SQL functions — test them at both layers. Write unit tests for the PostgreSQL functions and integration tests for the GraphQL field. Event triggers need an HTTP endpoint to test. Run a local webhook receiver in your test suite to capture and assert on trigger payloads.
Hasura's value proposition is that it turns your PostgreSQL schema into a production-ready GraphQL API in minutes. The tradeoff is that a lot of your application logic lives outside your application code — in Hasura's permission rules, relationships, and computed field definitions. If you only test your code and ignore Hasura configuration, you're leaving your most critical security and data access rules untested.
This post covers a practical testing strategy for Hasura using pytest, with real database fixtures and the Hasura Metadata API for programmatic control.
Test Environment Architecture
Testing Hasura effectively requires a real Hasura instance with a real PostgreSQL database. Don't try to mock Hasura's internals — the permission rule evaluation, relationship resolution, and row-level security are core to what you're testing.
A working test environment has:
- PostgreSQL — a dedicated test database, isolated from development and production
- Hasura — running against the test database, with its metadata reset before tests
- pytest — orchestrating fixtures, test data, and assertions
- An HTTP server — for capturing event trigger webhooks
Docker Compose makes this straightforward:
# docker-compose.test.yml
services:
postgres-test:
image: postgres:16
environment:
POSTGRES_DB: hasura_test
POSTGRES_USER: hasura
POSTGRES_PASSWORD: hasura_test_password
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hasura -d hasura_test"]
interval: 5s
timeout: 5s
retries: 5
hasura-test:
image: hasura/graphql-engine:v2.36.0
ports:
- "8080:8080"
environment:
HASURA_GRAPHQL_DATABASE_URL: postgres://hasura:hasura_test_password@postgres-test:5432/hasura_test
HASURA_GRAPHQL_ENABLE_CONSOLE: "false"
HASURA_GRAPHQL_ADMIN_SECRET: test-admin-secret
HASURA_GRAPHQL_DEV_MODE: "true"
depends_on:
postgres-test:
condition: service_healthy
webhook-receiver:
build: ./test-webhook-server
ports:
- "3001:3001"pytest Configuration and Fixtures
Set up pytest fixtures that establish database state and provide a GraphQL client:
# conftest.py
import pytest
import psycopg2
import requests
import time
HASURA_URL = "http://localhost:8080"
HASURA_ADMIN_SECRET = "test-admin-secret"
PG_DSN = "postgresql://hasura:hasura_test_password@localhost:5433/hasura_test"
def wait_for_hasura():
"""Wait until Hasura is healthy."""
for _ in range(30):
try:
response = requests.get(
f"{HASURA_URL}/healthz",
headers={"x-hasura-admin-secret": HASURA_ADMIN_SECRET}
)
if response.status_code == 200:
return
except requests.ConnectionError:
pass
time.sleep(1)
raise RuntimeError("Hasura did not become healthy in time")
@pytest.fixture(scope="session", autouse=True)
def hasura_ready():
wait_for_hasura()
@pytest.fixture
def db():
"""Provides a database connection with automatic rollback after each test."""
conn = psycopg2.connect(PG_DSN)
conn.autocommit = False
yield conn
conn.rollback()
conn.close()
@pytest.fixture
def graphql_admin():
"""GraphQL client with admin privileges (bypasses all permissions)."""
def execute(query, variables=None, role=None):
headers = {
"x-hasura-admin-secret": HASURA_ADMIN_SECRET,
"Content-Type": "application/json"
}
if role:
headers["x-hasura-role"] = role
response = requests.post(
f"{HASURA_URL}/v1/graphql",
json={"query": query, "variables": variables or {}},
headers=headers
)
return response.json()
return execute
@pytest.fixture
def graphql_as():
"""GraphQL client that executes as a specific user and role."""
def execute(user_id, role, query, variables=None):
headers = {
"x-hasura-admin-secret": HASURA_ADMIN_SECRET,
"x-hasura-user-id": str(user_id),
"x-hasura-role": role,
"Content-Type": "application/json"
}
response = requests.post(
f"{HASURA_URL}/v1/graphql",
json={"query": query, "variables": variables or {}},
headers=headers
)
return response.json()
return executeTesting Permission Rules
Hasura permission rules use session variables like x-hasura-user-id to enforce row-level access. Testing these requires sending requests as specific users and asserting what data they can and cannot see.
Consider a posts table where users can only see their own published posts, plus all posts with the public visibility:
-- Permission rule for 'user' role on posts:
-- { "_or": [
-- { "author_id": { "_eq": "X-Hasura-User-Id" } },
-- { "visibility": { "_eq": "public" } }
-- ] }# tests/test_permissions.py
import pytest
@pytest.fixture
def seed_posts(db):
"""Create test posts owned by different users."""
cursor = db.cursor()
cursor.execute("""
INSERT INTO users (id, email) VALUES
('user-1', 'alice@example.com'),
('user-2', 'bob@example.com')
ON CONFLICT DO NOTHING
""")
cursor.execute("""
INSERT INTO posts (id, title, author_id, visibility, status) VALUES
('post-1', 'Alice Private', 'user-1', 'private', 'published'),
('post-2', 'Alice Public', 'user-1', 'public', 'published'),
('post-3', 'Bob Private', 'user-2', 'private', 'published'),
('post-4', 'Bob Public', 'user-2', 'public', 'published')
ON CONFLICT DO NOTHING
""")
db.commit()
yield
cursor.execute("DELETE FROM posts WHERE id IN ('post-1','post-2','post-3','post-4')")
cursor.execute("DELETE FROM users WHERE id IN ('user-1','user-2')")
db.commit()
LIST_POSTS = """
query {
posts(order_by: { title: asc }) {
id
title
visibility
}
}
"""
def test_user_sees_own_private_posts(graphql_as, seed_posts):
result = graphql_as("user-1", "user", LIST_POSTS)
assert "errors" not in result
titles = [p["title"] for p in result["data"]["posts"]]
assert "Alice Private" in titles
assert "Alice Public" in titles
def test_user_cannot_see_other_private_posts(graphql_as, seed_posts):
result = graphql_as("user-1", "user", LIST_POSTS)
titles = [p["title"] for p in result["data"]["posts"]]
assert "Bob Private" not in titles
def test_user_sees_public_posts_from_others(graphql_as, seed_posts):
result = graphql_as("user-1", "user", LIST_POSTS)
titles = [p["title"] for p in result["data"]["posts"]]
assert "Bob Public" in titles
def test_unauthenticated_cannot_query_posts(graphql_admin, seed_posts):
"""Test 'anonymous' role if configured, or that missing auth returns error."""
result = graphql_admin(LIST_POSTS, role="anonymous")
# Depending on your setup: either no data or an error
if "errors" in result:
assert any("not found" in e["message"].lower() or
"unauthorized" in e["message"].lower()
for e in result["errors"])
else:
# If anonymous role exists but has no permission, should return empty
assert result["data"]["posts"] == []Testing Insert and Update Permissions
Don't forget write permissions — they're just as critical:
CREATE_POST = """
mutation CreatePost($title: String!, $visibility: String!) {
insert_posts_one(object: { title: $title, visibility: $visibility }) {
id
author_id
}
}
"""
def test_insert_sets_author_id_from_session(graphql_as):
"""Author ID should be automatically set from x-hasura-user-id, not from input."""
result = graphql_as(
"user-1", "user", CREATE_POST,
{"title": "New Post", "visibility": "private"}
)
assert "errors" not in result
post = result["data"]["insert_posts_one"]
assert post["author_id"] == "user-1"
def test_user_cannot_set_arbitrary_author_id(graphql_as):
"""If the permission preset sets author_id, the user shouldn't be able to override it."""
# This mutation includes author_id — it should either be rejected or overridden
mutation = """
mutation {
insert_posts_one(object: {
title: "Spoofed Post",
visibility: "private",
author_id: "user-2"
}) { id author_id }
}
"""
result = graphql_as("user-1", "user", mutation)
if "errors" in result:
# Correctly rejected — author_id is not in the insert permission columns
pass
else:
# If allowed, the preset must have overridden it to user-1
assert result["data"]["insert_posts_one"]["author_id"] == "user-1"Testing Relationships
Hasura relationships let you traverse from one table to another in a single query. Test that relationships work correctly and that permission rules apply to related objects:
# tests/test_relationships.py
GET_POST_WITH_COMMENTS = """
query GetPost($id: uuid!) {
posts_by_pk(id: $id) {
id
title
comments(order_by: { created_at: asc }) {
id
text
author {
id
email
}
}
comments_aggregate {
aggregate {
count
}
}
}
}
"""
@pytest.fixture
def post_with_comments(db):
cursor = db.cursor()
cursor.execute("""
INSERT INTO users (id, email) VALUES
('user-a', 'usera@example.com'),
('user-b', 'userb@example.com')
""")
cursor.execute("""
INSERT INTO posts (id, title, author_id, visibility, status)
VALUES ('post-rel-1', 'Test Post', 'user-a', 'public', 'published')
""")
cursor.execute("""
INSERT INTO comments (id, post_id, author_id, text) VALUES
('comment-1', 'post-rel-1', 'user-a', 'First comment'),
('comment-2', 'post-rel-1', 'user-b', 'Second comment')
""")
db.commit()
yield "post-rel-1"
cursor.execute("DELETE FROM comments WHERE post_id = 'post-rel-1'")
cursor.execute("DELETE FROM posts WHERE id = 'post-rel-1'")
cursor.execute("DELETE FROM users WHERE id IN ('user-a', 'user-b')")
db.commit()
def test_relationship_returns_nested_comments(graphql_admin, post_with_comments):
result = graphql_admin(GET_POST_WITH_COMMENTS, {"id": post_with_comments})
assert "errors" not in result
post = result["data"]["posts_by_pk"]
assert len(post["comments"]) == 2
assert post["comments_aggregate"]["aggregate"]["count"] == 2
def test_comment_author_relationship(graphql_admin, post_with_comments):
result = graphql_admin(GET_POST_WITH_COMMENTS, {"id": post_with_comments})
comment = result["data"]["posts_by_pk"]["comments"][0]
assert comment["author"]["email"] == "usera@example.com"Testing Computed Fields
Computed fields in Hasura are backed by PostgreSQL functions. Test them at two levels: the SQL function directly, and the GraphQL field:
-- migration: add_reading_time_computed_field.sql
CREATE OR REPLACE FUNCTION posts_reading_time(post_row posts)
RETURNS INTEGER AS $$
SELECT CEIL(
array_length(regexp_split_to_array(trim(post_row.content), '\s+'), 1)::float
/ 200
)::integer;
$$ LANGUAGE SQL STABLE;# tests/test_computed_fields.py
def test_reading_time_function_directly(db):
"""Test the SQL function that backs the computed field."""
cursor = db.cursor()
# 200 words = 1 minute
content_200_words = " ".join(["word"] * 200)
cursor.execute(
"SELECT posts_reading_time(ROW(%s, %s, %s, %s, %s, %s)::posts)",
("fake-id", "Title", content_200_words, "user-1", "public", "published")
)
assert cursor.fetchone()[0] == 1
# 201 words = 2 minutes (rounds up)
content_201_words = " ".join(["word"] * 201)
cursor.execute(
"SELECT posts_reading_time(ROW(%s, %s, %s, %s, %s, %s)::posts)",
("fake-id", "Title", content_201_words, "user-1", "public", "published")
)
assert cursor.fetchone()[0] == 2
def test_reading_time_via_graphql(graphql_admin, db):
"""Test the computed field is accessible in GraphQL."""
cursor = db.cursor()
content = " ".join(["word"] * 400)
cursor.execute("""
INSERT INTO posts (id, title, content, author_id, visibility, status)
VALUES ('computed-test-1', 'Long Post', %s, 'user-1', 'public', 'published')
""", (content,))
db.commit()
result = graphql_admin("""
query {
posts_by_pk(id: "computed-test-1") {
reading_time
}
}
""")
assert "errors" not in result
assert result["data"]["posts_by_pk"]["reading_time"] == 2
cursor.execute("DELETE FROM posts WHERE id = 'computed-test-1'")
db.commit()Testing Event Triggers
Hasura event triggers fire webhooks when rows are inserted, updated, or deleted. To test them, run a minimal HTTP server in your test suite that captures incoming requests:
# test_helpers/webhook_server.py
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import queue
received_events = queue.Queue()
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
received_events.put(json.loads(body))
self.send_response(200)
self.end_headers()
def log_message(self, *args):
pass # Suppress request logs during tests
def start_webhook_server(port=3001):
server = HTTPServer(("0.0.0.0", port), WebhookHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, received_events# tests/test_event_triggers.py
import time
import pytest
from test_helpers.webhook_server import start_webhook_server
@pytest.fixture(scope="module")
def webhook_server():
server, events = start_webhook_server(port=3001)
yield events
server.shutdown()
def test_post_created_trigger_fires(graphql_as, webhook_server, db):
"""Verify the 'post_created' event trigger fires and has the right payload."""
# Clear any existing events
while not webhook_server.empty():
webhook_server.get_nowait()
graphql_as("user-1", "user", """
mutation {
insert_posts_one(object: {
title: "Trigger Test Post",
visibility: "public"
}) { id }
}
""")
# Wait for the webhook (event triggers are async)
try:
event = webhook_server.get(timeout=5)
except Exception:
pytest.fail("Event trigger webhook not received within 5 seconds")
assert event["trigger"]["name"] == "post_created"
assert event["event"]["op"] == "INSERT"
assert event["event"]["data"]["new"]["title"] == "Trigger Test Post"
assert event["event"]["data"]["new"]["author_id"] == "user-1"Testing with the Hasura Metadata API
The Metadata API lets you inspect what permissions and relationships are configured. Use it to verify your metadata is applied correctly:
# tests/test_metadata.py
def get_table_metadata(table_name):
response = requests.post(
"http://localhost:8080/v1/metadata",
headers={
"x-hasura-admin-secret": "test-admin-secret",
"Content-Type": "application/json"
},
json={"type": "export_metadata", "args": {}}
)
metadata = response.json()
tables = metadata["sources"][0]["tables"]
return next(
(t for t in tables if t["table"]["name"] == table_name),
None
)
def test_posts_table_has_user_select_permission():
table_meta = get_table_metadata("posts")
assert table_meta is not None
select_perms = table_meta.get("select_permissions", [])
user_perm = next((p for p in select_perms if p["role"] == "user"), None)
assert user_perm is not None, "No select permission for 'user' role on posts table"
assert user_perm["permission"]["filter"] != {}, "Permission filter is empty (allows all rows)"
def test_posts_table_restricts_insert_columns():
table_meta = get_table_metadata("posts")
insert_perms = table_meta.get("insert_permissions", [])
user_perm = next((p for p in insert_perms if p["role"] == "user"), None)
assert user_perm is not None
allowed_columns = user_perm["permission"]["columns"]
assert "author_id" not in allowed_columns, \
"Users should not be able to set author_id directly"Structuring Hasura Tests
Organize tests by concern rather than by table. A test_post_permissions.py file covers all read, write, and delete permissions for posts. A separate test_post_relationships.py covers relationship traversal. A test_computed_fields.py file covers all SQL-backed fields:
tests/
conftest.py
test_user_permissions.py
test_post_permissions.py
test_post_relationships.py
test_computed_fields.py
test_event_triggers.py
test_metadata_integrity.py
test_helpers/
webhook_server.pyRun permission tests before every deploy. Hasura makes it easy to accidentally loosen a permission rule when updating metadata — having tests that verify each role's access level catches these regressions before they reach production.