/jest-prisma

Jest environment for integrated testing with Prisma client

Primary LanguageTypeScriptMIT LicenseMIT

jest-prisma

github actions npm version GitHub license

Jest environment for Prisma integrated testing. You can run each test case in isolated transaction which is rolled back automatically.

How to use

Install

$ npm i @quramy/jest-prisma -D

Configure Jest

/* jest.config.mjs */
export default {
  // ... Your jest configuration

  testEnvironment: "@quramy/jest-prisma/environment",
};

Configure TypeScript

/* tsconfig.json */

{
  "compilerOptions": {
    "types": ["@types/jest", "@quramy/jest-prisma"],
  }
}

Configure Prisma

jest-prisma uses Prisma interactive transaction feature. Interactive transaction needs to be listed in previewFeatures if you use @prisma/client < 4.7 .

Write tests

Global object jestPrisma is provided within jest-prisma environment. And Prisma client instance is available via jestPrisma.client

describe(UserService, () => {
  // jestPrisma.client works with transaction rolled-back automatically after each test case end.
  const prisma = jestPrisma.client;

  test("Add user", async () => {
    const createdUser = await prisma.user.create({
      data: {
        id: "001",
        name: "quramy",
      },
    });

    expect(
      await prisma.user.findFirst({
        where: {
          name: "quramy",
        },
      }),
    ).toStrictEqual(createdUser);
  });

  test("Count user", async () => {
    expect(await prisma.user.count()).toBe(0);
  });
});

Configuration

You can pass some options using testEnvironmentOptions.

/* jest.config.mjs */
export default {
  testEnvironment: "@quramy/jest-prisma/environment",
  testEnvironmentOptions: {
    verboseQuery: true,
  },
};

Alternatively, you can use @jest-environment-options pragma in your test file:

/**
 *
 * @jest-environment-options: { "verboseQuery": true }
 *
 */
test("it should execute prisma client", () => {
  /* .... */
});

Use customized PrismaClient instance

By default, jest-prisma instantiates and uses Prisma client instance from @prisma/client.

Sometimes you want to use customized (or extended) Prisma client instance, such as:

/* src/client.ts */
import { PrismaClient } from "@prisma/client";

export const prisma = new PrismaClient().$extends({
  client: {
    $myMethod: () => {
      /* ... */
    },
  },
});

You need configure jest-prisma by the following steps.

First, declare type of global.jestPrisma variable:

/* typeDefs/jest-prisma.d.ts */

import type { JestPrisma } from "@quramy/jest-prisma-core";
import type { prisma } from "../src/client";

declare global {
  var jestPrisma: JestPrisma<typeof prisma>;
}

And add the path of this declaration to your tsconfig.json:

/* tsconfig.json */

{
  "compilerOptions": {
    "types": ["@types/jest"], // You don't need list "@quramy/jest-prisma"
  },
  "includes": ["typeDefs/jest-prisma.d.ts"],
}

Finally, configure jest-prisma environment using setupFilesAfterEnv:

/* jest.config.mjs */

export default {
  testEnvironment: "@quramy/jest-prisma/environment",
  setupFilesAfterEnv: ["setupAfterEnv.ts"],
};
/* setupAfterEnv.ts */

import { prisma } from "./src/client";

jestPrisma.initializeClient(prisma);

Tips

Singleton

If your project uses singleton Prisma client instance, such as:

/* src/client.ts */
import { PrismaClient } from "@prisma/client";

export const prisma = new PrismaClient();
/* src/userService.ts */

import { prisma } from "./client.ts";

export function findUserById(id: string) {
  const result = await prisma.user.findUnique({
    where: { id },
  });
  return result;
}

You can replace the singleton instance to jestPrisma.client via jest.mock.

/* setup-prisma.js */

jest.mock("./src/client", () => {
  return {
    prisma: jestPrisma.client,
  };
});
/* jest.config.mjs */
export default {
  testEnvironment: "@quramy/jest-prisma/environment",
  setupFilesAfterEnv: ["<rootDir>/setup-prisma.js"],
};
import { prisma } from "./client";

import { findUserById } from "./userService";

describe("findUserById", () => {
  beforeEach(async () => {
    await prisma.user.create({
      data: {
        id: "test_user_id",
      },
    });
  });

  it("should return user", async () => {
    await findUserById("test_user_id");
    // assertion
  });
});

DI Containers

If you're using DI containers such as InversifyJS or Awilix and wish to introduce jest-prisma, you can easily do that just by rebinding PrismaClient to a global jestPrisma instance provided by jest-prisma.

Here is an example below. Given that we have the following repository. Note that it is decorated by @injectable so will prisma will be inject as a constructor argument.

/* types.ts */
export const TYPES = {
  PrismaClient: Symbol.for("PrismaClient"),
  UserRepository: Symbol.for("UserRepository"),
};
/* user-repository.ts */
import { TYPES } from "./types";

interface IUserRepository {
  findAll(): Promise<User[]>;
  findById(): Promise<User[]>;
  save(): Promise<User[]>;
}

@injectable()
class UserRepositoryPrisma implements IUserRepository {
  constructor(
    @inject(TYPES.PrismaClient)
    private readonly prisma: PrismaClient,
  ) {}

  async findAll() { .. }

  async findById() { .. }

  async save() { .. }
}
/* inversify.config.ts */
import { Container } from "inversify";
import { PrismaClient } from "prisma";

import { TYPES } from "./types";
import { UserRepositoryPrisma, IUserRepository } from "./user-repository";

const container = new Container();

container.bind(TYPES.PrismaClient).toConstantValue(new PrismaClient());

container.bind<IUserRepository>(TYPES.UserRepository).to(UserRepositoryPrisma);

In most cases, the setup above allows you to inject a pre-configured PrismaClient by associating the symbol to an actual instance like bind(TYPES.PrismaClient).toConstantValue(new PrismaClient()) and then acquire the repository by get(TYPES.UserRepository).

However, with jest-prisma, the global jestPrisma.client object is initialised for each unit tests so you have to make sure that you're binding the instance after the initialisation.

Note that we're rebinding PrismaClient to the jest-prisma inside beforeEach phase. Any other phase including beforeAll or setupFilesAfterEnv may not work as you expect.

/* user-repository.spec.ts */
describe("UserRepository", () => {
  beforeEach(() => {
    container
      .rebind(TYPES.PrismaClient)
      .toConstantValue(jestPrisma.client);
  });

  it("creates a user" ,() => {
    constainer.get<IUserRepository>(TYPES.UserRepository);
    ...
  });
});

Workaround for DateTime invocation error

If you encounter errors like the following:

Argument gte: Got invalid value {} on prisma.findFirstUser. Provided Json, expected DateTime.

It's because that Jest global Date is differ from JavaScript original Date(jestjs/jest#2549).

And this error can be work around by using single context environment:

/* myEnv.ts */
import type { Circus } from "@jest/types";
import type { JestEnvironmentConfig, EnvironmentContext } from "@jest/environment";

import { PrismaEnvironmentDelegate } from "@quramy/jest-prisma-core";
import Environment from "jest-environment-node-single-context";

export default class PrismaEnvironment extends Environment {
  private readonly delegate: PrismaEnvironmentDelegate;

  constructor(config: JestEnvironmentConfig, context: EnvironmentContext) {
    super(config, context);
    this.delegate = new PrismaEnvironmentDelegate(config, context);
  }

  async setup() {
    const jestPrisma = await this.delegate.preSetup();
    await super.setup();
    this.global.jestPrisma = jestPrisma;
  }

  handleTestEvent(event: Circus.Event) {
    return this.delegate.handleTestEvent(event);
  }

  async teardown() {
    await Promise.all([super.teardown(), this.delegate.teardown()]);
  }
}
/* jest.config.mjs */

export default {
  testEnvironment: "myEnv.ts",
};

Caveat: This work around might me affect your test cases using Jest fake timer features.

See also #56.

Transaction Rollback

If you are using $transaction callbacks in Prisma with the feature to roll back in case of an error, that's ok too. :D

Set enableExperimentalRollbackInTransaction in testEnvironmentOptions to true. This option allows nested transaction.

/* jest.config.mjs */
export default {
  testEnvironment: "@quramy/jest-prisma/environment",
  testEnvironmentOptions: {
    enableExperimentalRollbackInTransaction: true, // <- add this
  },
};

Then, jest-prisma reproduces them in tests

const someTransaction = async prisma => {
  await prisma.$transaction(async p => {
    await p.user.create({
      data: {
        // ...
      },
    });

    throw new Error("Something failed. Affected changes will be rollback.");
  });
};

it("test", async () => {
  const prisma = jestPrisma.client;

  const before = await prisma.user.aggregate({ _count: true });
  expect(before._count).toBe(0);

  await someTransaction(prisma);

  const after = await prisma.user.aggregate({ _count: true });
  expect(after._count).toBe(0); // <- this will be 0
});

Tip

The nested transaction is used to suppress PostgreSQL's current transaction is aborted commands ignored until end of transaction block error. See #141 if you want more details.

Internally, SAVEPOINT, which is formulated in the Standard SQL, is used.

Unfortunately, however, MongoDB does not support partial rollbacks within a Transaction using SAVEPOINT, so MongoDB is not able to reproduce rollbacks. In this case, do not set enableExperimentalRollbackInTransaction to true.

References

global.jestPrisma

export interface JestPrisma<T = PrismaClientLike> {
  /**
   *
   * Primsa Client Instance whose transaction are isolated for each test case.
   * And this transaction is rolled back automatically after each test case.
   *
   */
  readonly client: T;

  /**
   *
   * You can call this from setupAfterEnv script and set your customized PrismaClient instance.
   *
   */
  readonly initializeClient: (client: unknown) => void;
}

Environment options

export interface JestPrismaEnvironmentOptions {
  /**
   *
   * If set true, each transaction is not rolled back but committed.
   *
   */
  readonly disableRollback?: boolean;

  /**
   *
   * If set to true, it will reproduce the rollback behavior when an error occurs at the point where the transaction is used.
   *
   * In particular, if you are using MongoDB as the Database connector, you must not set it to true.
   *
   */
  readonly enableExperimentalRollbackInTransaction?: boolean;

  /**
   *
   * Display SQL queries in test cases to STDOUT.
   *
   */
  readonly verboseQuery?: boolean;

  /**
   *
   * The maximum amount of time the Prisma Client will wait to acquire a transaction from the database.
   *
   * The default value is 5 seconds.
   *
   */
  readonly maxWait?: number;

  /**
   *
   * The maximum amount of time the interactive transaction can run before being canceled and rolled back.
   *
   * The default value is 5 seconds.
   *
   */
  readonly timeout?: number;

  /**
   *
   * Sets the transaction isolation level. By default this is set to the value currently configured in your database.
   *
   * @link https://www.prisma.io/docs/orm/prisma-client/queries/transactions#transaction-isolation-level
   *
   */
  readonly isolationLevel?: Prisma.TransactionIsolationLevel;

  /**
   *
   * Override the database connection URL.
   *
   * Useful if you have a separate database for testing.
   *
   */
  readonly databaseUrl?: string;
}

License

MIT