【Prismaとテストシリーズ】PrismaでFactoryBot的にテストデータを作成する方法を調べてみた

「テストのためのデータを作成するのもきついな」「ヘルパー関数を用意するかFactoryBotのようなライブラリを使えたら嬉しいのに」。そんなモチベーションで調べてみました。結論から言うと先人を参考にヘルパーを作ってみました。
2023.10.03

こんにちは。AWS事業本部モダンアプリケーションコンサルティング部に所属している今泉(@bun76235104)です。

PrismaはTypeScriptでも利用できるORMの一つです。

前回、前々回と「Prismaでテストする際にどうすれば快適にテストに集中できるか」ということを考えてみました。

今回はこれに加えて、「テストをするときのデータ作成を楽にしたい問題」について考えてみました。

さっそくまとめ

調べてみた方法と検証してみた方法

作成したヘルパー関数

上記のように、@seyaさんの記事を参考にFactory的な関数を作成してみました。

以下のようにテストからモデル名Factoryという形式で呼び出すようにしています。

src/post/post.repository.spec.ts

import { Post } from "@prisma/client";
import { PostsRepository } from "./post.repository";
import { PostFactory } from "../../test/factories/post";

const client = jestPrisma.client;
const postRepository = new PostsRepository(client);
// PostFactoryのインスタンスを初期化
const postFactory = new PostFactory(client);

describe("PostsRepository", () => {
  let testPost: Post;

  beforeEach(async () => {
    // デフォルト値でテストデータ作成
    testPost = await postFactory.create({});
  });

  describe("updatePost", () => {
    it("updates post title", async () => {
      // arrange
      const postInput = {
        id: testPost.id,
        title: "changed",
        content: testPost.content,
        published: true,
      };

      // act
      const result = await postRepository.updatePost(postInput);

      // assert
      expect(result.title).toEqual("changed");
    });

    it("updates post content", async () => {
      // arrange
      const postInput = {
        id: testPost.id,
        title: testPost.title,
        content: "changed2",
        published: true,
      };

      // act
      const result = await postRepository.updatePost(postInput);

      // assert
      expect(result.content).toEqual("changed2");
    });

    it("can not create same title with same author", async () => {
      // arrange
      const mustErrorInput = {
        title: testPost.title,
        content: "何でも良い値",
        published: true,
        authorId: testPost.authorId as number,
      };

      // act & assert
      try {
        await postRepository.createPost(mustErrorInput);
      } catch (err) {
        const e = err as Error;
        const message = e.message;
        expect(e.name).toBe("PrismaClientKnownRequestError");
        expect(message).toContain(
          "Unique constraint failed on the constraint:"
        );
      }
      // なぜかrejects.toThrow()が通らない
      // await expect(postRepository.createPost(mustErrorInput)).rejects.toThrow();
    });
  });
});

なぜヘルパー関数を利用する方法にしてみたか

調査するにあたり以下のような既存のライブラリを見つけました。

どちらのライブラリもとても使いやすそうだったのですが、私が試してみた範囲だと若干ですが、ユニーク制約のあるテーブルなどで挙動が気になる場面がありました。

もちろんOSSとして発展に貢献できれば一番良いのですが、今は「今すぐに利用するならどういう方法があるか」という点を試してみたかったので、本格利用を控えてみました。

そこで以下の記事を参考に、「何か問題が起きても柔軟に対応しやすいヘルパー関数を作る」ことにしてみました。

やってみた

Factory関数を作るヘルパー関数を作る

上記記事の@seyaさんのコードをほぼ使いつつも、ちょっとだけ変更して以下のようなファイルを作成しました。

Factory関数を楽に作るためのヘルパー

generateFactoryFileFromPrismaTypeDef.ts

import * as ts from "typescript";
import * as fs from "fs";
import * as path from "path";

// アッパーキャメルケースをローワーキャメルケースに変換する
// モデル名はPrismaの命名規則に従ってアッパーキャメルケースの想定
export const toLowerCamelCase = (str: string) => {
  return str.charAt(0).toLowerCase() + str.slice(1);
}

// node_modules 内に生成されている Prisma の型定義を見に行きます。
const typeDefs = fs.readFileSync(
  "./node_modules/.prisma/client/index.d.ts",
  "utf8"
);

// ts-node で実行した時に引数の一番後ろをモデル名と判定します。
const modelName = process.argv[process.argv.length - 1];

const outputDir = path.join("test", "factories");
const outputFilename = path.join(outputDir, `${toLowerCamelCase(modelName)}.ts`);
const sourceFile = ts.createSourceFile(
  outputFilename,
  typeDefs,
  ts.ScriptTarget.Latest
);

