🔥

Node + Hono + Prisma + Jestで環境構築

2023/07/09に公開
3

はじめに

Node.jsでHonoとPrismaを使っての開発およびJestでのテストを行う環境を整えるのに苦労したので、備忘録として残す

リポジトリ

環境構築

プロジェクト作成

mkdir node-hono-prisma-jest
cd node-hono-prisma-jest
pnpm create hono@latest .
create-hono version 0.2.6
✔ Which template do you want to use? › nodejs
cloned honojs/starter#main to /Users/.../node-hono-prisma-jest
✔ Copied project files
pnpm install

動作確認

pnpm start

http://localhost:3000/にアクセスしてHello Hono!と表示されれば成功

TypeScriptやLinter周りの設定

これはお好みで

長いので省略
pnpm add -D typescript @types/node @tsconfig/strictest
tsconfig.json
{
  "extends": "@tsconfig/strictest/tsconfig.json",
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "outDir": "dist",
    "noPropertyAccessFromIndexSignature": false,
    "noImplicitReturns": false,
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"],
      "~/*": ["./*"]
    },
    "esModuleInterop": true,
    "isolatedModules": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
  },
  "include": ["src/**/*", "__tests__/**/*", "scripts/**/*"]
}

pnpm add -D eslint eslint-config-airbnb-base eslint-import-resolver-alias eslint-import-resolver-typescript eslint-plugin-import eslint-plugin-jest eslint-plugin-unused-imports @typescript-eslint/eslint-plugin @typescript-eslint/parser prettier eslint-config-prettier
.eslintrc.cjs
module.exports = {
  root: true,
  extends: [
    'eslint:recommended',
    'airbnb-base',
    'plugin:@typescript-eslint/recommended',
    'plugin:@typescript-eslint/recommended-requiring-type-checking',
    'plugin:import/typescript',
    'plugin:jest/recommended',
    'plugin:jest/style',
    'prettier',
  ],
  plugins: ['import', 'unused-imports', '@typescript-eslint', 'jest'],
  env: {
    'jest/globals': true,
    'es6': true,
  },
  parser: '@typescript-eslint/parser',
  parserOptions: {
    project: './tsconfig.json',
  },
  settings: {
    'import/parsers': {
      '@typescript-eslint/parser': ['.ts', '.tsx'],
    },
    'import/resolver': {
      alias: {
        map: [
          ['@', './src'],
          ['~', './'],
        ],
        extensions: ['.ts', '.js', '.json'],
      },
    },
  },
  rules: {
    /* eslint */
    'consistent-return': 'off',
    'no-underscore-dangle': 'off',
    /* typescript */
    '@typescript-eslint/no-unused-vars': [
      'warn',
      {
        argsIgnorePattern: '^_',
        varsIgnorePattern: '^_',
        caughtErrorsIgnorePattern: '^_',
        destructuredArrayIgnorePattern: '^_',
      },
    ],
    /* import */
    'import/extensions': 'off',
    'import/prefer-default-export': 'off',
    'unused-imports/no-unused-imports': 'error',
    'import/order': [
      'error',
      {
        'groups': [
          'builtin',
          'external',
          'parent',
          'sibling',
          'index',
          'object',
          'type',
        ],
        'pathGroupsExcludedImportTypes': ['builtin'],
        'newlines-between': 'always',
        'alphabetize': {
          order: 'asc',
        },
      },
    ],
    '@typescript-eslint/consistent-type-imports': [
      'error',
      {
        prefer: 'type-imports',
      },
    ],
    'no-restricted-imports': [
      'error',
      {
        patterns: ['./*', '../*'],
      },
    ],
    /* jest */
    'jest/consistent-test-it': ['error', { fn: 'it' }],
  },
};

.eslintignore
# config
.eslintrc.cjs
.prettierrc
tsconfig.json
jest.config.js
jest.setup.js

# scripts
scripts/

# build dir
**/dist/

# generated dir
**/generated/

.prettierrc
{
  "singleQuote": true,
  "semi": true,
  "tabWidth": 2,
  "quoteProps": "consistent",
  "trailingComma": "es5",
  "bracketSpacing": true,
  "arrowParens": "always"
}

.vscode/settings.json
{
  "editor.formatOnSave": true,

  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },

  "eslint.validate": [
    "javascript",
    "javascriptreact",
    "typescript",
    "typescriptreact"
  ],

  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true,
    "source.organizeImports": false
  },

  "javascript.format.enable": false,
  "typescript.format.enable": false,

  "eslint.workingDirectories": [
    {
      "mode": "auto"
    }
  ]
}

esbuildの設定

ビルドツールはesbuildを使用

pnpm add -D esbuild esbuild-plugin-alias-path

