Unit testing 

Unit tests help you confirm behavior before deploys and reduce regressions as your app changes.

This guide shows a React Router based setup that matches the current Gadget unit test template, with examples for utility functions, Gadget API calls, HTTP routes, and frontend components.

Sample project

You can use the unit test example repository as a reference implementation for this guide.

Set up local testing 

Complete these steps in your local Gadget app directory after you sync your app locally with ggt dev.

This is just one example of how to set up unit testing in a Gadget app. You can use other frameworks or libraries to set up your tests.

1. Install test dependencies 

Install Vitest, Testing Library, jsdom, dotenv, the Vite React plugin, and Testing Library DOM matchers:

terminal
yarn add -D vitest @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom @vitejs/plugin-react jsdom dotenv

2. Add a test script 

Add a test script to your package.json file:

json
{ "scripts": { "build": "NODE_ENV=production react-router build", "test": "vitest" } }

3. Configure Vite for Vitest and React Router 

Update vite.config.mjs to use Vitest projects. This keeps API tests in a Node environment and frontend tests in a jsdom environment with the React Vite plugin.

vite.config.mjs
TypeScript
1~import { defineConfig } from "vite";
1~import { defineConfig } from "vitest/config";
22 import { gadget } from "gadget-server/vite";
33 import { reactRouter } from "@react-router/dev/vite";
44 import path from "path";
5+import react from "@vitejs/plugin-react";
6+import dotenv from "dotenv";
57
8+dotenv.config({ path: ".env.local" });
9+
610 export default defineConfig({
711 plugins: [gadget(), reactRouter()],
812 resolve: {
913 alias: {
1014 "@": path.resolve(__dirname, "./web"),
1115 },
1216 },
17+ test: {
18+ projects: [
19+ {
20+ test: {
21+ name: "api",
22+ include: ["tests/api/**/*.test.ts"],
23+ environment: "node",
24+ mockReset: true,
25+ },
26+ },
27+ {
28+ plugins: [gadget(), react()],
29+ resolve: {
30+ alias: {
31+ "@": path.resolve(__dirname, "./web"),
32+ },
33+ },
34+ test: {
35+ name: "web",
36+ include: ["tests/web/**/*.test.tsx"],
37+ environment: "jsdom",
38+ setupFiles: ["./tests/setup.ts"],
39+ mockReset: true,
40+ },
41+ },
42+ ],
43+ },
1344 });

4. Add test setup file 

Create tests/setup.js:

tests/setup.js
JavaScript
import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; import { afterEach } from "vitest"; afterEach(() => { cleanup(); });
import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; import { afterEach } from "vitest"; afterEach(() => { cleanup(); });

You can now run the test suite:

terminal
yarn test

To test your actions, you also need to set up an API client.

5. Add local environment variables 

Prevent committing secrets to source control or pushing them to your environment.

  1. Add .env.local to .ignore so Gadget CLI does not sync it.
  2. Add .env.local to .gitignore so you do not commit secrets.

Create a test API key in Gadget, then store it in a local environment file.

  1. In the Gadget editor, open Settings.
  2. Go to API keys.
  3. Click Create API key.
  4. Select the access role your tests should run as.
  5. Copy the key for your test environment.
  6. Create a .env.local file in your project root:
terminal
# in .env.local GADGET_API_KEY=gsk-example-api-key GADGET_ENVIRONMENT=development
API key scope

Use a key with the minimum role needed for your tests. For cleanup helpers that call internal APIs such as deleteMany, you need a key with unrestricted access.

6. Add a test API client 

Create tests/api.js:

tests/api.js
JavaScript
import { Client } from "@gadget-client/example-app"; // Create a test API client instance export const api = new Client({ environment: process.env.GADGET_ENVIRONMENT, authenticationMode: { apiKey: process.env.GADGET_API_KEY, }, });
import { Client } from "@gadget-client/example-app"; // Create a test API client instance export const api = new Client({ environment: process.env.GADGET_ENVIRONMENT, authenticationMode: { apiKey: process.env.GADGET_API_KEY, }, });

