Backend

Testing

pytest against a real MongoDB for the backend, Vitest and the mock backend for the frontend.

Test the backend against a real MongoDB, so queries, indexes and transactions behave as in production. Test the frontend against the mock backend of pleate-client, an in memory implementation of every endpoint with the same shapes, status codes and error codes, served by MSW.

Backend: pytest

Start the services once (docker compose up -d mongo redis), then use pytest-django with a settings module for tests:

tests/settings.py
import os
from myproject.settings import *  # noqa: F403
from pleate_backend.settings import database

DATABASES = database(
    os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27027/?directConnection=true"),
    name=os.environ.get("TEST_DB", "myproject"),
)
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]   # fast
MAILERS = {"default": {"BACKEND": "django.core.mail.backends.locmem.EmailBackend"}}
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
PLEATE = {**PLEATE, "FRONTEND_URL": "http://app.test", "EMAILS": {"ASYNC": False}}
pyproject.toml
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "tests.settings"
pythonpath = ["."]
tests/conftest.py
import pytest
from django.core.cache import cache
from rest_framework.test import APIClient

PASSWORD = "correct-horse-battery-9"

@pytest.fixture(autouse=True)
def _clear_cache():
    cache.clear()          # rate limit counters live in the cache
    yield
    cache.clear()

@pytest.fixture
def api():
    return APIClient()

@pytest.fixture
def user(db):
    from django.contrib.auth import get_user_model
    return get_user_model().objects.create_user(email="ada@example.com", password=PASSWORD, email_verified=True)

@pytest.fixture
def auth_api(api, user):
    api.force_authenticate(user)
    return api
tests/test_catalog.py
import pytest
from django.core import mail

pytestmark = pytest.mark.django_db

def test_products_search_and_order(auth_api):
    Product.objects.create(name="Oat flour", sku="OAT-1", category="Pantry", price="4.50", stock=3)
    Product.objects.create(name="Rye flour", sku="RYE-1", category="Pantry", price="3.90", stock=40)
    res = auth_api.get("/api/catalog/products", {"search": "flour", "ordering": "price", "low_stock": "true"})
    assert res.status_code == 200
    assert [p["sku"] for p in res.json()["results"]] == ["OAT-1"]

def test_unknown_ordering_is_rejected(auth_api):
    res = auth_api.get("/api/catalog/products", {"ordering": "sku"})
    assert res.json()["error"]["code"] == "invalid_ordering"

def test_signup_sends_verification(api):
    res = api.post("/api/auth/signup", {"email": "new@example.com", "password": "correct-horse-battery-9"})
    assert res.status_code == 201
    assert "/verify-email?token=" in mail.outbox[0].body
  • MongoDB cannot roll back Django's TestCase transactions, so each test flushes the database. Keep tests independent.
  • Run files in parallel processes on separate databases with the database name variable (TEST_DB=myproject_files pytest tests/test_files.py).
  • APIClient skips CSRF unless you create it with enforce_csrf_checks=True: do that in the tests of your login like views.
  • Mock outgoing HTTP (OAuth, webhooks, AI providers) with respx; the EchoProvider needs no mock at all.

Schema check

Keep the OpenAPI schema valid, since the TypeScript types are generated from it:

sh
DJANGO_SETTINGS_MODULE=tests.settings python -m django spectacular > /dev/null

Frontend: Vitest with the mock

pleate-client/mock exports the database and the MSW handlers. Run them on msw/node with no latency:

test/backend.ts
import { setupServer } from "msw/node";
import { createMockDb, createMockHandlers } from "pleate-client/mock";

export const BASE = "http://localhost/api";
export const db = createMockDb({ signedIn: false });
export const server = setupServer(
  ...createMockHandlers({ db, baseUrl: BASE, latency: 0, streamDelay: 0, liveNotificationInterval: 0 }),
);
vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "happy-dom",
    // Same origin as the mock API, so no CORS preflight
    environmentOptions: { happyDOM: { url: "http://localhost/" } },
  },
});
login.test.tsx
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient } from "@tanstack/react-query";
import { createPleateClient } from "pleate-client";
import { DEMO_PASSWORD, DEMO_TOTP_CODE, MFA_EMAIL } from "pleate-client/mock";
import { PleateProvider, useLogin } from "pleate-client/react";
import { afterAll, beforeAll, beforeEach, expect, it } from "vitest";
import { BASE, db, server } from "./backend";

beforeAll(() => server.listen({ onUnhandledFrame: "error" }));
beforeEach(() => db.reset());
afterAll(() => server.close());

function Wrapper({ children }: { children: React.ReactNode }) {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return (
    <PleateProvider client={createPleateClient({ baseUrl: BASE })} queryClient={queryClient}>
      {children}
    </PleateProvider>
  );
}

it("asks for the code, then signs in", async () => {
  const { result } = renderHook(() => useLogin(), { wrapper: Wrapper });
  await act(() => result.current.loginFormProps.onSubmit({ email: MFA_EMAIL, password: DEMO_PASSWORD, remember: false }));
  await waitFor(() => expect(result.current.mfaRequired).toBe(true));
  await act(() => result.current.twoFactorFormProps.onSubmit({ code: DEMO_TOTP_CODE, method: "otp" }));
  await waitFor(() => expect(db.auth.userId).not.toBeNull());
});

What the mock knows

ExportValueUse
DEMO_EMAIL"ada@plate.dev"Admin with data in every app.
MFA_EMAIL"mfa@plate.dev"Same password, with 2FA.
DEMO_PASSWORD"plate-demo-2026"Password of both.
DEMO_TOTP_CODE"123456"The TOTP code the mock accepts.
DEMO_BACKUP_CODE"abcd-efgh"A backup code.
VALID_TOKEN"valid-token"The only reset, verification or newsletter token accepted.

createMockDb(options) takes seed (deterministic data), signedIn (start as Ada, default true), requireEmailVerification and now. The database is a plain object: read it in assertions, change it to set up a case, reset() it between tests.

In the browser

The same mock runs in a Service Worker, which is how the live examples of these docs work. It is handy for Storybook, design reviews and frontend work before the backend exists.

sh
npx msw init public

msw/browser has no server build, so load the module that starts the worker on the client only, with next/dynamic and ssr: false:

app/mock-worker.tsx
"use client";
import { startMockWorker } from "pleate-client/mock/browser";
import { useEffect } from "react";

export default function MockWorker({ onReady }: { onReady: () => void }) {
  useEffect(() => void startMockWorker().then(onReady), [onReady]);
  return null;
}
app/providers.tsx
"use client";
import dynamic from "next/dynamic";
import { PleateProvider } from "pleate-client/react";
import { useState } from "react";

const MockWorker = dynamic(() => import("./mock-worker"), { ssr: false });
const MOCK = process.env.NEXT_PUBLIC_MOCK_API === "1";

export function Providers({ children }: { children: React.ReactNode }) {
  const [ready, setReady] = useState(!MOCK);
  return (
    <>
      {MOCK && <MockWorker onReady={() => setReady(true)} />}
      {ready && <PleateProvider options={{ baseUrl: "/api" }}>{children}</PleateProvider>}
    </>
  );
}