Unit and integration tests help you confirm behavior before deploys and reduce regressions as your app changes.
This guide covers unit and integration testing for Gadget apps. Unit tests exercise isolated functions and components without calling the Gadget backend. Integration tests call your Gadget actions and HTTP routes through the API client and validate responses against a real environment.
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 and integration 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:
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
Steps 5 and 6 are only required for integration tests that call Gadget actions or HTTP routes. If you are writing unit tests only, you can skip them.
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.
Unit tests
Unit tests exercise functions and components in isolation without calling the Gadget backend.
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 frontend components
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,
});
});
});
Integration tests
Integration tests call your Gadget app through the API client and validate responses against a real environment. These tests require the test API client configured in step 6.
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" });
});
});
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.