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
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 app
yarnadd vitest dotenv react-test-renderer
Add a test script to your Gadget app's package.json file:
package.json
"scripts": {
"vite:build": "NODE_ENV=production vite build",
"test": "vitest"
}
Modify your vite.config.js file to include a reference to the vitest types, and add a test config to the defineConfig function:
vite.config.js
JavaScript
1/// <reference types="vitest" />
2importreactfrom"@vitejs/plugin-react-swc";
3import{ defineConfig }from"vite";
4
5// set up test config to read from .env.local
6importdotenvfrom"dotenv";
7dotenv.config({path: __dirname +"/.env.local"});
8
9exportdefaultdefineConfig({
10plugins:[react()],
11clearScreen:false,
12test:{
13// this test config is added to the defineConfig function so env vars inside .env.local are available
14setupFiles:["dotenv/config"],
15// reset mocks between tests
16mockReset:true,
17},
18});
The unit test runner is now set up, and you can run yarn test to start your test suite locally.
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.local to your .ignore file, this will ensure that the .env.local 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.local to your .gitignore file so that your API key
is not stored in your repository.
Create a .env.local 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.local file
4// an API client created using the GADGET_TEST_API_KEY environment variable
5exportconst api =newClient({
6environment:"Development",
7authenticationMode:{
8apiKey: process.env["GADGET_TEST_API_KEY"],
9},
10});
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",()=>{
8test("should create a new post record",async()=>{
9const result =await api.post.create({
10title:"Unit Test Post",
11content:{
12markdown:"This is a test post",
13},
14});
15expect(result.id).toBeDefined();
16expect(result.title).toBe("Unit Test Post");
17expect(result.content).toMatchObject({
18markdown:"This is a test post",
19truncatedHTML:"<p>This is a test post</p> ",
20});
21expect(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
1beforeAll(async()=>{
2// cleanup: delete all post records with name: "Unit Test Post" from old test runs
3// api.internal.post.deleteMany() with no args will wipe the development db
4// make sure your API client is set to the Development environment!!!
5await api.internal.post.deleteMany({
6filter:{title:{equals:"Unit Test Post"}},
7});
8});
Testing global actions
The same pattern used for model action testing can be used to test global actions in Gadget:
test/globalActions/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",()=>{
8test("should return some statistics on all blog posts",async()=>{
9const result =await api.getPostStats();
10expect(result.stats).toMatchObject({
11totalPosts:63,
12numAuthors:6,
13averageWordCount: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
// make sure to replace "@gadget-client/unit-test-sample" with your Gadget API client!
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:
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()
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:
5test("should get the word count (2) on the passed-in string",()=>{
6expect(getWordCount("Hello, world!")).toBe(2);
7});
8
9test("should return 0 for empty string",()=>{
10expect(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/frontend/routes/index.test.jsx
React
1import{ describe, expect, it }from"vitest";
2importrendererfrom"react-test-renderer";
3importIndexfrom"../../../frontend/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.jsx",()=>{
8it("should render the Index component",()=>{
9const tree = renderer.create(<Index/>).toJSON();
10expect(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:
16// vi.fn() allows us to set mock values in individual tests!
17useFindMany: vi.fn(),
18};
19});
20
21// mock the dependency using the hoisted mocks
22vi.mock("@gadgetinc/react",()=>{
23return{
24useUser: mocks.useUser,
25useFindMany: mocks.useFindMany,
26};
27});
28
29// mock the api client in the file tree
30vi.mock("../../../frontend/api",()=>{
31return{
32api:{
33user:{},
34post:{},
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.jsx",()=>{
41it("should render the default page for signed-in users",()=>{
42// Provide mock data for the useFindMany
43 mocks.useFindMany.mockReturnValue([
44{
45data:[
46{
47id:1,
48title:"Test post",
49},
50],
51fetching:false,
52error:null,
53},
54]);
55
56const tree = renderer.create(<SignedIn/>).toJSON();
57expect(tree).toMatchSnapshot();
58});
59
60it("should render the loading message for signed-in users",()=>{
61 mocks.useFindMany.mockReturnValue([
62{
63data:null,
64fetching:true,
65error:null,
66},
67]);
68
69const tree = renderer.create(<SignedIn/>).toJSON();
70expect(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.local 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.local file
// sample .gitignore file
.env.local
.gadget/sync.json
node_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: