Prisma で始める快適テストデータ生活
以前こんな記事を書きまして、こちらではいわゆる Rails とかである Factory Bot みたいな感覚で使えるものが欲しいなと思い作りました。
ただ、実際にこれを使ってテストを書き始めてみたものの、すぐにまだ足りないものを見つけました。
それは
- relation を持つもののデータを作るのがめんどくさい
- default のデータを書くのがめんどくさい
の2点です。これらが解ければユニットテストのデータ準備周りで困ることはなさそうだと思い、ソリューションを考えてきたのでご紹介します!
relation を持つもののデータを作るのがめんどくさい
まずこちらですが、relation の持ち方については次の二つがあるのでそれぞれ個別に考えます。
- foreign key を持っているパターン
- 中間テーブルで紐づけているパターン
foreign key を持っているパターン
こちらに関しては Prisma が持っている create や connect を活用します。
Prisma では次のように create をする時に foreign Key を持っているものをまとめて create したりできます。
create を使う場合
const post = await postFactory.create({
user: {
create: {
// このデータは User の Factory で定義したものを import します
data: UserDefaultAttributes
},
},
});
connect を使う場合
既に存在するデータを使う場合はこっちを使います。
const user = await prisma.pharmacy.findUnique({
where: { id: user },
});
const post = await postFactory.create({
user: {
connect: { id: user.id },
},
});
Prisma さんのお陰で大分楽に記述できました。ありがとう Prisma。
中間テーブルで紐づけているパターン
中間テーブルというのはいわゆる 多対多
の関係を表現する時に使われるものですが、下記 Prisma のドキュメントを見る限りスマートな方法はなさそうです。
なので次のようにまず片方を作り、もう片方のデータで create しながら中間テーブルのレコードも作るのが一番シンプルな形になりそうです。
const post = await postFactory.create({});
const category = await categoryFactory.create({
post_category: {
create: {
post_id: post.id,
},
},
});
default のデータを書くのがめんどくさい
再掲しますが、こちらで作った createFactory を使って Factory を作ろうとすると次のようなものを書く必要があります。
import * as faker from "faker";
import { createFactory } from ".";
import { Prisma, user } from "@prisma/client";
export const userDefaultAttributes: Prisma.userCreateInput = {
user_id: faker.datatype.uuid().substring(0, 16),
created_at: faker.datatype.datetime(),
updated_at: faker.datatype.datetime(),
};
export const userFactory = createFactory<
Prisma.userCreateInput,
user
>("user", userDefaultAttributes);
めんどくさい!
特にプロパティが10個とかあるようなモデルだと defaultAttributes
が中々めんどくさくて苦行にように感じます。
そこで閃きました。
上記の型定義を見れば分かる通り、 defaultAttributes
の型は Prisma.userCreateInput
と一緒です。
つまり Prisma.userCreateInput
から生成できるはずだと!
TypeScript Compiler API で生成する
という訳で型定義を元に何かを作るには TypeScript Compiler API が便利なのでこれを使って頑張ってみました。
めちゃくちゃ長い訳ではないので下記にベタ貼りしちゃいます。
import * as ts from "typescript";
import * as fs from "fs";
// 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 outputFilename = `./test/factory/${modelName}.ts`;
const sourceFile = ts.createSourceFile(
outputFilename,
typeDefs,
ts.ScriptTarget.Latest
);
function main() {
// まずは指定されたモデル名の型定義を抽出します
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.toLowerCase()}CreateInput`
) {
typeStr = child.getText(sourceFile);
}
});
}
node.forEachChild((child) => {
findTypeDef(child, sourceFile);
});
}
findTypeDef(sourceFile, sourceFile);
if (typeStr.length === 0) {
console.error("該当のモデルが見つかりませんでしたYO");
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 というライブラリを使っています。
function dummyDataStringByType(typeStr: string) {
switch (typeStr) {
case "Date | string | null":
case "Date | string":
case "Date | null":
case "Date":
return "faker.datatype.datetime()";
case "string":
case "string | null":
return "faker.datatype.string()";
case "number":
case "number | null":
return "faker.datatype.number()";
case "boolean":
case "boolean | null":
return "faker.datatype.boolean()";
default:
return "{}";
}
}
// Factory ファイルの文字列を生成する関数です。
function generateFactoryFileString(typeMap: EntityMap) {
return `import * as faker from "faker";
import { createFactory } from ".";
import { Prisma, ${modelName} } from "@prisma/client";
export const ${modelName}DefaultAttributes: Prisma.${modelName}CreateInput = {
${typeMap
.map((val) => `${val.key}: ${dummyDataStringByType(val.type)}`)
.join(",\n ")}
};
export const ${modelName}Factory = createFactory<
Prisma.${modelName}CreateInput,
${modelName}
>("${modelName}", ${modelName}DefaultAttributes);
`;
}
そしてこれを実行する npm script を記述します。
{
...,
"generate:factory-file": "ts-node scripts/generateFactoryFileFromPrismaTypeDef.ts",
...
}
あとはモデル名を指定して実行するだけです!
npm run generate:factory-file user
これで元データを用意するのがずいぶん楽になりました。
また、この TS Compiler API のロジックは他にも Entity Class やそのテストの生成にも流用できたので、一回学んでおくとレバレッジが効きやすい技術だなと感じました。不毛な単純タスクが撲滅できてとても気持ち良いです。
以上、Prisma を使っているテスト環境で快適にデータを作るためのテクニックを紹介しました。よい Prisma 生活を〜👍
Discussion