ビルドの用のスクリプトを作成

scripts/build.ts
import path from 'path';
import { argv } from 'process';

import { build } from 'esbuild';
import { aliasPath } from 'esbuild-plugin-alias-path';

import type { BuildOptions } from 'esbuild';

const options: BuildOptions = {
  entryPoints: [path.resolve(__dirname, '../src/index.ts')],
  minify: argv[2] === 'production',
  bundle: true,
  target: 'es2015',
  platform: 'node',
  external: [],
  outfile: path.resolve(__dirname, '../dist/index.js'),
  plugins: [
    aliasPath({
      alias: {
        '@': path.resolve(__dirname, '../src'),
      },
    }),
  ],
};

build(options).catch((err) => {
  process.stderr.write(err.stderr);
  process.exit(1);
});

package.jsonを修正

package.json
{
  "scripts": {
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts",
    "build": "tsx scripts/build.ts production",
    "build-dev": "tsx scripts/build.ts development"
  },
  "dependencies": {
    "@hono/node-server": "^1.0.2",
    "hono": "^3.2.7"
  },
  "devDependencies": {
    "@tsconfig/strictest": "^2.0.1",
    "@types/node": "^20.4.1",
    "@typescript-eslint/eslint-plugin": "^5.61.0",
    "@typescript-eslint/parser": "^5.61.0",
    "esbuild": "^0.18.11",
    "esbuild-plugin-alias-path": "^2.0.2",
    "eslint": "^8.44.0",
    "eslint-config-airbnb-base": "^15.0.0",
    "eslint-config-prettier": "^8.8.0",
    "eslint-import-resolver-alias": "^1.1.2",
    "eslint-import-resolver-typescript": "^3.5.5",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-jest": "^27.2.2",
    "eslint-plugin-unused-imports": "^2.0.0",
    "prettier": "^3.0.0",
    "tsx": "^3.12.2",
    "typescript": "^5.1.6"
  }
}

動作確認

pnpm build
pnpm start

速い。esbuildすごい

Prisma

まずはDB環境を作る

compose.yaml
version: "3.9"
services:
  db:
    image: postgres:15-alpine
    restart: always
    env_file:
      - .env
    volumes:
      - ./db/tmp/db:/var/lib/postgresql/data
    ports:
      - "5432:5432"

.env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public"

POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=postgres

docker compose up --build -d

ここからprismaの設定

pnpm add -D prisma
pnpm dlx prisma init
pnpm add zod zod-prisma-types
prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

generator zod {
  provider                  = "pnpm dlx zod-prisma-types"
  output                    = "../src/schema/generated/prisma"
  createRelationValuesTypes = true
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

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

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

package.jsonにprisma用のscriptを追加

package.json
{
  "scripts": {
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts",
    "build": "tsx scripts/build.ts production",
    "build-dev": "tsx scripts/build.ts development",
    "prisma:migrate": "prisma migrate dev",
    "prisma:generate": "prisma generate",
    "prisma:reset": "prisma migrate reset --force",
    "prisma:studio": "prisma studio"
  },
  "dependencies": {
    "@hono/node-server": "^1.0.2",
    "@prisma/client": "4.16.2",
    "hono": "^3.2.7",
    "zod": "^3.21.4",
    "zod-prisma-types": "^2.7.4"
  },
  "devDependencies": {
    "@tsconfig/strictest": "^2.0.1",
    "@types/node": "^20.4.1",
    "@typescript-eslint/eslint-plugin": "^5.61.0",
    "@typescript-eslint/parser": "^5.61.0",
    "esbuild": "^0.18.11",
    "esbuild-plugin-alias-path": "^2.0.2",
    "eslint": "^8.44.0",
    "eslint-config-airbnb-base": "^15.0.0",
    "eslint-config-prettier": "^8.8.0",
    "eslint-import-resolver-alias": "^1.1.2",
    "eslint-import-resolver-typescript": "^3.5.5",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-jest": "^27.2.2",
    "eslint-plugin-unused-imports": "^2.0.0",
    "prettier": "^3.0.0",
    "prisma": "^4.16.2",
    "tsx": "^3.12.2",
    "typescript": "^5.1.6"
  }
}

pnpm prisma:migrate
✔ Enter a name for the new migration: … init

prismaのインスタンスを作成

src/libs/prisma.ts
import { PrismaClient } from '@prisma/client';

// eslint-disable-next-line import/no-mutable-exports
let prisma: PrismaClient;

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient();
} else {
  const globalWithPrisma = global as typeof globalThis & {
    prisma: PrismaClient;
  };
  if (!globalWithPrisma.prisma) {
    globalWithPrisma.prisma = new PrismaClient();
  }
  prisma = globalWithPrisma.prisma;
}

