Testing React and Supabase with React Testing Library and Mock Service Worker

Loading...

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.