Nhost Testing Guide: Testing Hasura + Auth + Storage
Nhost is an open-source Firebase alternative that combines PostgreSQL, Hasura GraphQL Engine, Auth (JWT-based), Storage, and serverless functions into a single deployable platform. It's popular with teams who want typed GraphQL APIs auto-generated from their database schema without writing resolvers.
Testing Nhost applications involves several distinct concerns: GraphQL query/mutation testing, Hasura permission rules (row-level security via roles), JWT auth flows, and file storage. This guide covers all of them.
Local Development with the Nhost CLI
The most important testing tool for Nhost is the local development environment:
npm install -g nhost
nhost devThis spins up PostgreSQL, Hasura, Auth, Storage, and a dashboard locally using Docker. Your nhost/ directory contains the configuration — migrations, metadata, and hasura configuration files — that defines your entire backend.
For testing, the local Nhost instance is your integration test target. No cloud account needed for CI.
Testing GraphQL Queries
Nhost exposes a Hasura GraphQL endpoint. Test it at three levels:
- Unit tests: Test query document composition and response handling
- Integration tests: Run queries against the local Nhost instance
- E2E tests: Test full user flows that include GraphQL calls
Mocking GraphQL in Unit Tests
For component and service unit tests, mock the GraphQL client:
// tests/mocks/nhostClient.ts
import { NhostClient } from '@nhost/nhost-js';
export function createMockNhostClient() {
return {
graphql: {
request: jest.fn(),
},
auth: {
signIn: jest.fn(),
signOut: jest.fn(),
signUp: jest.fn(),
getUser: jest.fn(),
getAccessToken: jest.fn(),
isAuthenticated: jest.fn().mockReturnValue(false),
onAuthStateChanged: jest.fn(),
},
storage: {
upload: jest.fn(),
getPublicUrl: jest.fn(),
getDownloadUrl: jest.fn(),
delete: jest.fn(),
},
} as unknown as NhostClient;
}Testing a Service That Uses GraphQL
// src/services/PostService.ts
import { NhostClient } from '@nhost/nhost-js';
import { gql } from 'graphql-tag';
const GET_PUBLISHED_POSTS = gql`
query GetPublishedPosts($limit: Int!) {
posts(where: { status: { _eq: "published" } }, limit: $limit, order_by: { published_at: desc }) {
id
title
slug
published_at
author {
display_name
}
}
}
`;
const CREATE_POST = gql`
mutation CreatePost($title: String!, $content: String!, $author_id: uuid!) {
insert_posts_one(object: { title: $title, content: $content, author_id: $author_id, status: "draft" }) {
id
title
status
}
}
`;
export class PostService {
constructor(private nhost: NhostClient) {}
async getPublishedPosts(limit = 10) {
const { data, error } = await this.nhost.graphql.request(
GET_PUBLISHED_POSTS,
{ limit },
);
if (error) throw new Error(error.message);
return data.posts;
}
async createPost(title: string, content: string, authorId: string) {
const { data, error } = await this.nhost.graphql.request(CREATE_POST, {
title,
content,
author_id: authorId,
});
if (error) throw new Error(error.message);
return data.insert_posts_one;
}
}Testing this service:
// src/services/PostService.test.ts
import { PostService } from './PostService';
import { createMockNhostClient } from '../../tests/mocks/nhostClient';
describe('PostService', () => {
let service: PostService;
let mockNhost: ReturnType<typeof createMockNhostClient>;
beforeEach(() => {
mockNhost = createMockNhostClient();
service = new PostService(mockNhost as any);
});
describe('getPublishedPosts', () => {
it('returns posts from successful GraphQL response', async () => {
const mockPosts = [
{ id: '1', title: 'First Post', slug: 'first-post', published_at: '2026-01-01' },
{ id: '2', title: 'Second Post', slug: 'second-post', published_at: '2026-01-02' },
];
mockNhost.graphql.request.mockResolvedValue({
data: { posts: mockPosts },
error: null,
});
const posts = await service.getPublishedPosts(10);
expect(mockNhost.graphql.request).toHaveBeenCalledWith(
expect.anything(), // query document
{ limit: 10 },
);
expect(posts).toHaveLength(2);
expect(posts[0].title).toBe('First Post');
});
it('throws on GraphQL error', async () => {
mockNhost.graphql.request.mockResolvedValue({
data: null,
error: { message: 'permission denied for table posts' },
});
await expect(service.getPublishedPosts()).rejects.toThrow(
'permission denied',
);
});
});
});Integration Testing Against Local Nhost
Start the local Nhost dev server (nhost dev), then run integration tests against it.
// tests/integration/graphql.integration.test.ts
import { NhostClient } from '@nhost/nhost-js';
const nhost = new NhostClient({
subdomain: 'local',
region: 'local',
authUrl: 'http://localhost:1337/v1/auth',
graphqlUrl: 'http://localhost:1337/v1/graphql',
storageUrl: 'http://localhost:1337/v1/storage',
functionsUrl: 'http://localhost:1337/v1/functions',
});
async function cleanupPosts() {
// Use admin secret for cleanup (bypasses Hasura permissions)
const res = await fetch('http://localhost:1337/v1/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-hasura-admin-secret': 'nhost-admin-secret',
},
body: JSON.stringify({ query: 'mutation { delete_posts(where: {}) { affected_rows } }' }),
});
return res.json();
}
describe('Posts GraphQL (integration)', () => {
let userToken: string;
beforeAll(async () => {
// Sign up and get a JWT
const { session, error } = await nhost.auth.signUp({
email: `test-${Date.now()}@example.com`,
password: 'TestPass123!',
});
if (error) throw new Error(error.message);
userToken = session!.accessToken;
});
afterEach(cleanupPosts);
it('user can create a post', async () => {
const res = await fetch('http://localhost:1337/v1/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userToken}`,
},
body: JSON.stringify({
query: `
mutation {
insert_posts_one(object: { title: "My Post", content: "Body", status: "draft" }) {
id
title
status
}
}
`,
}),
});
const { data, errors } = await res.json();
expect(errors).toBeUndefined();
expect(data.insert_posts_one.title).toBe('My Post');
expect(data.insert_posts_one.status).toBe('draft');
});
it('user cannot read another user's posts', async () => {
// Create a post as another user (using admin secret)
await fetch('http://localhost:1337/v1/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-hasura-admin-secret': 'nhost-admin-secret',
},
body: JSON.stringify({
query: `
mutation {
insert_posts_one(object: {
title: "Other User Post",
content: "",
status: "draft",
author_id: "00000000-0000-0000-0000-000000000001"
}) { id }
}
`,
}),
});
// Try to read it as our test user
const res = await fetch('http://localhost:1337/v1/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${userToken}`,
},
body: JSON.stringify({ query: '{ posts { id title } }' }),
});
const { data } = await res.json();
// Row-level security: user should only see their own posts
expect(data.posts.every((p: any) => p.title !== 'Other User Post')).toBe(true);
});
});Testing Hasura Permissions
Hasura's row-level permissions are defined in your nhost/metadata/ YAML files. Testing them is critical — a misconfigured permission silently exposes data.
// tests/integration/permissions.test.ts
const ADMIN_HEADERS = {
'Content-Type': 'application/json',
'x-hasura-admin-secret': 'nhost-admin-secret',
};
async function graphqlAs(query: string, role: 'user' | 'editor', userId?: string) {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-hasura-role': role,
};
if (userId) {
headers['x-hasura-user-id'] = userId;
}
const res = await fetch('http://localhost:1337/v1/graphql', {
method: 'POST',
headers,
body: JSON.stringify({ query }),
});
return res.json();
}
describe('Hasura permissions', () => {
it('editors can update any post, users can only update their own', async () => {
// Create a post by user-1
const { data: createData } = await fetch('http://localhost:1337/v1/graphql', {
method: 'POST',
headers: ADMIN_HEADERS,
body: JSON.stringify({
query: `
mutation {
insert_posts_one(object: {
title: "User 1 Post", content: "", status: "draft",
author_id: "user-id-1"
}) { id }
}
`,
}),
}).then(r => r.json());
const postId = createData.insert_posts_one.id;
// User-1 can update their own post
const userUpdate = await graphqlAs(
`mutation { update_posts_by_pk(pk_columns: {id: "${postId}"}, _set: {title: "Updated"}) { title } }`,
'user',
'user-id-1',
);
expect(userUpdate.errors).toBeUndefined();
expect(userUpdate.data.update_posts_by_pk.title).toBe('Updated');
// User-2 cannot update user-1's post
const otherUserUpdate = await graphqlAs(
`mutation { update_posts_by_pk(pk_columns: {id: "${postId}"}, _set: {title: "Hijacked"}) { title } }`,
'user',
'user-id-2',
);
expect(otherUserUpdate.data.update_posts_by_pk).toBeNull();
// Editor can update any post
const editorUpdate = await graphqlAs(
`mutation { update_posts_by_pk(pk_columns: {id: "${postId}"}, _set: {title: "Editor Update"}) { title } }`,
'editor',
);
expect(editorUpdate.errors).toBeUndefined();
});
});Testing Nhost Auth
// tests/integration/auth.test.ts
import { NhostClient } from '@nhost/nhost-js';
const nhost = new NhostClient({ /* local config */ });
const email = `e2e-${Date.now()}@test.com`;
const password = 'TestPass123!';
describe('Nhost Auth', () => {
it('signs up a new user', async () => {
const { session, error } = await nhost.auth.signUp({ email, password });
expect(error).toBeNull();
expect(session).not.toBeNull();
expect(session!.user.email).toBe(email);
});
it('signs in with valid credentials', async () => {
const { session, error } = await nhost.auth.signIn({ email, password });
expect(error).toBeNull();
expect(session!.accessToken).toBeDefined();
expect(nhost.auth.isAuthenticated()).toBe(true);
});
it('rejects invalid credentials', async () => {
const { error } = await nhost.auth.signIn({ email, password: 'wrongpassword' });
expect(error).not.toBeNull();
expect(error!.message).toMatch(/invalid/i);
});
it('signs out and clears session', async () => {
await nhost.auth.signIn({ email, password });
await nhost.auth.signOut();
expect(nhost.auth.isAuthenticated()).toBe(false);
expect(nhost.auth.getAccessToken()).toBeNull();
});
});Testing File Storage
// tests/integration/storage.test.ts
import { NhostClient } from '@nhost/nhost-js';
import * as fs from 'fs';
const nhost = new NhostClient({ /* local config */ });
describe('Nhost Storage', () => {
let uploadedFileId: string;
beforeAll(async () => {
await nhost.auth.signIn({ email: 'test@example.com', password: 'TestPass123!' });
});
it('uploads a file and returns a file ID', async () => {
const file = new File(['hello world'], 'test.txt', { type: 'text/plain' });
const { fileMetadata, error } = await nhost.storage.upload({ file });
expect(error).toBeNull();
expect(fileMetadata).not.toBeNull();
expect(fileMetadata!.id).toBeDefined();
uploadedFileId = fileMetadata!.id;
});
it('generates a public URL for the uploaded file', () => {
const url = nhost.storage.getPublicUrl({ fileId: uploadedFileId });
expect(url).toContain(uploadedFileId);
});
it('deletes the file', async () => {
const { error } = await nhost.storage.delete({ fileId: uploadedFileId });
expect(error).toBeNull();
});
});CI with Local Nhost
# .github/workflows/integration.yml
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start Nhost local environment
run: |
npm install -g nhost-cli
nhost dev --detach
# Wait for services to be ready
timeout 60 bash -c 'until curl -sf http://localhost:1337/healthz; do sleep 2; done'
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run test:unit
- run: npm run test:integrationTesting Serverless Functions
Nhost Functions are Node.js/TypeScript functions in functions/. Unit test them directly since they're just Express-compatible handlers:
// functions/send-welcome-email.ts
import type { Request, Response } from 'express';
import { sendEmail } from '../src/email';
export default async function handler(req: Request, res: Response) {
const { userId, email } = req.body;
if (!userId || !email) {
return res.status(400).json({ error: 'userId and email are required' });
}
await sendEmail({ to: email, template: 'welcome', data: { userId } });
res.json({ success: true });
}// functions/send-welcome-email.test.ts
import { createMocks } from 'node-mocks-http';
import handler from './send-welcome-email';
jest.mock('../src/email');
import { sendEmail } from '../src/email';
describe('send-welcome-email function', () => {
it('sends email for valid payload', async () => {
const { req, res } = createMocks({
method: 'POST',
body: { userId: 'user-1', email: 'user@example.com' },
});
await handler(req, res);
expect(res._getStatusCode()).toBe(200);
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ to: 'user@example.com', template: 'welcome' }),
);
});
it('returns 400 for missing fields', async () => {
const { req, res } = createMocks({ method: 'POST', body: {} });
await handler(req, res);
expect(res._getStatusCode()).toBe(400);
});
});Key Testing Principles for Nhost Apps
Test permissions, not just data. Hasura's row-level security is powerful but easy to misconfigure. Dedicate explicit tests to permission scenarios: can users see only their own data? Can editors publish but not delete? Can anonymous users only see public records?
Use the admin secret sparingly. The x-hasura-admin-secret header bypasses all permissions. Use it only for test setup/teardown (seeding data, cleaning up), never in assertions about what regular users can do.
Keep migrations in version control. The nhost/migrations/ directory is your schema history. If a migration breaks something, your integration tests will catch it immediately on the next nhost dev start.
Separate unit and integration test suites. Unit tests (mocked GraphQL client) should run in milliseconds. Integration tests (real local Nhost) can be slower — run them in separate CI steps so unit test failures give fast feedback.
Teams monitoring Nhost-backed applications in production use HelpMeTest to run GraphQL smoke tests on a schedule — detecting when schema changes or permission updates break critical user flows before users report them.