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.
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";
22import { gadget } from "gadget-server/vite";
33import { reactRouter } from "@react-router/dev/vite";
44import path from "path";
5+import react from "@vitejs/plugin-react";
6+import dotenv from "dotenv";
57
8+dotenv.config({ path: ".env.local" });
9+
610export 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.
Add .env.local to .ignore so Gadget CLI does not sync it.
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.
In the Gadget editor, open Settings.
Go to API keys.
Click Create API key.
Select the access role your tests should run as.
Copy the key for your test environment.
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,
});
});
});
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.