【Prismaとテストシリーズ】PrismaでFactoryBot的にテストデータを作成する方法を調べてみた
こんにちは。AWS事業本部モダンアプリケーションコンサルティング部に所属している今泉(@bun76235104)です。
PrismaはTypeScriptでも利用できるORMの一つです。
前回、前々回と「Prismaでテストする際にどうすれば快適にテストに集中できるか」ということを考えてみました。
今回はこれに加えて、「テストをするときのデータ作成を楽にしたい問題」について考えてみました。
さっそくまとめ
調べてみた方法と検証してみた方法
- 調査してみた方法
- 既存のライブラリを使う方法
- ヘルパー関数を自作する方法
- Prisma で始める快適テストデータ生活
- 今回はこちらを利用してみました
- 今はまだ利用できないが、将来的に期待できるかもしれない方法
- Prisma Client Extensionsを利用する方法
- 今はまだソースコードも公開されていないような状況のようです
作成したヘルパー関数
上記のように、@seyaさんの記事を参考にFactory的な関数を作成してみました。
以下のようにテストからモデル名Factory
という形式で呼び出すようにしています。
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関数を楽に作るためのヘルパー
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を増やします。
{ "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関数(クラス)が生成されます。
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() };
テストからは以下のように実行できます。
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: "[email protected]"})
のように任意の値を書き換え可能です。
むしろそうすることによって、「このテストは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自体の勉強になりました)
もっと良い方法があれば追記していきたいと思います。
以上、最後までご覧いただきありがとうございました。
今泉でした。