# Unit and integration testing
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.
You can use the [unit test example repository](https://github.com/gadget-inc/unit-test-example/tree/nicholas/unit-test-template-update) 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](https://docs.gadget.dev/guides/development-tools/cli).
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:
```bash
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
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.
```typescript
import { defineConfig } from "vitest/config";
import { gadget } from "gadget-server/vite";
import { reactRouter } from "@react-router/dev/vite";
import path from "path";
import react from "@vitejs/plugin-react";
import dotenv from "dotenv";
dotenv.config({ path: ".env.local" });
export default defineConfig({
plugins: [gadget(), reactRouter()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./web"),
},
},
test: {
projects: [
{
test: {
name: "api",
include: ["tests/api/**/*.test.ts"],
environment: "node",
mockReset: true,
},
},
{
plugins: [gadget(), react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./web"),
},
},
test: {
name: "web",
include: ["tests/web/**/*.test.tsx"],
environment: "jsdom",
setupFiles: ["./tests/setup.ts"],
mockReset: true,
},
},
],
},
});
```
### 4\. Add test setup file
Create `tests/setup.js`:
```typescript
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:
```bash
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.
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.
3. In the Gadget editor, open **Settings**.
4. Go to **API keys**.
5. Click **Create API key**.
6. Select the access role your tests should run as.
7. Copy the key for your test environment.
8. Create a `.env.local` file in your project root:
```bash
# in .env.local
GADGET_API_KEY=gsk-example-api-key
GADGET_ENVIRONMENT=development
```
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`:
```typescript
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.
```typescript
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.
```tsx
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();
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();
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();
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
```typescript
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.
```typescript
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();
});
});
```
`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.
```typescript
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.
```typescript
import { Client } from "@gadget-client/example-app";
export const unauthenticatedApi = new Client({
environment: "development",
});
```
```typescript
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.
```typescript
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" });
});
});
```
## Running tests in CI
Run tests in CI before you deploy to production.
### GitHub Actions example
```yaml
// in .github/workflows/run-tests.yml
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](https://docs.gadget.dev/guides/environments/ci-cd) for more information.