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.
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.
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.
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
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.
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 appyarn add vitest dotenv react-test-renderer
- Add a
test
script to your Gadget app'spackage.json
file:
"scripts": {"vite:build": "NODE_ENV=production vite build","test": "vitest"}
- Modify your
vite.config.mjs
file to include a reference to thevitest
types, and add atest
config to thedefineConfig
function:
1import * as vitest from "vitest";2import react from "@vitejs/plugin-react-swc";3import { defineConfig } from "vite";45// set up test config to read from .env6import dotenv from "dotenv";7dotenv.config();89export 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 available14 setupFiles: ["dotenv/config"],15 // reset mocks between tests16 mockReset: true,17 },18});
1import * as vitest from "vitest";2import react from "@vitejs/plugin-react-swc";3import { defineConfig } from "vite";45// set up test config to read from .env6import dotenv from "dotenv";7dotenv.config();89export 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 available14 setupFiles: ["dotenv/config"],15 // reset mocks between tests16 mockReset: true,17 },18});
The unit test runner is now set up! Run the following command to run your tests:
terminalyarn 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
- Create a
.ignore
file at the root level of your Gadget project, which will be used to ignore syncing files back to Gadget - Add
.env
to your.ignore
file, this will ensure that the.env
file is not cloned to Gadget when usingggt dev
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.
- Create a
.env
file at the root level of your Gadget project - 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
- Copy the API key for the Development environment and add it as an environment variable in your local
.env
file
example entry in .envGADGET_TEST_API_KEY=gsk-12121212121212121212121212121212
- Create a test API client for the Development environment somewhere in your project
1// make sure to replace "@gadget-client/unit-test-sample" with your Gadget API client!2import { Client } from "@gadget-client/unit-test-sample";34export const api = new Client({5 // The environment the test API client will connect to6 environment: process.env.NODE_ENV,7 authenticationMode: {8 // an API client created using the GADGET_TEST_API_KEY environment variable9 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";34export const api = new Client({5 // The environment the test API client will connect to6 environment: process.env.NODE_ENV,7 authenticationMode: {8 // an API client created using the GADGET_TEST_API_KEY environment variable9 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.
1import { describe, expect, test } from "vitest";2import { api } from "../../api";34// this is an example of testing a Gadget model action5// it makes an API call to the action using a test API client and adds records to the database6// you can use this pattern to test model actions in Gadget7describe("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";34// this is an example of testing a Gadget model action5// it makes an API call to the action using a test API client and adds records to the database6// you can use this pattern to test model actions in Gadget7describe("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});
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.
1import { beforeAll } from "vitest";2import { api } from "../../api";34beforeAll(async () => {5 // cleanup: delete all post records with name: "Unit Test Post" from old test runs6 // api.internal.post.deleteMany() with no args will wipe the development db7 // 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";34beforeAll(async () => {5 // cleanup: delete all post records with name: "Unit Test Post" from old test runs6 // api.internal.post.deleteMany() with no args will wipe the development db7 // 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:
1import { describe, expect, test } from "vitest";2import { api } from "../api";34// this is an example of testing a Gadget global action5// it makes an API call to the action using a test API client6// you can use this pattern to test global actions in Gadget7describe("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";34// this is an example of testing a Gadget global action5// it makes an API call to the action using a test API client6// you can use this pattern to test global actions in Gadget7describe("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:
1// make sure to replace "@gadget-client/unit-test-sample" with your Gadget API client!2import { Client } from "@gadget-client/unit-test-sample";34// an unauthenticated API client5// eslint-disable-next-line6export 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";34// an unauthenticated API client5// eslint-disable-next-line6export 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:
1import { describe, expect, test } from "vitest";2import { api, unauthenticatedApi } from "../../api";34// this is an example of testing a Gadget action5describe("test the post.create action", () => {6 // test with an unauthenticated user, should throw a GGT_PERMISSION_DENIED error7 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";34// this is an example of testing a Gadget action5describe("test the post.create action", () => {6 // test with an unauthenticated user, should throw a GGT_PERMISSION_DENIED error7 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.
1import { describe, expect, test } from "vitest";2import { api } from "../api";34// 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";34// 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:
1import { expect, test } from "vitest";2import { getWordCount } from "../../../post/utils/getWordCount";34// this is how you test helper or util functions5test("should get the word count (2) on the passed-in string", () => {6 expect(getWordCount("Hello, world!")).toBe(2);7});89test("should return 0 for empty string", () => {10 expect(getWordCount("")).toBe(0);11});
1import { expect, test } from "vitest";2import { getWordCount } from "../../../post/utils/getWordCount";34// this is how you test helper or util functions5test("should get the word count (2) on the passed-in string", () => {6 expect(getWordCount("Hello, world!")).toBe(2);7});89test("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:
1import { describe, expect, it } from "vitest";2import renderer from "react-test-renderer";3import Index from "../../../web/routes/index";45// a simple example of a snapshot test6// this is similar to testing basic React components that do not require mocks7describe("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";45// a simple example of a snapshot test6// this is similar to testing basic React components that do not require mocks7describe("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:
1import { describe, expect, it, vi } from "vitest";2import renderer from "react-test-renderer";3import SignedIn from "../../../web/routes/signed-in";45// Gadget React hooks, such as useUser and useFindMany, can be mocked to return data6const mocks = vi.hoisted(() => {7 const defaultUser = {8 id: 0,9 email: "[email protected]",10 firstName: "Carl",11 lastName: "Weathers",12 };1314 return {15 useUser: () => defaultUser,16 // vi.fn() allows us to set mock values in individual tests!17 useFindMany: vi.fn(),18 };19});2021// mock the dependency using the hoisted mocks22vi.mock("@gadgetinc/react", () => {23 return {24 useUser: mocks.useUser,25 useFindMany: mocks.useFindMany,26 };27});2829// mock the api client in the file tree30vi.mock("../../../frontend/api", () => {31 return {32 api: {33 user: {},34 post: {},35 },36 };37});3839// this is an example mocking out the Gadget API client and useUser hook40describe("snapshot test for frontend/routes/signed-in", () => {41 it("should render the default page for signed-in users", () => {42 // Provide mock data for the useFindMany43 mocks.useFindMany.mockReturnValue([44 {45 data: [46 {47 id: 1,48 title: "Test post",49 },50 ],51 fetching: false,52 error: null,53 },54 ]);5556 const tree = renderer.create(<SignedIn />).toJSON();57 expect(tree).toMatchSnapshot();58 });5960 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 ]);6869 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";45// Gadget React hooks, such as useUser and useFindMany, can be mocked to return data6const mocks = vi.hoisted(() => {7 const defaultUser = {8 id: 0,9 email: "[email protected]",10 firstName: "Carl",11 lastName: "Weathers",12 };1314 return {15 useUser: () => defaultUser,16 // vi.fn() allows us to set mock values in individual tests!17 useFindMany: vi.fn(),18 };19});2021// mock the dependency using the hoisted mocks22vi.mock("@gadgetinc/react", () => {23 return {24 useUser: mocks.useUser,25 useFindMany: mocks.useFindMany,26 };27});2829// mock the api client in the file tree30vi.mock("../../../frontend/api", () => {31 return {32 api: {33 user: {},34 post: {},35 },36 };37});3839// this is an example mocking out the Gadget API client and useUser hook40describe("snapshot test for frontend/routes/signed-in", () => {41 it("should render the default page for signed-in users", () => {42 // Provide mock data for the useFindMany43 mocks.useFindMany.mockReturnValue([44 {45 data: [46 {47 id: 1,48 title: "Test post",49 },50 ],51 fetching: false,52 error: null,53 },54 ]);5556 const tree = renderer.create(<SignedIn />).toJSON();57 expect(tree).toMatchSnapshot();58 });5960 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 ]);6869 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:
- Store your API key from
.env
as a secret in your CI/CD runner - Add that secret as an environment variable that is passed to your project when the CI/CD runner runs your tests
- 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.
- Add a
.gitignore
file to your project and make sure to add your.env
file
// sample .gitignore file.env.gadget/sync.jsonnode_modules
- Store your Gadget API key as an encrypted secret for your repository
- Add a GitHub Actions workflow YAML file to your project in a
.github/workflows
folder (for example:.github/workflows/run_tests.yml
) - 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.ymlyml1name: Node.js CI23on:4 push:5 branches: [main]6 pull_request:7 branches: [main]89jobs:10 build:11 runs-on: ubuntu-latest1213 steps:14 - uses: actions/checkout@v315 - name: Use Node.js16 uses: actions/setup-node@v317 with:18 node-version: "18.x"19 - name: Create env file20 run: |21 echo "${{ secrets.GADGET_TEST_API_KEY }}" > .env22 - name: Install dependencies23 run: yarn --frozen-lockfile24 - name: Run tests25 run: yarn test
Now you can push your code to GitHub and your tests will run automatically.