Skip to content

Running Tests with RTL and Vitest on Internationalized React Server Components in Next.js 13

Published: at 03:22 PM

Testing is a crucial part of any software development process, ensuring that your code functions as expected and maintains its integrity as your application evolves. Considering the relatively new React Server Components (RSC) and Next.js 13, there is not a lot of documentation on how to test them. In this blog post, I’ll explain a method I found to work for running tests with Vitest and RTL (React Testing Library) on React Server Components that utilizes internationalization (i18n) via the next-international package.

Please note that this method is not officially supported by Next.js or next-international, and may not work for all use cases. I am also not an expert on testing, so if you have any suggestions for improvements, please let me know!

Table of contents

Open Table of contents

Prerequisites

This method applies to an existing Next.js 13 app that uses the App Router and next-international for i18n. If you don’t have an existing app, you can create a starter app using the following command:

npx create-next-app@latest

Setting up next-international

To set up next-international, follow the steps in the next-international documentation. After doing it correctly, you will have:

Setting up Vitest

Vitest is a testing framework for React Server Components. It is built on top of Jest and React Testing Library. To set up Vitest, follow the steps in for example this blogpost.

Make sure your vitest config file looks something like this:

// vitest.config.js
import react from "@vitejs/plugin-react";
import viteTsconfigPaths from "vite-tsconfig-paths";
import { configDefaults, defineConfig } from "vitest/config";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), viteTsconfigPaths()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: "src/setupTests.ts",
  },
});

Notice especially the setupFiles property, which points to a file that will be run before each test. Create this file inside the /src folder (or at the root if not using a /src folder) and add the following code:

// src/setupTests.ts
import "@testing-library/jest-dom";

Now we have set up Vitest with Jest and React Testing Library. We will configure this file further later.

Testing React Server Components

Lets say we have an async React Server Component called HelloWorld.tsx that we want to test. Note: When a server component isn’t async, we can test it normally. However, if we are fetching async data, the components has to be async. In this case there is no data, but there could have been. It looks something like this:

// src/components/HelloWorld.tsx
export default async function HelloWorld() {
  return (
    <div>
      <h1 data-testid="hello-world">Hello World</h1>
    </div>
  );
}

Let’s write a test for this component. We create a file called HelloWorld.test.tsx.

// src/components/tests/HelloWorld.test.tsx
import { screen, render } from "@testing-library/react";
import HelloWorld from "../HelloWorld";

describe("HelloWorld", () => {
  it("renders ", async () => {
    render(<HelloWorld />);

    expect(screen.getByTestId("hello-world")).toHaveTextContent("Hello World");
  });
});

We run the test using the following command:

npm run test

The test fails with the following error:

Error: Objects are not valid as a React child (found: [object Promise]). If you meant to render a collection of children, use an array instead.

This is because the component is async, and we need to use await to get the component before we can render it. At the moment there is not a lot of documentation on testing React Server Components, but there is an ongoing discussion on the RTL GitHub. For now I am using a workaround from there by caleb531. We need to create a custom render function that uses await to get the component before rendering it. Using it results in the following modified test:

// src/components/tests/HelloWorld.test.tsx
...
import { renderServerComponent } from '@/test-utils/renderServerComponent';

describe('HelloWorld', () => {
  it('renders ', async () => {
    await renderServerComponent(<HelloWorld />);

    expect(screen.getByTestId('hello-world')).toHaveTextContent('Hello World');
  });
});

Now the test passes! We can also use the renderServerComponent function to test other async React Server Components.

Note on testing nested async React Server Components

If you are testing an async server component that contains other async server components, the same error will occur. My fix for this so far is to mock the nested components. For example, if we have a HelloWorldContainer.tsx component that contains the HelloWorld.tsx component, we can mock the HelloWorld.tsx component in the test for HelloWorldContainer.tsx and test it separately. The test for HelloWorldContainer.tsx would contain a some kind of mock like this:

vi.mock('./HelloWorld.tsx', () => {', () => {
  return {
    __esModule: true,
    default: () => {
      return <div />;
    },
  };
});

Updated method

Another solution is to follow the recommended workaround By NickMcCurdy which includes using React Canary and testing with Suspense and async test-calls. Previously I had issues with this method, but it’s working now after communicating with him to clarify the steps.

If using this method your test calls will look like this:

// src/components/tests/HelloWorld.test.tsx
...
import { render } from '@testing-library/react';

describe('HelloWorld', () => {
  it('renders ', async () => {
    render(
      <Suspense>
        <HelloWorld />
      </Suspense>
    );

    expect(await screen.findByTestId('hello-world')).toHaveTextContent('Hello World');
  });
});

Note that you do not need to worry about nested async components with this method.

Testing internationalized React Server Components

Next-international provides methods for getting the current locale and setting the locale. It also provides useI18n() (for client components) and getI18n() (async method for server components) functions and hooks that can be used to translate text in your app.

Now let’s say we have an internationalized React Server Component called HelloWorld.tsx that we want to test. It looks something like this:

// src/components/HelloWorld.tsx
import { getI18n } from "@/locales/server";

export default async function HelloWorld() {
  const t = await getI18n();

  return (
    <div>
      <h1 data-testid="hello-world">{t("helloWorld")}</h1>
    </div>
  );
}

This component calls the async getI18n() function from next-international to get the current locale and use it to translate the text. The getI18n() function is async, so we need to use await to get the locale before we can use it. That means we need an async component as well.

When running the test, we get the following error:

This module cannot be imported from a Client Component module. It should only be used from a Server Component.

Let’s solve this problem by mocking the getI18n() function inside our test. We can use the vi.mock() function from Vitest to mock the getI18n() function. Then, lets return the translation directly from the mock. The test now looks like this:

// src/components/tests/HelloWorld.test.tsx
...
import { vi } from 'vitest';
import en from '@/locales/en';

vi.mock('@/locales/server', async () => {
  return {
    getI18n: () => {
      return (str: string) => {
        return en[str as keyof typeof en];
      };
    },
  };
});

describe('HelloWorld', () => {
  it('renders ', async () => {
    await renderServerComponent(<HelloWorld />);

    expect(screen.getByTestId('hello-world')).toHaveTextContent('Hello World');
  });
});

And it runs again! Let’s mock all the next-international functions we need to use in our test. Move the vi.mock() logic to the src/setupTests.ts file, and add mocks for the rest of the exported functions, including the client side hooks:

beforeEach(() => {
  vi.mock("@/locales/server", async () => {
    return {
      getCurrentLocale: () => {
        return "en";
      },
      getI18n: () => {
        return (str: string) => {
          return en[str as keyof typeof en];
        };
      },
      getScopedI18n: () => {
        return (str: string) => {
          return en[str as keyof typeof en];
        };
      },
      getStaticParams: () => {
        return {};
      },
    };
  });

  vi.mock("@/locales/client", async () => {
    return {
      useChangeLocale: () => {
        return () => {
          return;
        };
      },
      useCurrentLocale: () => {
        return "en";
      },
      useI18n: () => {
        return (str: string) => {
          return en[str as keyof typeof en];
        };
      },
      useScopedI18n: () => {
        return (str: string) => {
          return en[str as keyof typeof en];
        };
      },
      useStaticParams: () => {
        return {};
      },
    };
  });
});

afterEach(() => {
  cleanup();
});

That’s it! Now we can test our internationalized React Server Components. This method ensures you can control the locale you are testing with, and that the locale is set before the component is rendered. Of course this also allows us to test client component using the mocks.

Conclusion

In this blog post, I walked you through what I have learned about running tests with Vitest and RTL on internationalized React Server Components in Next.js 13.

I hope this post has been helpful. Please let me know if you have any questions or comments, and follow me on Twitter for more updates. Happy coding! 🚀