🏖️

Prisma で始める快適テストデータ生活

2021/11/30に公開

以前こんな記事を書きまして、こちらではいわゆる Rails とかである Factory Bot みたいな感覚で使えるものが欲しいなと思い作りました。
https://zenn.dev/seya/articles/a0d2d2da20ddad

ただ、実際にこれを使ってテストを書き始めてみたものの、すぐにまだ足りないものを見つけました。

それは

  • 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 のドキュメントを見る限りスマートな方法はなさそうです。
https://www.prisma.io/docs/concepts/components/prisma-schema/relations/many-to-many-relations/#relation-tables

なので次のようにまず片方を作り、もう片方のデータで create しながら中間テーブルのレコードも作るのが一番シンプルな形になりそうです。

const post = await postFactory.create({});
const category = await categoryFactory.create({
  post_category: {
    create: {
      post_id: post.id,
    },
  },
});

default のデータを書くのがめんどくさい

再掲しますが、こちらで作った createFactory を使って Factory を作ろうとすると次のようなものを書く必要があります。
https://zenn.dev/seya/articles/a0d2d2da20ddad

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