Unit testing 

Writing unit tests for your application is a great way to ensure that your application is working as expected, and helps prevent regressions when you make changes to your code. Unit tests are also a great way to document your code, as they provide a clear example of how your code should be used. Writing tests is a crucial part of the development process and should be done as early as possible, ideally before you deploy to Production.

Sample project

There is a sample project repo you can browse on GitHub. This project has a test folder with examples of unit tests for util functions, a Gadget action, an HTTP route, and frontend components. You can use this project as a reference when setting up your unit tests. It also has a CI/CD pipeline set up using GitHub Actions, which runs the test suite on every pull request or push to the main branch. The GitHub Actions workflow file is located in the .github/workflows folder.

Prefer a video?

Follow along to learn how to set up a unit test framework and add tests to your Gadget apps.

Set up local testing 

The following steps will allow you to run a test suite locally on your machine. This is useful for debugging and testing your application before you deploy it to Production. Note that this is just an example of one setup, you have the option to use different testing tools, frameworks, and CI/CD runners, but setup steps may be different. The Vitest test framework is used here because it works nicely with Vite which powers Gadget's React frontends.

Why test locally?

You could use Gadget's built-in command palette to run a unit test suite in the Gadget web editor, so why run tests locally? There are a few reasons:

  • Debugging tools: Running tests locally allows you to use your local development tools to debug your tests, which can be useful when you are writing tests for your application. Vitest runs with a watch mode by default, which enables test-driven development (TDD) by automatically running your tests when you make changes to your code. Using your local terminal allows for better control over this process compared to the Gadget editor.
  • Source control: Syncing your code to a local directory allows you to use source control tools like Git to track changes to your code and collaborate with others, while also allowing you to run tests for merges or pull requests using a CI/CD pipeline. We use GitHub Actions in this example to demonstrate how to set up a CI/CD pipeline for your tests.

Unit test runner setup 

  1. To get started, you'll need to clone your app files through Gadget's ggt CLI tool. This will copy your Gadget app's code files to a local directory on your machine. You can then run your tests against the files in this directory.

  2. Install dependencies: the Vitest testing framework, dotenv for environment variable management, and react-test-renderer for frontend snapshot testing:

run in the local directory of your Gadget app
yarn add vitest dotenv react-test-renderer
  1. Add a test script to your Gadget app's package.json file:
package.json
"scripts": {
"vite:build": "NODE_ENV=production vite build",
"test": "vitest"
}
  1. Modify your vite.config.mjs file to include a reference to the vitest types, and add a test config to the defineConfig function:
vite.config.mjs
JavaScript
1import * as vitest from "vitest";
2import react from "@vitejs/plugin-react-swc";
3import { defineConfig } from "vite";
4
5// set up test config to read from .env
6import dotenv from "dotenv";
7dotenv.config();
8
9export default defineConfig({
10 plugins: [react()],
11 clearScreen: false,
12 test: {
13 // this test config is added to the defineConfig function so env vars inside .env are available
14 setupFiles: ["dotenv/config"],
15 // reset mocks between tests
16 mockReset: true,
17 },
18});
1import * as vitest from "vitest";
2import react from "@vitejs/plugin-react-swc";
3import { defineConfig } from "vite";
4
5// set up test config to read from .env
6import dotenv from "dotenv";
7dotenv.config();
8
9export default defineConfig({
10 plugins: [react()],
11 clearScreen: false,
12 test: {
13 // this test config is added to the defineConfig function so env vars inside .env are available
14 setupFiles: ["dotenv/config"],
15 // reset mocks between tests
16 mockReset: true,
17 },
18});

The unit test runner is now set up! Run the following command to run your tests:

terminal
yarn test

If you are just testing util functions or frontend components, you can start writing tests now. If you want to test your Gadget API, such as model actions, global actions, or HTTP routes, you'll need to set up a test API client so you can make requests to the Development environment of your Gadget backend.

Test API client setup 

  1. Create a .ignore file at the root level of your Gadget project, which will be used to ignore syncing files back to Gadget
  2. Add .env to your .ignore file, this will ensure that the .env file is not cloned to Gadget when using ggt dev
Using source control?

If you are using source control for your Gadget project, you should also add .env to your .gitignore file so that your API key is not stored in your repository.

  1. Create a .env file at the root level of your Gadget project
  2. In the Gadget web editor, go to the API key page (Settings -> API Keys), create a new API key, and assign it to the access role you wish to test
  3. Copy the API key for the Development environment and add it as an environment variable in your local .env file