function main() {
  // 出力先のディレクトリを作成
  fs.mkdirSync(outputDir, { recursive: true });
  // まずは指定されたモデル名の型定義を抽出します
  let typeStr = "";
  function findTypeDef(node: ts.Node, sourceFile: ts.SourceFile) {
    if (ts.isModuleDeclaration(node) && node.name?.text === "Prisma") {
      node.body?.forEachChild((child) => {
        if (
          ts.isTypeAliasDeclaration(child) &&
          child.name?.escapedText === `${modelName}CreateInput`
        ) {
          typeStr = child.getText(sourceFile);
        }
      });
    }

    node.forEachChild((child) => {
      findTypeDef(child, sourceFile);
    });
  }
  findTypeDef(sourceFile, sourceFile);

  if (typeStr.length === 0) {
    console.error("該当のモデルが見つかりませんでした");
    return;
  }

  // 型定義が見つかったら、そこから プロパティ名: 型の文字列 のマップを作成します
  const typeMap = convertTypeStringToMap(typeStr);

  // 作成したマップを元に Factory 関数のファイルの文字列を作成します。
  const factoryFileString = generateFactoryFileString(typeMap);

  // できた文字列を書き込んだら完成です!
  fs.writeFileSync(outputFilename, factoryFileString, "utf-8");

  console.log(`生成に成功しました!${outputFilename} をご確認ください!!`);
}

main();

// プロパティ名: 型の文字列 なマップを作るための関数です。
type EntityMap = { key: string; type: string }[];
function convertTypeStringToMap(typeStr: string): EntityMap {
  const str = typeStr.split("=")[1].trim();
  const props = str.substring(1, str.length - 3);
  return props
    .split("\n")
    .map((keyValue: string) => ({
      key: keyValue.split(":")[0]?.trim().replace("?", ""),
      type: keyValue.split(":")[1]?.trim(),
    }))
    .filter((val) => val.key !== "__typename" && val.key !== "");
}

// 型定義の仕方によってどんなダミーデータを入れるかを指定する関数です。
// オリジナル作者様のコードを参考にfaker-jsを使うように修正
function dummyDataStringByType(typeStr: string) {
  switch (typeStr) {
    case "Date | string | null":
    case "Date | string":
    case "Date | null":
      return "faker.date.past()";
    case "string":
    case "string | null":
      return "faker.string.sample()";
    case "number":
    case "number | null":
      return "faker.number.int()";
    case "boolean":
    case "boolean | null":
      return "faker.datatype.boolean()";
    default:
      return "{}";
  }
}

// Factory ファイルの文字列を生成する関数です。
function generateFactoryFileString(typeMap: EntityMap) {
  const lowerCamelName = toLowerCamelCase(modelName);
  return `import { faker } from "@faker-js/faker";
import { Prisma, ${modelName}, PrismaClient } from "@prisma/client";

// 関連テーブルがある場合は親テーブル側のDefaultAttributesをimportして利用できます
export const ${lowerCamelName}DefaultAttributes: Prisma.${modelName}CreateInput = {
  ${typeMap
    .map((val) => `${val.key}: ${dummyDataStringByType(val.type)}`)
    .join(",\n  ")}
};

// こちらを参考にFactoryクラスを作成してください
// ある程度自由にカスタマイズして構いません
export class ${modelName}Factory {
  private readonly prisma: PrismaClient;

  constructor(prisma: PrismaClient) {
    this.prisma = prisma;
  }

  public async create(attributes: 
    Partial<Prisma.${modelName}CreateInput> = {}
  ): Promise<${modelName}> {
    return await this.prisma.${lowerCamelName}.create({
      data: {
        ...${lowerCamelName}DefaultAttributes,
        ...attributes,
      },
    });
  }

}
`;
}

ヘルパー関数を実行してみる

@seyaさんの記事と同じく、package.jsonのscriptsを増やします。

packaga.json

{
  "scripts": {
    "generate:factory-file": "ts-node scripts/generateFactoryFileFromPrismaTypeDef.ts"
  },
}

今回、schema.prsimaは以下のような状態となっています。

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
  posts Post[]

  @@map("users")
}

model Post {
  id       Int      @id @default(autoincrement())
  title     String
  content   String
  published Boolean  @default(false)
  author    User?    @relation(fields: [authorId], references: [id])
  authorId  Int?

  @@map("posts")
  @@unique([title, authorId])
}

Userモデルのヘルパー関数を作ると以下のような形となります。

npm run generate:factory-file Userを実行すると以下のようなFactory関数(クラス)が生成されます。

test/factory/user.ts