You can use this client to call actions from tests. This allows you to call actions, examine their responses, and employ test spies and mocks, similar to how you would in other Node projects.

Testing actions 

You can call model actions and global actions through your test API client, and validate their responses.

Test a model action 

tests/api/post/actions/create.test.js
JavaScript
import { describe, expect, test } from "vitest"; import { api } from "../../../api"; describe("post.create", () => { test("creates a post record", async () => { const result = await api.post.create({ title: "Unit Test Post", }); expect(result.id).toBeDefined(); expect(result.title).toBe("Unit Test Post"); }); });
import { describe, expect, test } from "vitest"; import { api } from "../../../api"; describe("post.create", () => { test("creates a post record", async () => { const result = await api.post.create({ title: "Unit Test Post", }); expect(result.id).toBeDefined(); expect(result.title).toBe("Unit Test Post"); }); });

Clean up records created by tests 

Action tests that call create will add records to your test environment's database. Add a cleanup step so repeated test runs start from a known state.

tests/api/post/actions/create.test.js
JavaScript
import { beforeAll, describe, expect, test } from "vitest"; import { api } from "../../../api"; beforeAll(async () => { await api.internal.post.deleteMany({ filter: { title: { equals: "Unit Test Post" } }, }); }); describe("post.create", () => { test("creates a post record", async () => { const result = await api.post.create({ title: "Unit Test Post", }); expect(result.id).toBeDefined(); }); });
import { beforeAll, describe, expect, test } from "vitest"; import { api } from "../../../api"; beforeAll(async () => { await api.internal.post.deleteMany({ filter: { title: { equals: "Unit Test Post" } }, }); }); describe("post.create", () => { test("creates a post record", async () => { const result = await api.post.create({ title: "Unit Test Post", }); expect(result.id).toBeDefined(); }); });
Use a dedicated test environment

api.internal.*.deleteMany can remove many records quickly. It is often best to test against a dedicated test environment to prevent data loss on other environments.

Test a global action or computed view 

The API client can also be used to test global actions and computed views.

tests/api/actions/getPostStats.test.js
JavaScript
import { describe, expect, test } from "vitest"; import { api } from "../../api"; describe("getPostStats", () => { test("returns blog statistics", async () => { const result = await api.getPostStats(); expect(result.stats.totalPosts).toBeDefined(); }); });
import { describe, expect, test } from "vitest"; import { api } from "../../api"; describe("getPostStats", () => { test("returns blog statistics", async () => { const result = await api.getPostStats(); expect(result.stats.totalPosts).toBeDefined(); }); });

Test unauthenticated access 

You can create a second client without an API key to test unauthenticated behavior.

tests/api.js
JavaScript
import { Client } from "@gadget-client/example-app"; export const unauthenticatedApi = new Client({ environment: "development", });
import { Client } from "@gadget-client/example-app"; export const unauthenticatedApi = new Client({ environment: "development", });
tests/api/post/actions/create.test.js
JavaScript
import { describe, expect, test } from "vitest"; import { unauthenticatedApi } from "../../../api"; describe("post.create permissions", () => { test("rejects unauthenticated calls", async () => { await expect( unauthenticatedApi.post.create({ title: "Unit Test Post", }) ).rejects.toThrowError("GGT_PERMISSION_DENIED"); }); });
import { describe, expect, test } from "vitest"; import { unauthenticatedApi } from "../../../api"; describe("post.create permissions", () => { test("rejects unauthenticated calls", async () => { await expect( unauthenticatedApi.post.create({ title: "Unit Test Post", }) ).rejects.toThrowError("GGT_PERMISSION_DENIED"); }); });

Testing HTTP routes 

Use api.fetch() to test HTTP routes in your Gadget backend.