example entry in .env
GADGET_TEST_API_KEY=gsk-12121212121212121212121212121212
  1. Create a test API client for the Development environment somewhere in your project
test/api.js
JavaScript
1// make sure to replace "@gadget-client/unit-test-sample" with your Gadget API client!
2import { Client } from "@gadget-client/unit-test-sample";
3
4export const api = new Client({
5 // The environment the test API client will connect to
6 environment: process.env.NODE_ENV,
7 authenticationMode: {
8 // an API client created using the GADGET_TEST_API_KEY environment variable
9 apiKey: process.env["GADGET_TEST_API_KEY"],
10 },
11});
1// make sure to replace "@gadget-client/unit-test-sample" with your Gadget API client!
2import { Client } from "@gadget-client/unit-test-sample";
3
4export const api = new Client({
5 // The environment the test API client will connect to
6 environment: process.env.NODE_ENV,
7 authenticationMode: {
8 // an API client created using the GADGET_TEST_API_KEY environment variable
9 apiKey: process.env["GADGET_TEST_API_KEY"],
10 },
11});

The snippet above creates an API client using the API key stored in an environment variable. You can use this client to test Gadget actions and HTTP routes.

Testing actions 

To test actions within your Gadget app, use the designated test API client that's set up. This allows you to call actions, examine their responses, and employ test spies and mocks, similar to how you would in other Node projects. The following examples demonstrate how to test model actions, global actions, and permissions for unauthenticated access roles.

Testing model actions 

The following example shows how to test the creation of a post record, where post is a model used for blog posts. The test file imports a test API client and calls the post.create action to create a new post record in the database. It then tests the response to ensure that the post record was created successfully.

test/post/actions/create.test.js
JavaScript
1import { describe, expect, test } from "vitest";
2import { api } from "../../api";
3
4// this is an example of testing a Gadget model action
5// it makes an API call to the action using a test API client and adds records to the database
6// you can use this pattern to test model actions in Gadget
7describe("test the post.create action", () => {
8 test("should create a new post record", async () => {
9 const result = await api.post.create({
10 title: "Unit Test Post",
11 content: {
12 markdown: "This is a test post",
13 },
14 });
15 expect(result.id).toBeDefined();
16 expect(result.title).toBe("Unit Test Post");
17 expect(result.content).toMatchObject({
18 markdown: "This is a test post",
19 truncatedHTML: "<p>This is a test post</p> ",
20 });
21 expect(result.wordCount).toBe(5);
22 });
23});
1import { describe, expect, test } from "vitest";
2import { api } from "../../api";
3
4// this is an example of testing a Gadget model action
5// it makes an API call to the action using a test API client and adds records to the database
6// you can use this pattern to test model actions in Gadget
7describe("test the post.create action", () => {
8 test("should create a new post record", async () => {
9 const result = await api.post.create({
10 title: "Unit Test Post",
11 content: {
12 markdown: "This is a test post",
13 },
14 });
15 expect(result.id).toBeDefined();
16 expect(result.title).toBe("Unit Test Post");
17 expect(result.content).toMatchObject({
18 markdown: "This is a test post",
19 truncatedHTML: "<p>This is a test post</p> ",
20 });
21 expect(result.wordCount).toBe(5);
22 });
23});
Clean up data from test runs

When you call actions against your CRUD actions, especially create actions, you will be adding data to your Development database. You can clean up this data with a beforeAll test function that deletes the records you created in your tests. This will ensure that your database is clean before each test run.

The internal API has a deleteMany API that can be useful. To call deleteMany, the API key used when initializing your test API client must have the system-admin role. Any records that match a passed-in filter will be deleted from the database. If you pass in no arguments, all records in the database will be deleted. Make sure your API client is pointing to your Development environment when you call this API.

test/post/actions/create.test.js
JavaScript
1import { beforeAll } from "vitest";
2import { api } from "../../api";
3
4beforeAll(async () => {
5 // cleanup: delete all post records with name: "Unit Test Post" from old test runs
6 // api.internal.post.deleteMany() with no args will wipe the development db
7 // make sure your API client is set to the Development environment!!!
8 await api.internal.post.deleteMany({
9 filter: { title: { equals: "Unit Test Post" } },
10 });
11});
1import { beforeAll } from "vitest";
2import { api } from "../../api";
3
4beforeAll(async () => {
5 // cleanup: delete all post records with name: "Unit Test Post" from old test runs
6 // api.internal.post.deleteMany() with no args will wipe the development db
7 // make sure your API client is set to the Development environment!!!
8 await api.internal.post.deleteMany({
9 filter: { title: { equals: "Unit Test Post" } },
10 });
11});

