# 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.