tests/api/routes/GET-hello.test.js
JavaScript
import { describe, expect, test } from "vitest"; import { api } from "../../api"; describe("GET-hello route", () => { test("returns a successful response", async () => { const response = await api.fetch("/hello"); expect(response.status).toBe(200); expect(await response.json()).toEqual({ hello: "world" }); }); });
import { describe, expect, test } from "vitest"; import { api } from "../../api"; describe("GET-hello route", () => { test("returns a successful response", async () => { const response = await api.fetch("/hello"); expect(response.status).toBe(200); expect(await response.json()).toEqual({ hello: "world" }); }); });

Testing utility functions 

Utility functions do not need a Gadget API client.

tests/api/post/utils/getWordCount.test.js
JavaScript
import { expect, test } from "vitest"; import { getWordCount } from "../../../../api/models/post/utils/getWordCount"; test("returns the word count", () => { expect(getWordCount("Hello world")).toBe(2); }); test("returns zero for empty input", () => { expect(getWordCount("")).toBe(0); });
import { expect, test } from "vitest"; import { getWordCount } from "../../../../api/models/post/utils/getWordCount"; test("returns the word count", () => { expect(getWordCount("Hello world")).toBe(2); }); test("returns zero for empty input", () => { expect(getWordCount("")).toBe(0); });

Testing frontends with React Router 

For React Router apps, prefer component tests that cover user flows. Focus on behavior and outcomes such as validation and action payloads. You can also include a small style smoke test for critical UI states.

tests/web/components/post-editor.test.jsx
React
import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { PostEditor } from "../../../web/components/post-editor"; describe("PostEditor", () => { it("renders the save button with primary styles", () => { render(<PostEditor onSubmit={vi.fn()} />); expect(screen.getByRole("button", { name: /save post/i }).className).toContain("bg-primary"); }); it("shows a validation message when title is empty", async () => { const user = userEvent.setup(); render(<PostEditor onSubmit={vi.fn()} />); await user.click(screen.getByRole("button", { name: /save post/i })); expect(screen.getByText("Title is required")).toBeInTheDocument(); }); it("submits the expected payload", async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(<PostEditor onSubmit={onSubmit} />); await user.type(screen.getByLabelText(/title/i), "Unit Test Post"); await user.type(screen.getByLabelText(/content/i), "A test post body"); await user.click(screen.getByRole("checkbox", { name: /publish now/i })); await user.click(screen.getByRole("button", { name: /save post/i })); expect(onSubmit).toHaveBeenCalledWith({ title: "Unit Test Post", content: "A test post body", publishNow: true, }); }); });
import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { PostEditor } from "../../../web/components/post-editor"; describe("PostEditor", () => { it("renders the save button with primary styles", () => { render(<PostEditor onSubmit={vi.fn()} />); expect(screen.getByRole("button", { name: /save post/i }).className).toContain("bg-primary"); }); it("shows a validation message when title is empty", async () => { const user = userEvent.setup(); render(<PostEditor onSubmit={vi.fn()} />); await user.click(screen.getByRole("button", { name: /save post/i })); expect(screen.getByText("Title is required")).toBeInTheDocument(); }); it("submits the expected payload", async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(<PostEditor onSubmit={onSubmit} />); await user.type(screen.getByLabelText(/title/i), "Unit Test Post"); await user.type(screen.getByLabelText(/content/i), "A test post body"); await user.click(screen.getByRole("checkbox", { name: /publish now/i })); await user.click(screen.getByRole("button", { name: /save post/i })); expect(onSubmit).toHaveBeenCalledWith({ title: "Unit Test Post", content: "A test post body", publishNow: true, }); }); });

Running tests in CI 

Run tests in CI before you deploy to production.

GitHub Actions example 

.github/workflows/run-tests.yml
yaml
name: Unit tests on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest env: GADGET_API_KEY: ${{ secrets.GADGET_API_KEY }} GADGET_ENVIRONMENT: staging steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20" - name: Install dependencies run: yarn --frozen-lockfile - name: Run tests run: yarn test

Store your Gadget API key as a repository secret named GADGET_API_KEY.

You can also use ggt with a CLI token to push changes to a staging environment and run tests against that deployment. See the CI/CD docs for more information.

Was this page helpful?