Testing global actions 

The same pattern used for model action testing can be used to test global actions in Gadget:

test/actions/getPostStats.test.js
JavaScript
1import { describe, expect, test } from "vitest";
2import { api } from "../api";
3
4// this is an example of testing a Gadget global action
5// it makes an API call to the action using a test API client
6// you can use this pattern to test global actions in Gadget
7describe("test the getPostStats global action", () => {
8 test("should return some statistics on all blog posts", async () => {
9 const result = await api.getPostStats();
10 expect(result.stats).toMatchObject({
11 totalPosts: 63,
12 numAuthors: 6,
13 averageWordCount: 452,
14 });
15 });
16});
1import { describe, expect, test } from "vitest";
2import { api } from "../api";
3
4// this is an example of testing a Gadget global action
5// it makes an API call to the action using a test API client
6// you can use this pattern to test global actions in Gadget
7describe("test the getPostStats global action", () => {
8 test("should return some statistics on all blog posts", async () => {
9 const result = await api.getPostStats();
10 expect(result.stats).toMatchObject({
11 totalPosts: 63,
12 numAuthors: 6,
13 averageWordCount: 452,
14 });
15 });
16});

Testing unauthenticated access role permissions 

You can test your action with different access roles by creating a new API key and client with the role you want to test, or you can test the unauthenticated role by creating a new API client without an API key:

test/api.js
JavaScript
1// make sure to replace "@gadget-client/unit-test-sample" with your Gadget API client!
2import { Client } from "@gadget-client/unit-test-sample";
3
4// an unauthenticated API client
5// eslint-disable-next-line
6export const unauthenticatedApi = new Client({ environment: "Development" });
1// make sure to replace "@gadget-client/unit-test-sample" with your Gadget API client!
2import { Client } from "@gadget-client/unit-test-sample";
3
4// an unauthenticated API client
5// eslint-disable-next-line
6export const unauthenticatedApi = new Client({ environment: "Development" });

You can then use this client to test the unauthenticated role's access to your actions. For example, if you wanted to make sure that the post.create action is not callable for unauthenticated users, you could do the following:

test/post/actions/create.test.js
JavaScript
1import { describe, expect, test } from "vitest";
2import { api, unauthenticatedApi } from "../../api";
3
4// this is an example of testing a Gadget action
5describe("test the post.create action", () => {
6 // test with an unauthenticated user, should throw a GGT_PERMISSION_DENIED error
7 test("should throw error for unauthenticated user", async () => {
8 await expect(
9 unauthenticatedApi.post.create({
10 title: "Unit Test Post",
11 content: {
12 markdown: "This is a test post",
13 },
14 })
15 ).rejects.toThrowError("GGT_PERMISSION_DENIED");
16 });
17});
1import { describe, expect, test } from "vitest";
2import { api, unauthenticatedApi } from "../../api";
3
4// this is an example of testing a Gadget action
5describe("test the post.create action", () => {
6 // test with an unauthenticated user, should throw a GGT_PERMISSION_DENIED error
7 test("should throw error for unauthenticated user", async () => {
8 await expect(
9 unauthenticatedApi.post.create({
10 title: "Unit Test Post",
11 content: {
12 markdown: "This is a test post",
13 },
14 })
15 ).rejects.toThrowError("GGT_PERMISSION_DENIED");
16 });
17});

Testing HTTP routes 

Your test API client can also be used to test HTTP routes in Gadget. You can use await api.fetch("/my-route") to send a request to an HTTP route in your Gadget backend and test the response.

The following example shows how to test an HTTP route that tests the default GET-hello.js route defined in Gadget starter app templates. The test file imports a test API client, calls the /hello route, and then tests the response to ensure that the response is returned successfully.

