Appwrite Testing Guide: Unit, Integration, and E2E Tests

Appwrite Testing Guide: Unit, Integration, and E2E Tests

Appwrite is an open-source backend platform that packages auth, databases, storage, functions, and messaging into a single self-hostable (or cloud) service. It's popular with teams who want Firebase-style convenience without the Google dependency. Testing Appwrite-backed applications, however, isn't always obvious — the SDK is designed for browser and server environments, and most tutorials assume you're calling the real service.

This guide covers three layers of testing: mocking the Appwrite SDK for fast unit tests, running integration tests against a local Appwrite instance via Docker, and writing end-to-end tests for real user flows.

The Three Testing Layers

Before writing any tests, decide which layer covers which risk:

Layer What it tests Speed Where Appwrite runs
Unit Business logic using Appwrite data Fast Mocked
Integration SDK calls, queries, permissions Medium Docker locally
E2E Full user flows in the browser Slow Docker or staging

Start with unit tests for business logic, integration tests for database queries and auth rules, and E2E tests for critical user journeys.

Unit Testing: Mocking the Appwrite SDK

The Appwrite SDK (appwrite for browsers, node-appwrite for Node.js servers) is straightforward to mock with Jest or Vitest.

Mocking the Client and Databases

// __mocks__/node-appwrite.ts
export const Client = jest.fn().mockImplementation(() => ({
  setEndpoint: jest.fn().mockReturnThis(),
  setProject: jest.fn().mockReturnThis(),
  setKey: jest.fn().mockReturnThis(),
}));

export const Databases = jest.fn().mockImplementation(() => ({
  listDocuments: jest.fn(),
  getDocument: jest.fn(),
  createDocument: jest.fn(),
  updateDocument: jest.fn(),
  deleteDocument: jest.fn(),
}));

export const Query = {
  equal: jest.fn((attr, value) => `equal("${attr}", "${value}")`),
  greaterThan: jest.fn((attr, value) => `greaterThan("${attr}", ${value})`),
  orderDesc: jest.fn((attr) => `orderDesc("${attr}")`),
  limit: jest.fn((n) => `limit(${n})`),
};

export const ID = {
  unique: jest.fn(() => 'mock-unique-id'),
};

Testing a Service Layer

Suppose you have a PostService that wraps Appwrite database calls:

// src/services/PostService.ts
import { Databases, Query, ID } from 'node-appwrite';

export class PostService {
  constructor(
    private db: Databases,
    private databaseId: string,
    private collectionId: string,
  ) {}

  async getPublishedPosts(limit = 10) {
    const result = await this.db.listDocuments(
      this.databaseId,
      this.collectionId,
      [
        Query.equal('status', 'published'),
        Query.orderDesc('publishedAt'),
        Query.limit(limit),
      ],
    );
    return result.documents;
  }

  async createPost(data: { title: string; content: string; authorId: string }) {
    return this.db.createDocument(
      this.databaseId,
      this.collectionId,
      ID.unique(),
      { ...data, status: 'draft', createdAt: new Date().toISOString() },
    );
  }
}

Testing this service with mocks:

// src/services/PostService.test.ts
import { Databases } from 'node-appwrite';
import { PostService } from './PostService';

jest.mock('node-appwrite');

const mockDb = new Databases(null as any) as jest.Mocked<Databases>;
const service = new PostService(mockDb, 'cms-db', 'posts');

describe('PostService', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  describe('getPublishedPosts', () => {
    it('returns published posts ordered by date', async () => {
      const mockDocs = [
        { $id: '1', title: 'First Post', status: 'published' },
        { $id: '2', title: 'Second Post', status: 'published' },
      ];
      mockDb.listDocuments.mockResolvedValue({
        documents: mockDocs,
        total: 2,
      } as any);

      const posts = await service.getPublishedPosts(10);

      expect(mockDb.listDocuments).toHaveBeenCalledWith(
        'cms-db',
        'posts',
        expect.arrayContaining([
          expect.stringContaining('equal("status", "published")'),
        ]),
      );
      expect(posts).toHaveLength(2);
      expect(posts[0].title).toBe('First Post');
    });

    it('applies the limit parameter', async () => {
      mockDb.listDocuments.mockResolvedValue({ documents: [], total: 0 } as any);

      await service.getPublishedPosts(5);

      expect(mockDb.listDocuments).toHaveBeenCalledWith(
        'cms-db',
        'posts',
        expect.arrayContaining([expect.stringContaining('limit(5)')]),
      );
    });
  });

  describe('createPost', () => {
    it('creates a draft post with unique ID', async () => {
      const mockPost = { $id: 'mock-unique-id', title: 'New Post' };
      mockDb.createDocument.mockResolvedValue(mockPost as any);

      const result = await service.createPost({
        title: 'New Post',
        content: 'Body',
        authorId: 'user-123',
      });

      expect(mockDb.createDocument).toHaveBeenCalledWith(
        'cms-db',
        'posts',
        'mock-unique-id',
        expect.objectContaining({ status: 'draft', title: 'New Post' }),
      );
      expect(result.$id).toBe('mock-unique-id');
    });
  });
});