export default prisma;

Jest

pnpm add -D jest @types/jest esbuild-jest @quramy/jest-prisma-node node-fetch@2

tsconfig.jsontypesを追加

tsconfig.json
{
  "extends": "@tsconfig/strictest/tsconfig.json",
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "outDir": "dist",
    "noPropertyAccessFromIndexSignature": false,
    "noImplicitReturns": false,
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"],
      "~/*": ["./*"]
    },
    "esModuleInterop": true,
    "isolatedModules": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["@types/jest", "@quramy/jest-prisma-node"]
  },
  "include": ["src/**/*", "__tests__/**/*", "scripts/**/*"]
}

jest.config.js
module.exports = {
  testMatch: ['**/*.test.ts'],
  transform: {
    '^.+\\.ts$': 'esbuild-jest',
  },
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '^~/(.*)$': '<rootDir>/$1',
  },
  testEnvironment: '@quramy/jest-prisma-node/environment',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};

@quramy/jest-prisma-nodeを使うと、jest環境でprismaを使う際に、自動的にロールバックしてくれるようになる

jest.setup.js
const nodeFetch = require('node-fetch');
// Mocking fetch Web API using node-fetch
if (typeof fetch === 'undefined') {
  global.fetch = nodeFetch;
  global.Request = nodeFetch.Request;
  global.Response = nodeFetch.Response;
}

jest.mock('./src/libs/prisma', () => {
  return {
    __esModule: true,
    default: jestPrisma.client,
  };
});

jest環境でHonoを使うと

ReferenceError: request is not defined
ReferenceError: response is not defined

というエラーが出るので、jest.setup.jsで対策をする

package.jsonにjest用のscriptを追加

package.json
{
  "scripts": {
    "start": "node dist/index.js",
    "dev": "tsx watch src/index.ts",
    "build": "tsx scripts/build.ts production",
    "build-dev": "tsx scripts/build.ts development",
    "prisma:migrate": "prisma migrate dev",
    "prisma:generate": "prisma generate",
    "prisma:reset": "prisma migrate reset --force",
    "prisma:studio": "prisma studio",
    "test": "DATABASE_URL='postgresql://postgres:postgres@localhost:5432/postgres?schema=test' jest --watchAll",
    "test:exec": "DATABASE_URL='postgresql://postgres:postgres@localhost:5432/postgres?schema=test' pnpm prisma:reset && pnpm test"
  },
  "dependencies": {
    "@hono/node-server": "^1.0.2",
    "@prisma/client": "4.16.2",
    "hono": "^3.2.7",
    "zod": "^3.21.4",
    "zod-prisma-types": "^2.7.4"
  },
  "devDependencies": {
    "@quramy/jest-prisma-node": "^1.5.0",
    "@tsconfig/strictest": "^2.0.1",
    "@types/jest": "^29.5.2",
    "@types/node": "^20.4.1",
    "@typescript-eslint/eslint-plugin": "^5.61.0",
    "@typescript-eslint/parser": "^5.61.0",
    "esbuild": "^0.18.11",
    "esbuild-jest": "^0.5.0",
    "esbuild-plugin-alias-path": "^2.0.2",
    "eslint": "^8.44.0",
    "eslint-config-airbnb-base": "^15.0.0",
    "eslint-config-prettier": "^8.8.0",
    "eslint-import-resolver-alias": "^1.1.2",
    "eslint-import-resolver-typescript": "^3.5.5",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-jest": "^27.2.2",
    "eslint-plugin-unused-imports": "^2.0.0",
    "jest": "^29.6.1",
    "node-fetch": "2",
    "prettier": "^3.0.0",
    "prisma": "^4.16.2",
    "tsx": "^3.12.2",
    "typescript": "^5.1.6"
  }
}

jest環境ではDBを本番用と分けたいので、script内で環境変数を書き換える

動作確認

試しにテストを書いてみる

__tests__/models/user.test.ts
import prisma from '@/libs/prisma';

describe('User', () => {
  describe('add user', () => {
    it('creates a new user with valid input', async () => {
      const email = 'test@example.com';
      const name = 'test';

      const user = await prisma.user.create({
        data: {
          email,
          name,
        },
      });

      expect(
        await prisma.user.findUnique({
          where: {
            email,
          },
        })
      ).toStrictEqual(user);
    });

    it('throws an error if email is already in use', async () => {
      const email = 'test@example.com';
      const name = 'test';

      await prisma.user.create({
        data: {
          email,
          name,
        },
      });

      await expect(
        prisma.user.create({
          data: {
            email,
            name: 'test2',
          },
        })
      ).rejects.toMatchObject({
        name: 'PrismaClientKnownRequestError',
      });
    });
  });
});

