Published on

How to implement API testing for tRPC APIs with Jest

Author
  • Karuppusamy's profile picture
    Name
    Karuppusamy
    Headline
    Developer

Introduction

In this post, I am going to show how to implement API testing for tRPC APIs with Jest.

What is tRPC?

tRPC is a typescript first framework for building end-to-end typesafe APIs with typescript. It is an alternative to REST and GraphQL APIs. Learn more about tRPC here.

Initial setup

First, we need an application with a tRPC server. For that, I am going to use the t3 starter template. You can also use your existing project or any other tRPC project.

npm create t3-app@latest

For more information about the t3 starter template visit here

Initial setup for Jest

Let's set up Jest for our project. Run the following command to install Jest and other required packages. Learn more about Jest here

npm install --save-dev jest @jest/globals ts-jest

Now, add scripts to run Jest in the package.json file.

package.json
{
  "scripts": {
    "test": "jest"
  }
}

Configure Jest to work with typescript

By default, Jest can run without any config files, but it will not compile .ts files. To make it transpile typescript with ts-jest, we will need to create a configuration file that will tell Jest to use a ts-jest transformer. Learn more about ts-jest here

Generate a basic configuration file for Jest using the following command.

npx ts-jest config:init

This will create a jest.config.js file in the root of your project. You can modify this file according to your needs.

To support typescript for .spec.ts and .test.ts files, update tsconfig.json to include the following configuration.

jest.config.js
{
  "include": ["**/*.spec.ts", "**/*.test.ts"]
}

Create helper functions for API testing

Before writing tests, we need to create helper functions to make API requests. Create a new file api.ts in the tests folder and add the following code.

tests/api.ts
import { createCaller } from "@/server/api/root";
import { db } from "@/server/db";
import type { Session } from "next-auth";
 
type User = Session["user"];
 
export function getAPIForTest(user?: User) {
  const ctx = createTRPCContextForJest(user);
  return createCaller(ctx);
}
 
export function createTRPCContextForJest(user?: User) {
  if (!user) {
    return {
      db,
      headers: new Headers(),
      session: null,
    };
  }
 
  return {
    db,
    headers: new Headers(),
    session: {
      user,
      // for testing purposes, we can set the session to expire in the future.
      expires: new Date(new Date().getTime() + 1000 * 60 * 60).toISOString(),
    },
  };
}

Writing tests

Let's write a test for the post API. Create a new file post.spec.ts in the tests folder and add the following code.

tests/post.spec.ts
import { db } from "@/server/db";
import { beforeAll, describe, expect, it } from "@jest/globals";
import type { User } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import { randomInt } from "crypto";
import { getAPIForTest } from "./api";
 
describe("post API", () => {
  let user: User | null = null;
  beforeAll(async () => {
    user = await db.user.findFirst();
  });
 
  const randomString = `This is a random string ${randomInt(100)}`;
 
  it("should return hello world", async () => {
    expect(user).not.toBeNull();
    const api = getAPIForTest({
      id: user!.id,
      email: user!.email,
    });
 
    const greeting = await api.post.hello({ text: "world" });
 
    expect(greeting).toStrictEqual({ greeting: "Hello world" });
  });
 
  it("should be able to add a post", async () => {
    expect(user).not.toBeNull();
    const api = getAPIForTest({
      id: user!.id,
      email: user!.email,
    });
 
    const res = await api.post.create({
      name: randomString,
    });
 
    expect(res.id).toBeDefined();
    expect(res.name).toBe(randomString);
    expect(res.createdAt).toBeDefined();
    expect(res.updatedAt).toBeDefined();
    expect(res.createdById).toBe(user!.id);
  });
 
  it("should return the latest post", async () => {
    expect(user).not.toBeNull();
    const api = getAPIForTest({
      id: user!.id,
      email: user!.email,
    });
 
    const res = await api.post.getLatest();
    expect(res?.name).toBe(randomString);
  });
 
  it("should be able to get the secret message", async () => {
    expect(user).not.toBeNull();
    const api = getAPIForTest({
      id: user!.id,
      email: user!.email,
    });
 
    const res = await api.post.getSecretMessage();
 
    expect(res).toBe("you can now see this secret message!");
  });
 
  it("should not be able to get the secret message if not logged in", async () => {
    const api = getAPIForTest();
 
    expect.assertions(2);
    try {
      await api.post.getSecretMessage();
    } catch (error) {
      expect(error).toBeInstanceOf(TRPCError);
      expect((error as TRPCError).code).toBe("UNAUTHORIZED");
    }
  });
});

Running tests

You can run the tests using the following command.

npm run test

Common errors

While running the tests, you may encounter some errors. Let's see common errors and how to fix them.

Cannot find module '@/server/api/root' from 'src/tests/api.ts'

This error occurs when Jest is not able to resolve the module. To fix this error, you need to update the moduleNameMapper in the jest.config.js file to map the module to the actual path.

module.exports = {
  moduleNameMapper: {
    "^@/(.*)$": "<rootDir>/src/$1",
  },
};

SyntaxError: Cannot use import statement outside a module.

This error occurs when Jest is not able to transpile the package that is causing the error or Jest is not able to find the JavaScript files.

To fix this error, update jest.config.js file to include transform for javascript files and enable isolatedModules option. Then update the transformIgnorePatterns to include the package that is causing the error.

jest.config.js
module.exports = {
  transform: {
    "^.+\\.[tj]s$": [
      "ts-jest",
      {
        isolatedModules: true,
      },
    ],
  },
  transformIgnorePatterns: ["node_modules\\(?!(superjson)\\)"],
  // transformIgnorePatterns: ["node_modules\\.pnpm\\(?!(superjson)\\)"], # For pnpm
};

TypeError: (0 , react_1.cache) is not a function

To fix this error, we need to mock the missing imports. To do that,

  1. Update the setupFilesAfterEnv in jest.config.js file to include jest.setup.ts setup.
jest.config.js
module.exports = {
  setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
};
  1. Create a new file jest.setup.ts in the root of your project. I have added mocks for react cache and next-auth providers. You can add more mocks if needed.
jest.setup.ts
import { jest } from "@jest/globals";
 
// mock react cache
jest.mock("react", () => {
  const react = jest.requireActual("react");
  return {
    __esModule: true,
    ...react,
    cache: jest.fn().mockImplementation((val) => {
      if (typeof val === "function") {
        return val();
      }
      return val;
    }),
  };
});
 
jest.mock("next-auth/providers/google", () => ({
  __esModule: true,
  default: jest.fn().mockReturnValue({
    id: "google",
    name: "Google",
    type: "oidc",
    issuer: "https://accounts.google.com",
  }),
}));
 
// mock next-auth
jest.mock("next-auth", () => ({
  __esModule: true,
  default: jest.fn().mockReturnValue({
    handlers: {},
    signIn: jest.fn(),
    signOut: jest.fn(),
    auth: jest.fn(),
  }),
}));