abelcastro.dev

Decoupling Data Fetching of This Blog

2024-08-20

TypeScriptTestingRest-APINext.js

Currently, this blog fetches data from an external REST API. You can find more details here.

In my recent work , I focused on decoupling my components from the data source. My goal was to transition from code like this:

export default async function Home({
  searchParams,
}: {
  searchParams?: HomeSearchParams;
}) {
  const posts = await fetch("https://rest-api-url.com/");

Here, we're making a fetch call to an external REST API to retrieve post objects.

To something like this:

export default async function Home({
  searchParams,
}: {
  searchParams?: HomeSearchParams;
}) {
  const posts = await activeDataProvider.getAll();

With these changes, we introduced a new layer between data-fetching operations and the component itself. I refer to this layer as the "data provider." I defined an interface specifying the required and optional methods for a data provider:

export interface IDataProvider {
    getAll(options: PostSearchOptions): Promise<PaginatedPosts>;
    getBySlug(slug: string): Promise<Post | null>;
    create?(data: Partial<Post>): Promise<Post>;
    update?(slug: string, data: Partial<Post>): Promise<Post | null>;
    delete?(slug: string): Promise<boolean>;
}

This approach allows us to easily switch data sources in the future. For example, if we decide to fetch data directly from a database, we would simply create a new DbDataProvider that implements IDataProvider.

We would then only need to update the data-providers/active.ts file to use the new DbDataProvider:

import { DbAPIDataProvider } from './db';

const activeDataProvider = new DbAPIDataProvider();

export default activeDataProvider;

By modifying just one file (after creating the new data provider), you can change the app's persistence layer.

Another significant benefit of this approach is improved testability. Initially, I aimed to replace the active data provider with a TestDataProvider that returns hard-coded data for unit tests. I planned to inject the active data provider as a dependency into Next.js page components like this:

export default async function Home({
    dataProvider = activeDataProvider,
    searchParams,
}: HomeProps) {
    ...

This setup allowed me to pass the test data provider as a parameter to the component:

<Suspense>
 <Home searchParams={searchParams} dataProvider={testDataProvider} />
</Suspense>

While this worked well in development, I encountered errors when running next build, such as:

Type error: Page "app/page.tsx" has an invalid "default" export:
  Type "HomeProps" is not valid.
 ELIFECYCLE  Command failed with exit code 1.
Error: Command "pnpm run build" exited with 1

The issue was that Next.js components cannot accept parameters other than params or searchParams (source).

Since dependency injection was not possible, I ended up using spyOn calls in my unit tests. Although I aimed to avoid mocks and spies, I couldn't find an alternative when dependency injection wasn't feasible.

Despite this, the testability of the code improved. For example, the test case initially looked like this:

import { getPostsAndTotalPages } from "../../app/lib/fetchPosts";

test("Home page component should match the snapshot", async () => {
  const searchParams = {
    query: "",
    page: "1",
  };

  const getPostsAndTotalPagesMock = getPostsAndTotalPages as Mock;
  getPostsAndTotalPagesMock.mockResolvedValue({
    posts: generateMockPostAPIResponse().results,
    totalPages: 2,
  });

  const { container } = render(
    <Suspense>
      <Home searchParams={searchParams} />
    </Suspense>
  );

  // Access the screen first; otherwise, toMatchSnapshot will generate an empty snapshot
  await screen.findByText("Post 1");
  expect(container).toMatchSnapshot();
});

After the changes, it now looks like this:

const jsonData = JSON.parse(readFileSync('tests/test-data.json', 'utf-8'));
const memoryDataProvider = new MemoryDataProvider(jsonData);

test('Component should match the snapshot', async () => {
    const postSlug = 'post-1';
    const params = {
        slug: postSlug,
    };

    vi.spyOn(
        activeDataProvider,
        'getSinglePostFromStorage',
    ).mockImplementation(() => memoryDataProvider.getSinglePostFromStorage(postSlug));

    const { container } = render(
        <Suspense>
            <SinglePostPage params={params} />
        </Suspense>,
    );

    // Access the screen first; otherwise, toMatchSnapshot will generate an empty snapshot
    await screen.findByText('Post 1');
    expect(container).toMatchSnapshot();
});

The revised test case is now less coupled to the implementation details of fetching post data. This makes the tests more robust and simplifies future code changes.

I hope some of this can also be helpful for you. Happy decoupling! 🚀