(prismaでcreateに失敗したときは、rejectはされるがerrorはthrowされないっぽい?)

APIの実装とテスト

pnpm add @hono/zod-validator
src/api/user/route.ts
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { z } from 'zod';

import prisma from '@/libs/prisma';

export const user = new Hono().post(
  '/',
  zValidator(
    'json',
    z.object({
      email: z.string().email(),
      name: z.string().max(10),
    }),
    (result, c) => {
      if (!result.success) {
        return c.json(
          {
            error: {
              message: result.error,
            },
          },
          400
        );
      }
    }
  ),
  async (c) => {
    const { email, name } = c.req.valid('json');

    const createUserResponse = await (async () => {
      try {
        const newUser = await prisma.user.create({
          data: {
            email,
            name,
          },
        });

        return {
          status: 200,
          data: newUser,
        } as const;
      } catch (e) {
        return {
          status: 500,
          error: {
            message: e instanceof Error ? e.message : 'Internal Server Error',
          },
        } as const;
      }
    })();

    return c.jsonT(createUserResponse, createUserResponse.status);
  }
);

src/index.ts
import { serve } from '@hono/node-server';
import { Hono } from 'hono';

import { user } from '@/api/user/route';

const app = new Hono().basePath('/api');

export const route = app.route('/user', user);

if (process.env.NODE_ENV !== 'test') {
  serve(app);
}

jest環境ではserveしないようにすることで、portの競合を防ぐ

src/api/user/user.test.ts
import { route } from '@/index';

describe('User API', () => {
  describe('POST /user', () => {
    it('should return 200 with valid data', async () => {
      const response = await route.request('http://localhost:3000/api/user', {
        method: 'POST',
        body: JSON.stringify({
          email: 'test@example.com',
          name: 'test',
        }),
      });

      expect(response.status).toBe(200);
    });

    it('should return 400 with invalid data', async () => {
      const response = await route.request('http://localhost:3000/api/user', {
        method: 'POST',
        body: JSON.stringify({
          email: 'test',
          name: 'test',
        }),
      });

      expect(response.status).toBe(400);
    });
  });
});

ちゃんと通った

テストケースを自動で生成する

@quramy/prisma-fabbricaを使用すると、prismaのschemaからテストケースを自動生成できる

pnpm add -D @quramy/prisma-fabbrica
jest.config.js
module.exports = {
  testMatch: ['**/*.test.ts'],
  transform: {
    '^.+\\.ts$': 'esbuild-jest',
  },
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '^~/(.*)$': '<rootDir>/$1',
  },
  testEnvironment: '@quramy/jest-prisma-node/environment',
  setupFilesAfterEnv: [
    '@quramy/prisma-fabbrica/scripts/jest-prisma',
    '<rootDir>/jest.setup.js',
  ],
};

prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

generator zod {
  provider                  = "pnpm dlx zod-prisma-types"
  output                    = "../src/schema/generated/prisma"
  createRelationValuesTypes = true
}

generator fabbrica {
  provider = "prisma-fabbrica"
  output   = "../fabbrica"
  tsconfig = "../tsconfig.json"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

...

schema.prismaを変更した後はgenerateが必要

pnpm prisma:generate

@quramy/prisma-fabbricaを使用すると、テストを以下のように書ける

__tests__/models/user.test.ts
import prisma from '@/libs/prisma';
import { defineUserFactory } from '~/fabbrica';

describe('User', () => {
  describe('add user', () => {
    it('creates a new user with valid input', async () => {
      const userFactory = defineUserFactory();
      const user = await userFactory.create();

      expect(
        await prisma.user.findUnique({
          where: {
            email: user.email,
          },
        })
      ).toStrictEqual(user);
    });

    it('throws an error if email is already in use', async () => {
      const userFactory = defineUserFactory({
        defaultData: {
          email: 'test@example.com',
        },
      });

      await userFactory.create();

      await expect(userFactory.create()).rejects.toMatchObject({
        name: 'PrismaClientKnownRequestError',
      });
    });
  });
});

まとめ

@quramy/jest-prisma-node@quramy/prisma-fabbricaが便利すぎて神

GitHubで編集を提案

Discussion

MelodyclueMelodyclue
 Environment variable not found: DATABASE_URL.

と言われてしまったのですが、解決策ご存知でしょうか?

airRnotairRnot

Prismaのエラーでしょうか?
.envファイルが上手く読み込めていない可能性が高いので、.env内でtypoしていないか、.envがどこに置かれているかといったことを確認してみてください

MelodyclueMelodyclue

こちら

import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

を書くことで解決しました!