test/routes/GET-hello.test.js
JavaScript
1import { describe, expect, test } from "vitest";
2import { api } from "../api";
3
4// use a test API client to make a real HTTP request to the route by calling api.fetch()
5describe("test GET-hello HTTP route", () => {
6 test("should return hello: world", async () => {
7 const response = await api.fetch("/hello");
8 expect(response.status).toBe(200);
9 expect(await response.json()).toEqual({ hello: "world" });
10 });
11});
1import { describe, expect, test } from "vitest";
2import { api } from "../api";
3
4// use a test API client to make a real HTTP request to the route by calling api.fetch()
5describe("test GET-hello HTTP route", () => {
6 test("should return hello: world", async () => {
7 const response = await api.fetch("/hello");
8 expect(response.status).toBe(200);
9 expect(await response.json()).toEqual({ hello: "world" });
10 });
11});

Testing util functions 

Testing utility or helper functions in Gadget does not require a test API client to be set up. You can test these functions like you would in any other Node project. For example, if there is a function used to get the word count of a string for blog posts, you can test it like this:

test/post/utils/getWordCount.test.js
JavaScript
1import { expect, test } from "vitest";
2import { getWordCount } from "../../../post/utils/getWordCount";
3
4// this is how you test helper or util functions
5test("should get the word count (2) on the passed-in string", () => {
6 expect(getWordCount("Hello, world!")).toBe(2);
7});
8
9test("should return 0 for empty string", () => {
10 expect(getWordCount("")).toBe(0);
11});
1import { expect, test } from "vitest";
2import { getWordCount } from "../../../post/utils/getWordCount";
3
4// this is how you test helper or util functions
5test("should get the word count (2) on the passed-in string", () => {
6 expect(getWordCount("Hello, world!")).toBe(2);
7});
8
9test("should return 0 for empty string", () => {
10 expect(getWordCount("")).toBe(0);
11});

Testing frontends 

Testing frontends in Gadget follows a similar approach to testing React frontends in any other Vite project. You can make use of your test runner's snapshot testing functionality to test your frontend components, and mock out the Gadget API client and React hooks to test your frontend logic.

For example, if you wanted to test the default frontend/routes/index.jsx route you get in your Gadget project:

test/web/routes/index.test.jsx
React
1import { describe, expect, it } from "vitest";
2import renderer from "react-test-renderer";
3import Index from "../../../web/routes/index";
4
5// a simple example of a snapshot test
6// this is similar to testing basic React components that do not require mocks
7describe("snapshot test for frontend/routes/index", () => {
8 it("should render the Index component", () => {
9 const tree = renderer.create(<Index />).toJSON();
10 expect(tree).toMatchSnapshot();
11 });
12});
1import { describe, expect, it } from "vitest";
2import renderer from "react-test-renderer";
3import Index from "../../../web/routes/index";
4
5// a simple example of a snapshot test
6// this is similar to testing basic React components that do not require mocks
7describe("snapshot test for frontend/routes/index", () => {
8 it("should render the Index component", () => {
9 const tree = renderer.create(<Index />).toJSON();
10 expect(tree).toMatchSnapshot();
11 });
12});

Here is an example of a test that mocks out the Gadget API client and React hooks to test the frontend logic of the frontend/routes/signed-in.jsx route:

test/web/routes/signed-in.test.jsx
React
1import { describe, expect, it, vi } from "vitest";
2import renderer from "react-test-renderer";
3import SignedIn from "../../../web/routes/signed-in";
4
5// Gadget React hooks, such as useUser and useFindMany, can be mocked to return data
6const mocks = vi.hoisted(() => {
7 const defaultUser = {
8 id: 0,
9 email: "[email protected]",
10 firstName: "Carl",
11 lastName: "Weathers",
12 };
13
14 return {
15 useUser: () => defaultUser,
16 // vi.fn() allows us to set mock values in individual tests!
17 useFindMany: vi.fn(),
18 };
19});
20
21// mock the dependency using the hoisted mocks
22vi.mock("@gadgetinc/react", () => {
23 return {
24 useUser: mocks.useUser,
25 useFindMany: mocks.useFindMany,
26 };
27});
28
29// mock the api client in the file tree
30vi.mock("../../../frontend/api", () => {
31 return {
32 api: {
33 user: {},
34 post: {},
35 },
36 };
37});
38
39// this is an example mocking out the Gadget API client and useUser hook
40describe("snapshot test for frontend/routes/signed-in", () => {
41 it("should render the default page for signed-in users", () => {
42 // Provide mock data for the useFindMany
43 mocks.useFindMany.mockReturnValue([
44 {
45 data: [
46 {
47 id: 1,
48 title: "Test post",
49 },
50 ],
51 fetching: false,
52 error: null,
53 },
54 ]);
55
56 const tree = renderer.create(<SignedIn />).toJSON();
57 expect(tree).toMatchSnapshot();
58 });
59
60 it("should render the loading message for signed-in users", () => {
61 mocks.useFindMany.mockReturnValue([
62 {
63 data: null,
64 fetching: true,
65 error: null,
66 },
67 ]);
68
69 const tree = renderer.create(<SignedIn />).toJSON();
70 expect(tree).toMatchSnapshot();
71 });
72});
1import { describe, expect, it, vi } from "vitest";
2import renderer from "react-test-renderer";
3import SignedIn from "../../../web/routes/signed-in";
4
5// Gadget React hooks, such as useUser and useFindMany, can be mocked to return data
6const mocks = vi.hoisted(() => {
7 const defaultUser = {
8 id: 0,
9 email: "[email protected]",
10 firstName: "Carl",
11 lastName: "Weathers",
12 };
13
14 return {
15 useUser: () => defaultUser,
16 // vi.fn() allows us to set mock values in individual tests!
17 useFindMany: vi.fn(),
18 };
19});
20
21// mock the dependency using the hoisted mocks
22vi.mock("@gadgetinc/react", () => {
23 return {
24 useUser: mocks.useUser,
25 useFindMany: mocks.useFindMany,
26 };
27});
28
29// mock the api client in the file tree
30vi.mock("../../../frontend/api", () => {
31 return {
32 api: {
33 user: {},
34 post: {},
35 },
36 };
37});
38
39// this is an example mocking out the Gadget API client and useUser hook
40describe("snapshot test for frontend/routes/signed-in", () => {
41 it("should render the default page for signed-in users", () => {
42 // Provide mock data for the useFindMany
43 mocks.useFindMany.mockReturnValue([
44 {
45 data: [
46 {
47 id: 1,
48 title: "Test post",
49 },
50 ],
51 fetching: false,
52 error: null,
53 },
54 ]);
55
56 const tree = renderer.create(<SignedIn />).toJSON();
57 expect(tree).toMatchSnapshot();
58 });
59
60 it("should render the loading message for signed-in users", () => {
61 mocks.useFindMany.mockReturnValue([
62 {
63 data: null,
64 fetching: true,
65 error: null,
66 },
67 ]);
68
69 const tree = renderer.create(<SignedIn />).toJSON();
70 expect(tree).toMatchSnapshot();
71 });
72});

Running tests with CI/CD 

You can also set up a CI/CD pipeline to run your test suite. This is a great way to ensure that your tests are always passing, and that you don't accidentally deploy code with failing tests to Production.

To run tests as part of a CI/CD pipeline, you need to:

  1. Store your API key from .env as a secret in your CI/CD runner
  2. Add that secret as an environment variable that is passed to your project when the CI/CD runner runs your tests
  3. Run tests as part of your CI/CD pipeline

GitHub Actions example 

The following example shows how to set up a CI/CD pipeline using GitHub Actions. This example uses the sample project.

  1. Add a .gitignore file to your project and make sure to add your .env file
// sample .gitignore file
.env
.gadget/sync.json
node_modules
  1. Store your Gadget API key as an encrypted secret for your repository
  2. Add a GitHub Actions workflow YAML file to your project in a .github/workflows folder (for example: .github/workflows/run_tests.yml)
  3. In the YAML file, you need to define the environment, add the encrypted secret to a .env file, and run your tests

The following is an example of a simple YAML file used to run tests on every push or pull request to the main branch, where the encrypted secret is stored as GADGET_TEST_API_KEY and piped into a .env file before running tests:

.github/workflows/run_tests.yml
yml
1name: Node.js CI
2
3on:
4 push:
5 branches: [main]
6 pull_request:
7 branches: [main]
8
9jobs:
10 build:
11 runs-on: ubuntu-latest
12
13 steps:
14 - uses: actions/checkout@v3
15 - name: Use Node.js
16 uses: actions/setup-node@v3
17 with:
18 node-version: "18.x"
19 - name: Create env file
20 run: |
21 echo "${{ secrets.GADGET_TEST_API_KEY }}" > .env
22 - name: Install dependencies
23 run: yarn --frozen-lockfile
24 - name: Run tests
25 run: yarn test

Now you can push your code to GitHub and your tests will run automatically.

Was this page helpful?