import { faker } from "@faker-js/faker";
import { Prisma, User, PrismaClient } from "@prisma/client";

// 関連テーブルがある場合は親テーブル側のDefaultAttributesをimportして利用できます
export const UserDefaultAttributes: Prisma.UserCreateInput = {
  email: faker.string.sample(),
  name: faker.string.sample()
};

// こちらを参考にFactoryクラスを作成してください
// ある程度自由にカスタマイズして構いません
export class UserFactory {
  private readonly prisma: PrismaClient;

  constructor(prisma: PrismaClient) {
    this.prisma = prisma;
  }

  public async create(attributes: 
    Partial<Prisma.UserCreateInput> = {}
  ): Promise<User> {
    return await this.prisma.user.create({
      data: {
        ...UserDefaultAttributes,
        ...attributes,
      },
    });
  }
}

生成されたUserDefaultAttributesはカラムの型しかみていないので、カラムの性質に応じて以下のように変更してみます。

export const userDefaultAttributes: Prisma.UserCreateInput = {
  email: faker.internet.email(),
  name: faker.person.lastName()
};

テストからは以下のように実行できます。

src/users/users.repository.spec.ts

import { User } from "@prisma/client";
import { UsersRepository } from "./users.repository";
import { UserFactory } from "../../test/factories/user";

const userRepository = new UsersRepository(jestPrisma.client);
const userFactory = new UserFactory(jestPrisma.client);

describe("UsersRepository", () => {
  describe("updateUser", () => {
    let testUser: User

    beforeEach(async () => {
      // UserDefaultAttributesの値で作成
      testUser = await userFactory.create({})
    });

    it("updates user name", async () => {
      // arrange
      const userInput = {
        id: testUser.id,
        name: "changed",
      };

      // act
      const result = await userRepository.updateuser(userInput);

      // assert
      expect(result.name).toEqual(userInput.name);
    });

  });
});

もちろんtestUser = await userFactory.create({email: "hoge@example.com"})のように任意の値を書き換え可能です。

むしろそうすることによって、「このテストはemailという値が重要だよ」ということをテストを見る人に伝えることができます。

この辺りの考え方は、rspec-style-guideにも記載されているようです。

1対Nのテーブルで試してみた

同じようにPostテーブルでも試してみます。

npm run generate:factory-file Postを実行します。

以下のようにsrc/test/factory/post.tsが生成されます。

import { faker } from "@faker-js/faker";
import { Prisma, Post, PrismaClient } from "@prisma/client";

// 関連テーブルがある場合は親テーブル側のDefaultAttributesをimportして利用できます
export const postDefaultAttributes: Prisma.PostCreateInput = {
  title: faker.string.sample(),
  content: faker.string.sample(),
  published: faker.datatype.boolean(),
  author: {}
};

// こちらを参考にFactoryクラスを作成してください
// ある程度自由にカスタマイズして構いません
export class PostFactory {
  private readonly prisma: PrismaClient;

  constructor(prisma: PrismaClient) {
    this.prisma = prisma;
  }

  public async create(attributes: 
    Partial<Prisma.PostCreateInput> = {}
  ): Promise<Post> {
    return await this.prisma.post.create({
      data: {
        ...postDefaultAttributes,
        ...attributes,
      },
    });
  }

}

Postは1つのUserを持っている必要があるので、先に作っておいたUserのFactoryクラスを使いながら、Factory関数を良い感じに編集してみます。

export const postDefaultAttributes: Prisma.PostCreateInput = {
  title: faker.animal.dog(),
  content: faker.music.songName(),
  published: true,
  author: {
    // emailはユニーク制約があるのであえて上書きしている
    create: {...userDefaultAttributes, ...{email: faker.internet.email()}}
  }
};

さいごに

前回までは「テストと開発環境の世界を切り分けたい」というモチベーションで試してみました。

今回は上でもリンクを貼っている以下記事の言葉が深く刺さって、「何かテストデータを作るのを楽にする方法を考えなきゃ」というモチベーションでやってみました。

テストをきちんと書く文化を根付かせるためには「テストコード書くのダルい」とメンバーに感じさせない工夫は重要と思っています。

今回作ったヘルパーは@seyaさんの作ったヘルパー関数を参考にしつつも、少しだけカスタマイズしています。

オリジナルの関数はもっと動的に動いてくれて良さげだったのですが、今回は必要最小限として各モデルのFactoryクラスの雛形を生成するヘルパー関数の作成に留めてみました。 (あのスクリプトすごいし、TypeScriot自体の勉強になりました)

もっと良い方法があれば追記していきたいと思います。

以上、最後までご覧いただきありがとうございました。

今泉でした。