Testing React and Supabase with React Testing Library and Mock Service Worker
I have worked on a project called Supabase-Query lately which combines React-Query and Supabase, which I've been writing some integration tests for. In this tutorial, I'll go through how I tested React and Supabase by using React Testing Library and Mock Service Worker.
I will use Vite with Vitest for this tutorial, but the concepts can be applied to any framework.
The source code for the final code can be found
here.
If you want to follow along, run these commands:
yarn create vite supabase-testing --template react;
cd supabase-testing;
yarn;
yarn add @supabase/supabase-js;
Writing our todo app
For this app I have a table in Supabase named todos
with the
columns id
(autogenerated) and name
(text).
Now let's write the React code!
App.jsx
:
import "./App.css";
import { createClient } from "@supabase/supabase-js";
import { useEffect, useState } from "react";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseAnonKey);
function App() {
const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(true);
const [inputValue, setInputValue] = useState("");
useEffect(() => {
supabase
.from("todos")
.select()
.then((res) => {
setTodos(res.data);
setLoading(false);
});
}, []);
async function onSubmit(e) {
e.preventDefault();
await supabase.from("todos").insert({ name: inputValue });
// Revalidate the todos table.
// Ideally you'd use a server state manager that handles
// this for you, like supabase-query
const newTodos = await supabase.from("todos").select();
setInputValue("");
setTodos(newTodos.data);
}
return (
<div>
{loading ? (
<p>Loading...</p>
) : (
<div>
{todos?.map((todo) => (
<p key={todo.id}>{todo.name}</p>
))}
</div>
)}
<form onSubmit={onSubmit}>
<input
aria-label="Todo name"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button>Add todo</button>
</form>
</div>
);
}
export default App;
This is a simple app that fetches our todos and displays them, and a form for adding new todos.
Now add a .env
file in your project root with the variables for your Supabase database:
VITE_SUPABASE_URL=https://foobarbaz.supabase.co
VITE_SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5c...
By using env variables for the supabase URL, we will
automatically intercept the correct Supabase URL
in our MSW handlers as we'll see in a second.
Feel free to make a dedicated .env.test
file as well with these variables, but it's not
necessary for this tutorial.
Setting up the test environment
Let's install the dependencies we need and setup Vitest in our Vite project.
yarn add -D vitest @testing-library/jest-dom @testing-library/react jsdom msw
Change your vite.config.js
:
export default defineConfig({
plugins: [react()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ },
});
Globals makes the Vitest functions global, and jsdom enables HTML in Vitest.
Finally add a test
script to the package.json
:
{
"scripts": {
"dev": "vite",
+ "test": "vitest",
...
}
}
Inspecting the Supabase client
To be able to capture the requests the Supabase client makes internally, we can inspect
the network requests being made in our app.
We see that the URL is formatted like this: {supabaseurl}/rest/v1/{table}
. Great! We now have
a specific URL to intercept for our mock endpoint.
Next let's mock the responses from MSW in the same format which the
Supabase client expects. This is super simple for our table select()
usage above,
Supabase just expects an array of objects of containing our table rows. With this, let's
write some tests!
Writing our first test
App.test.jsx
:
import {
fireEvent,
render,
screen,
waitForElementToBeRemoved,
} from "@testing-library/react";
import { rest } from "msw";
import { setupServer } from "msw/node";
import App from "./App";
import { expect, afterEach } from "vitest";
import "@testing-library/jest-dom";
// The same URL is used when we call Supabase createClient in App.jsx,
// which makes us intercept the right URL in MSW
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
// Mock todos rows response
const todos = [{ id: 1, name: "Do laundry" }];
const server = setupServer(
rest.all(`${SUPABASE_URL}/rest/v1/todos`, async (req, res, ctx) => {
switch (req.method) {
case "GET":
return res(ctx.json(todos));
default:
return res(ctx.json("Unhandled method"));
}
})
);
// Ideally you'd move this to a setupTests file
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("fetches and displays Todos", async () => {
render(<App />);
await waitForElementToBeRemoved(() => screen.queryByText("Loading..."));
expect(await screen.findByText(todos[0].name)).toBeInTheDocument();
});
Here we are creating our server, then in our test waiting for the loading text to disappear and then asserting that the todo is displayed.
Finally, run your test with yarn test
and see that it passes as expected! ✅
Testing insertions
Now let's see how we can write a test for adding a new todo.
Again, let's inspect the network request and see what Supabase does
on insertions. We see that it uses the same endpoint, but with a POST
that simply has a JSON body containing our inserted data.
In our case: { "name": "New todo" }
.
With this, let's modify our handler to handle POST requests:
const server = setupServer(
rest.all(`${SUPABASE_URL}/rest/v1/todos`, async (req, res, ctx) => {
switch (req.method) {
case "GET":
return res(ctx.json(todos));
case "POST":
const body = await req.json();
const newTodo = { ...body, id: 2 };
todos.push(newTodo);
return res(ctx.json(newTodo));
default:
return res(ctx.json("Unhandled method"));
}
})
);
Note that we are mutating the todos array above which will affect
tests run after this one if using this variable.
If you want you can reset it to it's initial value with e.g. an afterEach
function.
Then let's write our test:
test("adds a new todo", async () => {
const newTodoName = "Buy groceries";
render(<App />);
const todoNameInput = screen.getByLabelText("Todo name");
const addTodoButton = screen.getByRole("button");
// Type in the new todo name and submit the form
fireEvent.change(todoNameInput, { target: { value: newTodoName } });
fireEvent.click(addTodoButton);
// Wait for the new todo to be rendered
expect(await screen.findByText(newTodoName)).toBeInTheDocument();
});
Here we are entering the new todo name in the input, submitting and
asserting that the new todo is rendered.
Cool! We have now successfully tested that adding a new todo works as expected. 🎉
Wrapping up
Thats it! Although our example is fairly simple, this should give you a good foundation to build on for handling more complex scenarios.