Integration Testing: Local Appwrite with Docker

For integration tests that exercise the real Appwrite API, run Appwrite locally via Docker Compose.

Docker Compose Setup

Create docker-compose.test.yml:

version: '3'

services:
  appwrite:
    image: appwrite/appwrite:1.5
    ports:
      - "80:80"
    environment:
      - _APP_ENV=test
      - _APP_OPENSSL_KEY_V1=your-secret-key
      - _APP_DOMAIN=localhost
      - _APP_DB_SCHEMA=appwrite
      - _APP_DB_USER=user
      - _APP_DB_PASS=password
      - _APP_REDIS_HOST=redis
      - _APP_DB_HOST=mariadb
    depends_on:
      - mariadb
      - redis

  mariadb:
    image: mariadb:10.7
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: appwrite
      MYSQL_USER: user
      MYSQL_PASSWORD: password

  redis:
    image: redis:7-alpine

Test Setup with the Node SDK

// tests/integration/setup.ts
import { Client, Databases, ID, Users, Account } from 'node-appwrite';

export function createTestClient() {
  const client = new Client()
    .setEndpoint(process.env.APPWRITE_ENDPOINT ?? 'http://localhost/v1')
    .setProject(process.env.APPWRITE_PROJECT_ID ?? 'test-project')
    .setKey(process.env.APPWRITE_API_KEY ?? 'test-api-key');

  return client;
}

export async function seedDatabase(
  db: Databases,
  databaseId: string,
  collectionId: string,
  documents: Record<string, unknown>[],
) {
  const created = [];
  for (const doc of documents) {
    const result = await db.createDocument(
      databaseId,
      collectionId,
      ID.unique(),
      doc,
    );
    created.push(result);
  }
  return created;
}

export async function cleanCollection(
  db: Databases,
  databaseId: string,
  collectionId: string,
) {
  const result = await db.listDocuments(databaseId, collectionId);
  for (const doc of result.documents) {
    await db.deleteDocument(databaseId, collectionId, doc.$id);
  }
}

Integration Tests for Database Operations

// tests/integration/posts.test.ts
import { Databases, Query } from 'node-appwrite';
import { createTestClient, seedDatabase, cleanCollection } from './setup';

const DATABASE_ID = 'cms-db';
const COLLECTION_ID = 'posts';

describe('Posts collection (integration)', () => {
  let db: Databases;

  beforeAll(() => {
    const client = createTestClient();
    db = new Databases(client);
  });

  afterEach(async () => {
    await cleanCollection(db, DATABASE_ID, COLLECTION_ID);
  });

  it('can create and retrieve a document', async () => {
    const created = await db.createDocument(DATABASE_ID, COLLECTION_ID, 'post-1', {
      title: 'Test Post',
      status: 'published',
      authorId: 'user-1',
    });

    expect(created.$id).toBe('post-1');

    const retrieved = await db.getDocument(DATABASE_ID, COLLECTION_ID, 'post-1');
    expect(retrieved.title).toBe('Test Post');
  });

  it('filters by status correctly', async () => {
    await seedDatabase(db, DATABASE_ID, COLLECTION_ID, [
      { title: 'Published', status: 'published', authorId: 'u1' },
      { title: 'Draft', status: 'draft', authorId: 'u1' },
      { title: 'Archived', status: 'archived', authorId: 'u1' },
    ]);

    const result = await db.listDocuments(DATABASE_ID, COLLECTION_ID, [
      Query.equal('status', 'published'),
    ]);

    expect(result.documents).toHaveLength(1);
    expect(result.documents[0].title).toBe('Published');
  });

  it('enforces collection permissions', async () => {
    // Create a document as admin (API key)
    await db.createDocument(DATABASE_ID, COLLECTION_ID, 'restricted', {
      title: 'Admin Only',
      status: 'internal',
      authorId: 'admin',
    });

    // A user-scoped client should not be able to read it
    // (assuming collection has no public read permission)
    // Test using a client scoped to a regular user session
    // This test verifies your permission rules work as intended
    expect(true).toBe(true); // Replace with actual user-scoped assertion
  });
});

Testing Appwrite Auth

Auth flows are the most critical part of any Appwrite app. Test them at the integration level using the real Appwrite auth endpoints.

// tests/integration/auth.test.ts
import { Client, Account, ID } from 'appwrite'; // browser SDK

describe('Auth flows (integration)', () => {
  let client: Client;
  let account: Account;
  const testEmail = `test-${Date.now()}@example.com`;
  const testPassword = 'TestPassword123!';

  beforeAll(() => {
    client = new Client()
      .setEndpoint('http://localhost/v1')
      .setProject('test-project');
    account = new Account(client);
  });

  it('can register a new user', async () => {
    const user = await account.create(
      ID.unique(),
      testEmail,
      testPassword,
      'Test User',
    );

    expect(user.email).toBe(testEmail);
    expect(user.name).toBe('Test User');
    expect(user.$id).toBeDefined();
  });

  it('can log in with valid credentials', async () => {
    await account.createEmailPasswordSession(testEmail, testPassword);
    const session = await account.get();

    expect(session.email).toBe(testEmail);
  });

  it('rejects invalid credentials', async () => {
    await expect(
      account.createEmailPasswordSession(testEmail, 'wrongpassword'),
    ).rejects.toMatchObject({
      code: 401,
    });
  });

  it('can log out', async () => {
    await account.createEmailPasswordSession(testEmail, testPassword);
    await account.deleteSession('current');

    await expect(account.get()).rejects.toMatchObject({ code: 401 });
  });
});

Testing Appwrite Functions

Appwrite Functions are serverless — you can test them by deploying to your local instance and invoking via the SDK.

// tests/integration/functions.test.ts
import { Client, Functions } from 'node-appwrite';

describe('process-payment function', () => {
  let functions: Functions;

  beforeAll(() => {
    const client = new Client()
      .setEndpoint('http://localhost/v1')
      .setProject('test-project')
      .setKey('test-api-key');
    functions = new Functions(client);
  });

  it('processes a valid payment payload', async () => {
    const execution = await functions.createExecution(
      'process-payment',
      JSON.stringify({ amount: 2999, currency: 'usd', userId: 'user-1' }),
      false, // async = false, wait for result
    );

    expect(execution.status).toBe('completed');
    const response = JSON.parse(execution.responseBody);
    expect(response.success).toBe(true);
  });

  it('rejects missing required fields', async () => {
    const execution = await functions.createExecution(
      'process-payment',
      JSON.stringify({ userId: 'user-1' }), // missing amount and currency
      false,
    );

    expect(execution.status).toBe('completed');
    const response = JSON.parse(execution.responseBody);
    expect(response.error).toMatch(/amount.*required/i);
  });
});

End-to-End Testing with Playwright

For full browser flows against a local Appwrite instance:

// tests/e2e/signup.spec.ts
import { test, expect } from '@playwright/test';

test.describe('User registration', () => {
  test('new user can sign up and land on dashboard', async ({ page }) => {
    const email = `user-${Date.now()}@test.com`;

    await page.goto('/signup');
    await page.fill('[name=email]', email);
    await page.fill('[name=password]', 'SecurePass123!');
    await page.fill('[name=name]', 'E2E Tester');
    await page.click('[type=submit]');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('[data-testid=welcome-message]')).toContainText(
      'E2E Tester',
    );
  });

  test('duplicate email shows error', async ({ page }) => {
    await page.goto('/signup');
    await page.fill('[name=email]', 'existing@example.com'); // pre-seeded
    await page.fill('[name=password]', 'SecurePass123!');
    await page.click('[type=submit]');

    await expect(page.locator('[data-testid=error-message]')).toBeVisible();
    await expect(page.locator('[data-testid=error-message]')).toContainText(
      'already exists',
    );
  });
});

CI Configuration

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      appwrite:
        image: appwrite/appwrite:1.5
        ports:
          - 80:80
        env:
          _APP_ENV: test
          _APP_OPENSSL_KEY_V1: ${{ secrets.APPWRITE_KEY }}
          _APP_DOMAIN: localhost

    env:
      APPWRITE_ENDPOINT: http://localhost/v1
      APPWRITE_PROJECT_ID: ${{ secrets.APPWRITE_PROJECT_ID }}
      APPWRITE_API_KEY: ${{ secrets.APPWRITE_API_KEY }}

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run test:unit
      - run: npm run test:integration
      - run: npx playwright install --with-deps
      - run: npm run test:e2e

Key Testing Patterns

Isolate test data per test. Use afterEach to clean collections, or create a fresh collection per test suite. Appwrite lets you create collections programmatically, so spinning up isolated namespaces is feasible.

Don't test Appwrite's internals. Trust that account.create() creates a user. Test your business logic: what happens after a user is created? What does your app do with the returned user object?

Mock at the SDK boundary, not deeper. If you're testing a service class that calls db.listDocuments, mock listDocuments, not the internal fetch calls. Your mock should match what the SDK actually returns.

Test permissions explicitly. Appwrite's collection-level permissions are easy to misconfigure. Write explicit integration tests that verify a regular user can't read admin-only documents, and that data isolation between users is working.

Use Appwrite's local emulator for CI. The Docker Compose setup above gives you a full Appwrite instance in CI with no external dependencies. It's slower than mocking but catches permission bugs and query edge cases that unit tests miss.

For teams running continuous testing at scale, HelpMeTest lets you monitor your Appwrite-backed app 24/7 with automated tests that run on a schedule — catching auth regressions and database permission issues before your users do.

Read more