Open42

クローン病患者向け体調記録アプリを作っています

petaxa | いちむらゆうまpetaxa | いちむらゆうま

アプリを作り始めました

2023/08頃にクローン病を発症したので、それ用の健康管理アプリを作り始めました。
病床に臥しながら、Viteのテンプレートをすこーしだけ改変したフロントエンドと、GASで体温と排便の記録をスプレッドシートに書き込んでいくアプリを作りました。
ひとまず1カ月くらいで日常生活ができるようになったので、これを原案に初めてWebアプリを公開してみようということになりました。
とはいえ、公開できる体を成していないのでそれらを作っていこうというのが今回のお話です。

途方もなく間違っている見積もりで3割くらい進んだ気がするのですが、やる気が今にも消えそうなので様々公開して外的圧力を醸成しようという魂胆で書き始めました。
ひとまず、今までをダイジェストでお届けします。
※お断り: 技術の採用理由は私ができるからと名前がかっこいいからくらいしかありません。いつかちゃんと選べるくらいちゃんとした人になろうと思います。

使っている技術

時系列で説明する前に、(今のところにはなりますが)使っている技術やら環境やらを列挙しておきます。

フロントエンド

  • TypeScript
  • Vue
  • Nuxt (使った方が良い気がするので検討しています)
  • 色々未定

バックエンド

  • TypeScript
  • Express
  • Prisma
  • Nodemailer

サーバ、DB、その他

  • ConohaVPS
  • Route 53
  • nginx
  • MySQL
  • Docker
  • Figma
petaxa | いちむらゆうまpetaxa | いちむらゆうま

0. プロトタイプ

病床で作ったプロトタイプがどういうものかを軽く説明します。

  • GAS+スプレッドシートで書き込み作業をAPI化
  • (編集はおろか、削除の機能もありません。ミスったら元気なときに手で直します。)
  • Viteをセットアップしたときのテンプレートを一部改変して各種入力欄を設置
  • 入力内容を渡してAPIを叩き、スプレッドシートへ記録
  • 基本はスマホで操作する
  • 認証等は何もなし。僕のパソコンにローカルで起動して、家庭のLAN内に公開してスマホで使う。
  • もちろん、外では使えない(酷く不便。)

1. ドメインを準備

まず取り組んだのがドメインの購入です。
お金を払えばやる気が無尽蔵に湧いてくると、その時は信じていたからです。

Route 53

ドメインなんて買ったことがないので、とりあえずRoute 53で買って、管理をすることにしました。
全てなんとなくで選びましたが、無料を喧伝しているサービスはシステムがちょっと姑息で嫌でした。
功罪はある気がしますが、何事も経験ということで。

サブドメインを切る

テスト環境やら、本番環境やらはよくわからない(うえにまだそこに行きつくまでに時間がかかるだろう)ということで、ひとまずAPI用のサブドメインだけ切っておきました。
AWSのドキュメント通りに作業しました。
ボタンを押して必要事項を入力するだけでできました。
レコードタイプが色々あるみたいです(Wikipedia. 「DNSレコードタイプの一覧」)
https://ja.wikipedia.org/wiki/DNSレコードタイプの一覧
今回はAレコードのみ作りました。

2. Webサーバーを準備

もともとConohaにVPSサーバーを借りていたので、これを利用しようということになりました。
Webサーバーソフトウェアにはnginxを採用しました。
速くて強いとうわさを聞いたので。
ここでやったことは

  • nginxでWebサーバーを構築して
  • LetsEncryptでSSL/TLSに対応した

という感じです。
以下の2つの記事の手順通りに作業したらできました。
アイデアノート101010.Fun. 「Ubuntuにnginxをインストールしてウェブサーバーを立ち上げる」
https://101010.fun/programming/setup-nginx-on-ubuntu.html
Qiita. @akubi0w1(あくび). 「Ubuntu + nginx + LetsEncryptでSSL/TLSを設定する」
https://qiita.com/akubi0w1/items/c436343f544d13e3be1d

3. デザイン

普通は機能を考えるとは思うんですが、うまくまとまらなかったのでFigmaで画面を作りながら機能をまとめていくという強硬手段を執りました。
Figmaを触るのも初めてだったので、とりあえずがむしゃらです。
最終的に、画面下部にメニューバーがあるタイプのデザインをベースに各機能をデザインしました。
デザインもFigmaのデータも無茶苦茶だと思いますが、厚顔無恥を装って公開しておきます。
Figmaの公開リンク
https://www.figma.com/file/ZbNDK9AN0Ya6EDYgT0FmSK/RecordViscera?type=design&node-id=123%3A21937&mode=design&t=VE2etZMBb9zNITsK-1

NERV防災アプリの情報設計や彩度、明度のバランスを参考にしたり能力が足らずできなかったりしました。
その他有名なスマホアプリから知見を盗みは貼りを繰り返しています。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

4. 機能の列挙

出来上がったデザインから、機能を列挙していきます。

仕様をざっくり説明しておくと、

  • メール認証でアカウント作成
  • 毎日の体調記録を行う
  • トイレをするたびに記録する
  • 通院の予定と結果を記録する
  • 服薬の予定と結果を記録する
  • CDAIを算出し、目安の重症度を可視化する

みたいな感じです。
通院記録と通院予定は同じテーブルを利用します。
過去の日付は記録、未来の日付なら予定です。

アカウント周り

  • ログイン
  • ログアウト
  • アカウント作成
  • メールアドレス認証
  • パスワード再設定
  • プロフィール変更
  • アカウント情報参照

毎日の体調記録

  • 参照
  • 追加
  • 編集
  • 削除

トイレの記録

  • 参照
  • 追加
  • 編集
  • 削除

通院

通院記録(または予定)

  • 参照
  • 追加
  • 編集
  • 削除

検査

通院記録にその日やる予定の(またはやった)検査を紐づける

  • 参照
  • 追加
  • 編集
  • 削除

服薬

服薬記録

  • 参照
  • 追加
  • 編集
  • 削除

服薬計画

  • 参照
  • 追加
  • 編集
  • 削除

重症度の目安

  • CDAI算出
  • 重症度目安算出

設定関連

  • テーマカラー

5. APIプロジェクト作成

これで機能の列挙ができたので、バックエンド側を作っていきます。
TypeScript, Expressで作成します。
ひとまず、プロジェクトの概形を作りました。

recordVisceraApi
├── log
├── public
├── src
│    ├─ bin
│    ├─ consts
│    ├─ controllers
│    ├─ routes
│    ├─ services
│    └─ app.ts
├── views
├── .env
├── .gitignore
├── package-lock.json
├── package.json
└── tsconfig.json

現時点ではこんな感じだったと思います。
メモ程度に解説します。
(プロジェクト作成に何を使ったか忘れてしまいました。express-generaterは使っていない気がしますがそれも自信がありません。)
src/consts: DBやレスポンスメッセージで使う定数を定義
src/ controllers: ビジネスロジックを行う処理を置くところ
src/routes: ルーティングを処理を書くところ
src/services: ビジネスロジックに書くと長くなったり、共通化したい処理を置くところ

6. Dockerを用意

これも初めてで全然わからないので、ひとまず.envとdocker, docker-compose を作りました

.env

  • データベースURLとパスワードの変数を用意する
  • 中身を入れておく

docker

docker
FROM node:18
WORKDIR /project

docker-compose.yaml

recordVisceraApiのボリューム、mysqlのボリュームを用意しました。
working_dirを設定しないとexecでボリューム内に入れなかった記憶があります。

docker-compose.yaml
version: '3.8'
services:
  recordVisceraApi:
    build: .
    environment:
      TZ: Asia/Tokyo
    image: node:18
    tty: true
    ports:
      - "3001:3000"
    volumes:
      - .:/project
    working_dir: /project
    restart: always
    # command: npm start
  mysql:
    image: mysql:8.0
    volumes:
      - ./mysql/seed:/docker-entrypoint-initdb.d # 初回データ実行
      - ./mysql/db:/var/lib/mysql # データ永続化
    environment:
      MYSQL_DATABASE: record-viscera-api
      MYSQL_ROOT_PASSWORD: webapp:${MYSQL_ROOT_PASSWORD} # パスワード設定
      TZ: "Asia/Tokyo"
    ports:
      - "3333:3306"
    restart: always
petaxa | いちむらゆうまpetaxa | いちむらゆうま

7. DBとの接続を行う

ORMにPrismaを利用して、スキーマの作成、DB操作を行える環境を整えました。
DB設計を行っていないことに疑問を持つかと思いますが、順序に間違いはありません。
Prismaでスキーマを書く際に設計を行いました。
(というか、設計しながらスキーマの記法に則ってメモをしていったという感じです。)
実に非効率。手戻りがすごい多かったです。

8. 文字化けを修正する

接続テストの結果、日本語を格納しようとすると文字化けすることがわかりました。
show variables like "chara%";で文字コードを確認すると、utf-8になっていなかったので変更します。
ここで手で変更しても、まっさらにして立ち上げ直したら再設定が必要なので根本的な解決を望みます。
どうやら、my.cnfという、mysqlの設定ファイルがあるようです。
これを作成し、docker起動時にマウントするようにすればよいみたい。
やってみます

my.cnf
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_general_ci
explicit-defaults-for-timestamp=1
general-log=1
general-log-file=/var/log/mysql/mysqld.log

[client]
default-character-set=utf8mb4
docker-compose.yaml
services:
...
  mysql:
    ...
    volumes:
      ...
      - ./my.cnf:/etc/mysql/conf.d/my.cnf
 ...

これで上手くいきました。


現時点でのフォルダ構成です。

recordVisceraApi
├── dist
├── log
├── mysql
├── node_modules
├── prisma
├── public
├── src
│    ├─ bin
│    ├─ consts
│    ├─ controllers
│    ├─ routes
│    ├─ services
│    └─ app.ts
├── views
├── .env
├── .gitignore
├── docker-compose.yaml
├── Docker
├── my.cnf
├── package-lock.json
├── package.json
└── tsconfig.json
petaxa | いちむらゆうまpetaxa | いちむらゆうま

9. プロトタイプの機能を作成

プロトタイプで実装していた機能を、こちらで作り替えます。
バック、フロントどちらも換装して動作の確認を行いました。

  • 体温記録
  • トイレの記録

これらの参照、登録を行えるようにします。

バックエンド

  • DBにテーブルを追加
  • API作成

フロントエンド

  • GASの部分を自作のAPIに換装

9. DB設計を行う

前述のとおり、schema.prismaにスキーマを書きながら設計を行いました。
概要は4. 機能の列挙になると思います。
ここにはschema.prismaとprismaに作ってもらったER図(後述します)を貼り付けます。
※schema.prismaは折りたたんでいます

schema.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"
}

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

// ER図の生成
generator erd {
  provider                  = "prisma-erd-generator"
  theme                     = "forest"
  output                    = "ERD.md"
  includeRelationFromFields = true
}

// ユーザー
model User {
  // メールアドレス
  email                String
  // ユーザーネーム
  name                 String
  // パスワード
  password             String
  // Email認証トークン
  verifyEmailHash      String?
  // パスワード再設定認証トークン
  passResetHash        String?
  // メールアドレス認証有無
  verified             Int                    @default(0)
  // ログインステータス
  loginStatus          Int                    @default(0)
  // 紐づけ
  Bowel_Movement        Bowel_Movement[]
  Profile              Profile?
  User_Medical_History User_Medical_History?
  User_Setting         User_Setting?
  Daily_Report         Daily_Report[]
  Clinic_Report        Clinic_Report[]
  Medication_Info_User Medication_Info_User[]
  Medication_Schedule  Medication_Schedule[]
  Medication_Result    Medication_Result[]
  // id, 作成日時, 更新日時
  id                   Int                    @id @default(autoincrement())
  createdAt            DateTime               @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt            DateTime               @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("users")
}

// ユーザープロフィール
model Profile {
  // 性別
  sex       Int?
  // 身長
  height    Float?
  // 生年月日
  birthday  DateTime? @db.Date
  // id, ユーザー紐づけ, 作成日時, 更新日時
  id        Int       @id @default(autoincrement())
  user      User      @relation(fields: [userId], references: [id])
  userId    Int       @unique
  createdAt DateTime  @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt DateTime  @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("profiles")
}

// 治療歴
model User_Medical_History {
  // 回腸造瘻術の有無
  ileostomy Int
  // id, ユーザー紐づけ, 作成日時, 更新日時
  id        Int      @id @default(autoincrement())
  User      User     @relation(fields: [userId], references: [id])
  userId    Int      @unique
  createdAt DateTime @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt DateTime @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("user_medical_history")
}

// ユーザー設定
model User_Setting {
  // テーマカラー
  themeColor Int
  // id, ユーザー紐づけ, 作成日時, 更新日時
  id         Int      @id @default(autoincrement())
  User       User     @relation(fields: [userId], references: [id])
  userId     Int      @unique
  createdAt  DateTime @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt  DateTime @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("user_setting")
}

// 便の状態
model Bowel_Movement {
  // 日時
  day                  DateTime            @db.Date
  time                 DateTime            @db.Time
  // 便の状態
  Bristol_Stool_Scales Bristol_Stool_Scale @relation(fields: [bristolStoolScale], references: [id])
  bristolStoolScale    Int
  // 血の有無
  blood                Int
  // 粘液の有無
  drainage             Int
  // 備考
  note                 String?
  // id, ユーザー紐づけ, 作成日時, 更新日時
  id                   Int                 @id @default(autoincrement())
  User                 User                @relation(fields: [userId], references: [id])
  userId               Int
  createdAt            DateTime            @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt            DateTime            @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("bowel_movements")
}

// ブリストルスケール
model Bristol_Stool_Scale {
  id             Int              @id @default(autoincrement())
  typeName       String           @unique
  Bowel_Movement Bowel_Movement[]

  @@map("bristol_stool_scales")
}

// 今日の体調
model Daily_Report {
  // 日時
  day                              DateTime                          @db.Date
  time                             DateTime                          @db.Time
  // 体温
  Daily_report_Temp                Daily_report_Temp?
  // 体重
  Daily_report_Weight              Daily_report_Weight?
  // 腹痛
  Daily_report_Stomachache         Daily_report_Stomachache?
  // 体調
  Daily_report_Condition           Daily_report_Condition?
  // 関節痛の有無
  Daily_report_Arthritis           Daily_report_Arthritis?
  // 皮膚病変の有無
  Daily_report_Skin_Lesions        Daily_report_Skin_Lesions?
  // 眼病変の有無
  Daily_report_Ocular_Lesitions    Daily_report_Ocular_Lesitions?
  // 肛門病変の有無
  Daily_report_Anorectal_Lesitions Daily_report_Anorectal_Lesitions?
  // 腹部腫瘤の有無
  Daily_report_Abdominal           Daily_report_Abdominal?
  // id, ユーザー紐づけ, 作成日時, 更新日時
  id                               Int                               @id @default(autoincrement())
  User                             User                              @relation(fields: [userId], references: [id])
  userId                           Int
  createdAt                        DateTime                          @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt                        DateTime                          @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  // userIdとday, timeでユニークブロックを作成する
  @@unique([userId, day], name: "dailyReportUnique")
  @@map("daily_report")
}

// 体温
model Daily_report_Temp {
  // 結果
  result        Float?
  // id, 今日の体調紐づけ, 作成日時, 更新日時
  id            Int          @id @default(autoincrement())
  Daily_Report  Daily_Report @relation(fields: [dailyReportId], references: [id], onDelete: Cascade)
  dailyReportId Int          @unique
  createdAt     DateTime     @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt     DateTime     @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("daily_report_temp")
}

// 体重
model Daily_report_Weight {
  // 結果
  result        Float?
  // id, 今日の体調紐づけ, 作成日時, 更新日時
  id            Int          @id @default(autoincrement())
  Daily_Report  Daily_Report @relation(fields: [dailyReportId], references: [id], onDelete: Cascade)
  dailyReportId Int          @unique
  createdAt     DateTime     @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt     DateTime     @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("daily_report_weight")
}

// 腹痛
model Daily_report_Stomachache {
  // 結果
  Stomachache_Scale_Types   Stomachache_Scale_Types @relation(fields: [stomachache_Scale_TypesId], references: [id])
  stomachache_Scale_TypesId Int
  // id, 今日の体調紐づけ, 作成日時, 更新日時
  id                        Int                     @id @default(autoincrement())
  Daily_Report              Daily_Report            @relation(fields: [dailyReportId], references: [id], onDelete: Cascade)
  dailyReportId             Int                     @unique
  createdAt                 DateTime                @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt                 DateTime                @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("daily_report_stomachache")
}

// 腹痛スケール区分
model Stomachache_Scale_Types {
  id                       Int                        @id @default(autoincrement())
  typeName                 String                     @unique
  score                    Int
  Daily_report_Stomachache Daily_report_Stomachache[]

  @@map("stomachache_scale_types")
}

// 体調
model Daily_report_Condition {
  // 結果
  Condition_Scale_Types   Condition_Scale_Types @relation(fields: [condition_Scale_TypesId], references: [id])
  condition_Scale_TypesId Int
  // id, 今日の体調紐づけ, 作成日時, 更新日時
  id                      Int                   @id @default(autoincrement())
  Daily_Report            Daily_Report          @relation(fields: [dailyReportId], references: [id], onDelete: Cascade)
  dailyReportId           Int                   @unique
  createdAt               DateTime              @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt               DateTime              @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("daily_report_condition")
}

// 体調スケール区分
model Condition_Scale_Types {
  id                     Int                      @id @default(autoincrement())
  typeName               String                   @unique
  score                  Int
  Daily_report_Condition Daily_report_Condition[]

  @@map("condition_scale_types")
}

// 関節痛の有無
model Daily_report_Arthritis {
  // 結果
  result        Int?
  // id, 今日の体調紐づけ, 作成日時, 更新日時
  id            Int          @id @default(autoincrement())
  Daily_Report  Daily_Report @relation(fields: [dailyReportId], references: [id], onDelete: Cascade)
  dailyReportId Int          @unique
  createdAt     DateTime     @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt     DateTime     @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("daily_report_arthritis")
}

// 皮膚病変の有無
model Daily_report_Skin_Lesions {
  // 結果
  result        Int?
  // id, 今日の体調紐づけ, 作成日時, 更新日時
  id            Int          @id @default(autoincrement())
  Daily_Report  Daily_Report @relation(fields: [dailyReportId], references: [id], onDelete: Cascade)
  dailyReportId Int          @unique
  createdAt     DateTime     @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt     DateTime     @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("daily_report_skin_lesions")
}

// 眼病変の有無
model Daily_report_Ocular_Lesitions {
  // 結果
  result        Int?
  // id, 今日の体調紐づけ, 作成日時, 更新日時
  id            Int          @id @default(autoincrement())
  Daily_Report  Daily_Report @relation(fields: [dailyReportId], references: [id], onDelete: Cascade)
  dailyReportId Int          @unique
  createdAt     DateTime     @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt     DateTime     @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("daily_report_ocular_lesions")
}

// 肛門病変の有無
model Daily_report_Anorectal_Lesitions {
  // 痔瘻
  fistula       Int?
  // その他の肛門病変
  others        Int?
  // id, 今日の体調紐づけ, 作成日時, 更新日時
  id            Int          @id @default(autoincrement())
  Daily_Report  Daily_Report @relation(fields: [dailyReportId], references: [id], onDelete: Cascade)
  dailyReportId Int          @unique
  createdAt     DateTime     @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt     DateTime     @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("daily_report_anorectal_lesions")
}

// 腹部腫瘤の有無
model Daily_report_Abdominal {
  // 結果
  Abdominal_Scale_Types   Abdominal_Scale_Types @relation(fields: [abdominal_Scale_TypesId], references: [id])
  abdominal_Scale_TypesId Int
  // id, 今日の体調紐づけ, 作成日時, 更新日時
  id                      Int                   @id @default(autoincrement())
  Daily_Report            Daily_Report          @relation(fields: [dailyReportId], references: [id], onDelete: Cascade)
  dailyReportId           Int                   @unique
  createdAt               DateTime              @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt               DateTime              @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("daily_report_abdominal")
}

// 腹部腫瘤スケール区分
model Abdominal_Scale_Types {
  id                     Int                      @id @default(autoincrement())
  typeName               String                   @unique
  score                  Int
  Daily_report_Abdominal Daily_report_Abdominal[]

  @@map("abdominal_scale_types")
}

// 通院記録
model Clinic_Report {
  // 日時
  day         DateTime     @db.Date
  time        DateTime     @db.Time
  // 検査予定/結果
  Checkup     Checkup?
  // 通院ノート
  Clinic_Note Clinic_Note?
  // id, ユーザー紐づけ, 作成日時, 更新日時
  id          Int          @id @default(autoincrement())
  User        User         @relation(fields: [userId], references: [id])
  userId      Int
  createdAt   DateTime     @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt   DateTime     @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("clinic_report")
}

// 検査予定/結果
model Checkup {
  // 血液検査
  Checkup_Blood  Checkup_Blood?
  // MRI
  Checkup_Mri    Checkup_Mri?
  // CT
  Checkup_Ct     Checkup_Ct?
  // カスタム検査
  Checkup_Custom Checkup_Custom?
  // id, 通院記録, 作成日時, 更新日時
  id             Int             @id @default(autoincrement())
  Clinic_Report  Clinic_Report   @relation(fields: [clinicReportId], references: [id])
  clinicReportId Int             @unique
  createdAt      DateTime        @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt      DateTime        @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("checkup")
}

// 血液検査
model Checkup_Blood {
  // Hr
  hematocrit               Int?
  // CRP
  crp                      Int?
  // その他
  Checkup_Blood_Additional Checkup_Blood_Additional[]
  // id, 検査予定/結果, 作成日時, 更新日時
  id                       Int                        @id @default(autoincrement())
  Checkup                  Checkup                    @relation(fields: [checkupId], references: [id])
  checkupId                Int                        @unique
  createdAt                DateTime                   @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt                DateTime                   @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("checkup_blood")
}

// その他の血液検査
model Checkup_Blood_Additional {
  // 検査項目名
  itemName       String
  // 検査結果
  result         String?
  // 単位
  unit           String?
  // id, 血液検査, 作成日時, 更新日時
  id             Int           @id @default(autoincrement())
  Checkup_Blood  Checkup_Blood @relation(fields: [checkupBloodId], references: [id])
  checkupBloodId Int
  createdAt      DateTime      @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt      DateTime      @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("checkup_blood_additional")
}

// MRIの検査結果
model Checkup_Mri {
  // 結果
  result    String
  // id, 検査予定/結果, 作成日時, 更新日時
  id        Int      @id @default(autoincrement())
  Checkup   Checkup  @relation(fields: [checkupId], references: [id])
  checkupId Int      @unique
  createdAt DateTime @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt DateTime @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("checkup_mri")
}

// CTの検査結果
model Checkup_Ct {
  // 結果
  result    String
  // id, 検査予定/結果, 作成日時, 更新日時
  id        Int      @id @default(autoincrement())
  Checkup   Checkup  @relation(fields: [checkupId], references: [id])
  checkupId Int      @unique
  createdAt DateTime @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt DateTime @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("checkup_ct")
}

// ユーザーカスタムの検査
model Checkup_Custom {
  // 検査名
  checkupName String
  // 検査結果
  result      String?
  // id, 検査予定/結果, 作成日時, 更新日時
  id          Int      @id @default(autoincrement())
  Checkup     Checkup  @relation(fields: [checkupId], references: [id])
  checkupId   Int      @unique
  createdAt   DateTime @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt   DateTime @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("checkup_custom")
}

// 通院ノート
model Clinic_Note {
  // ノート
  note           String
  // id, 通院記録, 作成日時, 更新日時
  id             Int           @id @default(autoincrement())
  Clinic_Report  Clinic_Report @relation(fields: [clinicReportId], references: [id])
  clinicReportId Int           @unique
  createdAt      DateTime      @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt      DateTime      @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("clinic_note")
}

// ユーザー登録薬剤リスト
model Medication_Info_User {
  // 薬剤
  Medication_Info_Master Medication_Info_Master @relation(fields: [medicationId], references: [id])
  medicationId           Int
  // 服用個数
  count                  Float?
  // id, ユーザー紐づけ, 作成日時, 更新日時
  id                     Int                    @id @default(autoincrement())
  User                   User                   @relation(fields: [userId], references: [id])
  userId                 Int
  createdAt              DateTime               @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt              DateTime               @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime
  Medication_Schedule    Medication_Schedule[]
  Medication_Result      Medication_Result[]

  @@map("medication_info_user")
}

// 薬剤マスタ
model Medication_Info_Master {
  // 薬剤名
  name                 String
  // YJコード
  yjCode               String
  // 規格
  specification        String
  // id, ユーザー薬剤紐づけ, 作成日時, 更新日時
  id                   Int                    @id @default(autoincrement())
  createdAt            DateTime               @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt            DateTime               @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime
  Medication_Info_User Medication_Info_User[]

  @@map("medication_info_master")
}

// ユーザー服薬予定
model Medication_Schedule {
  // 服用タイミング
  Medication_Timing_Types Medication_Timing_Types @relation(fields: [timing], references: [id])
  timing                  Int
  // 薬剤
  Medication_Info_User    Medication_Info_User    @relation(fields: [medicationInfoId], references: [id])
  medicationInfoId        Int
  // id, ユーザー紐づけ, 作成日時, 更新日時
  id                      Int                     @id @default(autoincrement())
  User                    User                    @relation(fields: [userId], references: [id])
  userId                  Int
  createdAt               DateTime                @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt               DateTime                @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("medication_schedule")
}

// 服用タイミング区分
model Medication_Timing_Types {
  id                  Int                   @id @default(autoincrement())
  typeName            String                @unique
  Medication_Schedule Medication_Schedule[]

  @@map("medication_timing_types")
}

// ユーザー服薬記録
model Medication_Result {
  // 日時
  day                  DateTime             @db.Date
  time                 DateTime             @db.Time
  // 薬剤
  Medication_Info_User Medication_Info_User @relation(fields: [medicationInfoId], references: [id])
  medicationInfoId     Int
  // id, ユーザー紐づけ, 作成日時, 更新日時
  id                   Int                  @id @default(autoincrement())
  User                 User                 @relation(fields: [userId], references: [id])
  userId               Int
  createdAt            DateTime             @default(dbgenerated("NOW()")) @db.DateTime
  updatedAt            DateTime             @default(dbgenerated("NOW() ON UPDATE NOW()")) @db.DateTime

  @@map("medication_result")
}


ER図

このER図はprisma generateコマンドを行った際に作ってもらえるよう、schema.prismaに設定を書いています。

schema.prisma
...
// ER図の生成
generator erd {
  provider                  = "prisma-erd-generator"
  theme                     = "forest"
  output                    = "ERD.md" // 出力するファイルのpath
  includeRelationFromFields = true
}
...
petaxa | いちむらゆうまpetaxa | いちむらゆうま

10. APIの各機能を作成

現在はこの作業の途中です。
作成した機能を列挙します。

自作のPrismaClientにはuserを扱う際にパスワードのハッシュ化、すべてのクエリでタイムゾーンの変更を行う機能をつけています。
また、医療情報は暗号化して保存する予定のため、その機能も追加する予定です。
現在、PrismaClientはつねに自作のものを利用するという運用にしています。
正しいかはわからないですが、ハッシュ化し忘れ、暗号化し忘れとか洒落にならないのでこれなら絶対に防げるかなという意図です。

アカウント周り(一部)実装

  • アカウント作成
  • メール認証
  • ログイン
  • プロフィール編集
  • パスワード変更
  • パスワードリセット
  • ログアウト

トイレの記録

  • 取得
  • 追加
  • 編集
  • 削除
  • 回数/日の算出

今日の体調

  • 取得
  • 登録
  • 編集
  • 削除

11. DBの初期データを自動投入できるように整備

実際には10. APIの各機能を作成の途中でやっていたことですが、shでsqlファイルの中身を全部実行するように設定しました。
外から(execせずとも)実行できるように、shを二層に分けて実行しました。
以下の記事を参考に、フォルダ内のすべてのSQLファイルを実行するようにアレンジしてやってみました。
Qiita. @A-Kira(Akira Demizu). 「docker-compose でMySQL環境簡単構築」
https://qiita.com/A-Kira/items/f401aea261693c395966

init-mysql.sh
#!/bin/sh
docker-compose exec mysql bash -c "chmod 0644 /etc/mysql/conf.d/my.cnf"
docker-compose exec mysql bash -c "chmod 0775 docker-entrypoint-initdb.d/init-database.sh"
docker-compose exec mysql bash -c "./docker-entrypoint-initdb.d/init-database.sh"
mysql/seed/init-database.sh
#!/bin/sh
filepath="docker-entrypoint-initdb.d/sql/*"
dirs=$(find $filepath -maxdepth 0 -type f -name *.sql)
for file in $dirs; do
    mysql -u root -ppassword record-viscera-api < $file
done

mysql/seed/sqlに初期データの投入を記述したSQLファイルを配置する


ダイジェストはこれで終わりです。
今後の予定

  • 機能が1個完成したら更新する
  • 技術的に苦難を強いられたら更新する

つもりです。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

Checkup_Mri, Checkup_CtのresultをNull許容にした

通院機能を作成中だが、通院予定の作成時、MRI, CTのレコードを追加しようとしたらresulutが必須だよと怒られました。
結論から言うと、Null許容に変更しました。
血液検査はNull許容になっているし。
(これは特定の検査結果を入れてもらえるようにカラムが定義されているので、その検査をしない場合に対応できるようにという意図です。タブン)
スキーマを設計したのが昔すぎるし、なにもメモもしていないのでなぜこうしたのか覚えていない。

ひとまず、今後はClinic_Reportが持っているCheckupに紐づいている検査が予定している/実施した検査という運用にしてみる。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

データを取得するようなAPIでの絞り込みの実装方法に一生悩まされる

難しすぎるので、不都合が出ると割り切って実装しているのが現状です。この後、フロントを書いたり、新規機能の検討をする中で、不都合が出たら修正する。というスタンス。最悪だけどべいびーなので許してほしい気持ち。
失敗して血をだらだら流しながら成長します。

さて、本題。
API設計のためにいくつか参考にしたサイトがあります。
その1つに以下のようなことが書かれていました。

フィルタやソート、検索といった機能はリクエストパラメータで制御するのが良いです
(中略)
/tickets でチケットのリストを取得する際に、state が open のものだけに絞りたいことがあると思います。このような要望は GET /tickets?state=open のようにして実現させましょう。

Qiita. @@mserizawa(Masato Serizawa). 翻訳: WebAPI 設計のベストプラクティス -フィルタ・ソート・検索はリクエストパラメータでやろう.

べいびーな私は、ここでなにかをDBから取得するようなAPIには検索やフィルタリングが必要なのかと気づきました。(よく考えたらそりゃそうですよね。)
ということで、今までのAPIでもこれに基づきどうにか実装してきました。が、DBがネストする場合はどうすればよいのかが悩ましいです。

現在作っている通院記録周りでは、

という感じになっています。

この時、通院記録テーブルの中身を取得するようなAPIにおいて、どこまで検索、絞り込みができればよいのでしょう。
いまは以下のようにしています

  • 通院記録の取得のみ(検査テーブル以降はfilterしない設計で)行う
  • 通院記録取得時には、紐づく検査をすべて返す(孫となる、各検査の結果も)
  • 検査を取得するAPIを用意し、検査結果のフィルターはそっちで行う

検査用APIのURLは

POST Baseurl/users/clinics/:id/checkups/edit

としています。
clinicReportのidから紐づく検査を...という感じです。
まだ実装もテストもしていないので、ほんとはこれではだめかもしれませんが、この方針でやってみます

petaxa | いちむらゆうまpetaxa | いちむらゆうま

データを取得するようなAPIで、年月日をそれぞれ受け取るようにした理由

はるか昔にたくさん考えて書いた仕様書に、クエリでもらう年月日をそれぞれ、year, month, dayでもらうようにしていました。
この時の意図を思い出せたので記録しておきます。
不都合あれば変えます。その程度の決定です。

端的に言えば、年月日のフォーマットがたくさんあるからです。
YYYY-MM-DDとか、YYYYMMDDとか、MM-DD-YYYYとか、ぱっと思いつくだけでも文化圏によっていろいろあるなと。
こうすると、仕様書を読み込まないと使えないAPIになってしまいます。
これが嫌でした。
クエリの一覧をばーっとみて、使い方がほとんど想像つくようにしたかったです。

フロントを作るときのことを考えると、Date型をStringにキャストしたものを渡すのが一番楽ですが、APIを叩く側がどんな環境かはわからないので(感知しないで作るのが良いと聞いたので)、こうしました。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

テストを書きます。

最近、テストのためのライブラリ?フレームワーク?っていうやつがあることがわかりました。
作り終わったらやろっかなぁと思っていましたが、すべてを中断してもうやります。
作っていたらバンバンミスが見つかるので、こりゃダメです。
べいびー一人で品質の維持管理は無理難題でした。(なんでできると思っていたのかよくわかりません。)

代表的って出てきた4つのテストをすべてやります。
機能が1個できたら(新たに叩けるAPIが完成したら)これらを行うこととします。

  1. 静的テスト
    • ESLintでチェック
    • Jestの実装が終わったらやる
  2. 単体テスト
    • Jestを導入してテストコードを書く
  3. 結合テスト
    • Jestを導入してテストコードを書く
  4. End to Endテスト
petaxa | いちむらゆうまpetaxa | いちむらゆうま

導入手順メモ

基本的には、サバイバルTypeScriptとJestのドキュメントを参考にしようと思います。

サバイバルTypeScript -Jestでテストを書こう
https://typescriptbook.jp/tutorials/jest

Jest -始めましょう
https://jestjs.io/ja/docs/getting-started

手順

  1. Jestをインストール(Jestドキュメント)
    npm install --save-dev jest@29.1.1

  2. ts-jest, @types/jestをインストール(サバイバルTypeScript)
    npm install --save-dev ts-jest@29.1.1 @types/jest@29.1.1
    Jestのドキュメントによると、

    Try to match versions of Jest and @types/jest as closely as possible.
    とのことなので、バージョンを指定した方がいいかもです。
    今回は29.1.1で統一しました。

  3. Jestの設定ファイルを作る(サバイバルTypeScript)
    参考: Jestドキュメント -Jestの設定
    コメント部分(/** @type {import("ts-jest/dist/types").InitialOptionsTsJest} */)は廃止されたようなので削除しました。
    代わりに、Jestドキュメントより、/** @type {import('jest').Config} */を書きました。
    詳しいことは調べてないです。もし機会があったら追記します。

  4. package.jsonにスクリプトを追加する(Jestドキュメント)

    package.jsonのscriptsにtestを追加しました。

    package.json
    {
      "scripts": {
        "test": "jest"
      }
    }
    
  5. jestを試す(サバイバルTypeScript)
    check.test.tsを作成し、jestを実行してみる

    check.test.ts
    test("check", () => {
    console.log("OK");
    });
    

    コンソールに以下のように出力され、正しく動作していることを確認

    > recordvisceraapi@0.0.0 test
    > jest
    
      console.log
        OK
    
          at Object.<anonymous> (src/check.test.ts:2:13)
    
     PASS  src/check.test.ts (14.15 s)
      ✓ check (144 ms)
    
    Test Suites: 1 passed, 1 total
    Tests:       1 passed, 1 total
    Snapshots:   0 total
    Time:        16.321 s
    Ran all test suites.
    

    ※14秒、かかりすぎじゃない...?
    起動も同じくらい遅いので、なにかがおかしい可能性があります。

  6. check.test.tsはいらないので消します。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

npmコマンド実行に時間かかりすぎ問題、解決せず

ググってみると、Docker関係の設定のせいで遅くなることがあるらしいので試してみた。
結果、変わらなかったのでこの変更は反映しないことにした。

docker-compose.yamlで、node_modulesをボリュームマウントすると遅くなるようです。
以下の記事を参考に、docker-compose.yamlを変更しました。
Qiita. @keito654. Dockerでnpm installが遅い問題を解決する.
https://qiita.com/keito654/items/035d0547c5ab210cc7ab

docker-compose.yaml
version: '3.8'
services:
  recordVisceraApi:
    build: .
    environment:
      TZ: Asia/Tokyo
    image: node:18
    tty: true
    ports:
      - "3001:3000"
    volumes:
      - .:/project
+      - type: volume
+        source: node_modules
+        target: /node_modules
    working_dir: /project
    restart: always
    # command: npm start
  mysql:
    image: mysql:8.0
    volumes:
      - ./mysql/seed:/docker-entrypoint-initdb.d # 初回データ実行
      - ./mysql/db:/var/lib/mysql # データ永続化
      - ./my.cnf:/etc/mysql/conf.d/my.cnf
    environment:
      MYSQL_DATABASE: record-viscera-api
      MYSQL_ROOT_PASSWORD: webapp:${MYSQL_ROOT_PASSWORD} # パスワード設定
      TZ: "Asia/Tokyo"
    ports:
      - "3333:3306"
    restart: always

+volumes:
+  node_modules:

変更後、コンテナの再起動とrecordVisceraApi内にexecしてnpm installを実行しなおしたが変化はなかった。
また何か見つけたら試してみる。
2minとか言っている記事もあったので、もしかしたらこのくらい普通なのかもしれない

petaxa | いちむらゆうまpetaxa | いちむらゆうま

JestでTypeScriptと同じエイリアスを使えるようにした。

そのままやってみたら(当たり前だけど)エラーが出たので、jest.config.jsにエイリアスの設定を追加した。
moduleNameMapperを設定すればよいらしい。

jest.config.js
/** @type {import('jest').Config} */
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
+ moduleNameMapper:{
+   "@/(.*)": "<rootDir>/src/$1"
+ }
};

以下のtsconfig.jsonと同じエイリアスにすることができた

tsconfig.json
"compilerOptions": {
    "paths": {
      "@/*": [
        "src/*"
      ]
}

参考: Jestドキュメント -moduleNameMapper
https://jestjs.io/ja/docs/configuration#modulenamemapper-objectstring-string--arraystring

petaxa | いちむらゆうまpetaxa | いちむらゆうま

Jest+Prismaで、DBの中身を任意のオブジェクトとしてJestでのテストを実行する

状況

  • ORMとしてPrismaを使用する
  • PrismaClientは自分でextendsしたcustomizedPrismaを使用する
  • DBからデータを取得し、そのオブジェクトを返却する関数をテストする

ポイント

  • PrismaClientのmockを作成(mockPrisma)
  • 対象のメソッドの結果を上書き(mockResolvedValue)
  • テスト対象の関数で、PrismaClientを受け取るように変更
  • mockPrismaの型はjest.MockedObject<typeof 利用するPrismaClient>
  • テスト対象関数のPrismaClientが入る引数の型はtypeof 利用するPrismaClient

注意

  • この時、実際のfindUniqueは実行されておらず、mockResolvedValue()に渡した値が変換されるだけの関数に置き換わる
ソースコード全文

テストコード

src/service/__tests__/bowelMovements.test.ts
import { describe, expect, jest, test, beforeEach, afterEach } from '@jest/globals';
import { Bowel_Movement } from '@prisma/client';
import { findUniqueBowelMovementAbsoluteExist } from '@/services/prismaService/bowelMovements';
import { DbRecordNotFoundError } from '@/services/prismaService/index'
import { customizedPrisma } from '@/services/prismaClients';

const mockPrisma = {
    bowel_Movement: {
        findUnique: jest.fn(),
    },
} as unknown as jest.MockedObject<typeof customizedPrisma>;

describe("排便記録の取得テスト", () => {
    beforeEach(() => {
        jest.clearAllMocks();
    });

    afterEach(() => {
        jest.restoreAllMocks();
    });

    test("排便記録が存在する場合、データを返す", async () => {
       // ダミーのデータを定義
        const mockBowelMovement: Bowel_Movement = {
            id: 9,
            userId: 2,
            day: new Date('2022-12-20T00:00:00.000Z'),
            time: new Date('1970-01-01T09:50:00.000Z'),
            bristolStoolScale: 3,
            blood: 0,
            drainage: 1,
            note: 'note',
            createdAt: new Date('2023-09-05T10:00:00Z'),
            updatedAt: new Date('2023-09-05T11:00:00Z'),
        };

       // 実行結果を引数に渡しておく
        mockPrisma.bowel_Movement.findUnique.mockResolvedValue(mockBowelMovement);

        // 実行して、結果をチェック
        const result = await findUniqueBowelMovementAbsoluteExist({ id: 9 }, mockPrisma);
        expect(result).toEqual(mockBowelMovement);
    });

    test("排便記録が存在しない場合、DbRecordNotFoundErrorを投げる", async () => {
        mockPrisma.bowel_Movement.findUnique.mockResolvedValue(null);

        await expect(findUniqueBowelMovementAbsoluteExist({ id: 9 }, customizedPrisma)).rejects.toThrow(new DbRecordNotFoundError("排便記録が見つかりません。"));
    });
});

テスト対象関数

src/services/prismaService/bowelMovement.ts
import { Prisma } from "@prisma/client";
import { customizedPrisma } from "../prismaClients";
import { BOWEL_MOVEMENT_NOT_FOUND } from "@/consts/responseConsts/bowelMovement";
import { DbRecordNotFoundError } from ".";

/**
 * DBより、排便記録の存在確認、取得を行う。
 * 排便記録が存在しなかった場合はDbRecordNotFoundErrorを投げる
 * @param where 検索条件
 * @param res
 * @returns
 */
export const findUniqueBowelMovementAbsoluteExist = async (where: Prisma.Bowel_MovementWhereUniqueInput, prismaClient: typeof customizedPrisma) => {
    // idから排便記録を取得
    const bowelMovementData = await prismaClient.bowel_Movement.findUnique({ where })
    // 排便記録が無かったらDbRecordNotFoundErrorを投げる
    if (!bowelMovementData) {
        const responseMsg = BOWEL_MOVEMENT_NOT_FOUND.message
        throw new DbRecordNotFoundError(responseMsg)
    }

    return bowelMovementData
}
petaxa | いちむらゆうまpetaxa | いちむらゆうま

jest-prismaを利用して、テストの間だけDBを任意の状態にする

jest-prismaを利用すると、テストケースごとに隔離された状態でレコード作成や削除ができ、テスト終了後にそれらはロールバックされます。

Jest environment for Prisma integrated testing. You can run each test case in isolated transaction which is rolled back automatically.

Github. Quramy. jest-prisma.
https://github.com/Quramy/jest-prisma

これを利用してPrismaを利用している関数のテストをします。

今回は、取得系APIのサービス関数のテストにて、

みたいなことができるようなテストコードを書きました。
ググってみるとリレーションがしんどいっぽい雰囲気を感じますが、とりあえずこれでしばらくやってみます。

以下のような、prismaClientを引数で指定できる関数でテストします。

テスト対象関数
src/service/prismaService/users.ts
/**
 * DBより、ユーザーの存在確認、取得を行う。
 * ユーザーが存在しなかった場合はDbRecordNotFoundErrorを投げる
 * @param where 検索条件
 * @param res
 * @returns
 */
export const findUniqueUserAbsoluteExist = async (
    where: Prisma.UserWhereUniqueInput,
    prismaClient: typeof customizedPrisma
) => {
    // userIdからユーザーを取得
    const user = await prismaClient.user.findUnique({ where });
    // ユーザーが見つからなかったらDbRecordNotFoundErrorを投げる
    if (!user) {
        const responseMsg = USER_NOT_FOUND.message;
        throw new DbRecordNotFoundError(responseMsg);
    }

    return user;
};

テストコードの流れ

jest-prismaのPrismaClientを利用し、レコードの作成、参照を行います。
前述のとおり、jest-prismaは当該ケースが終わったら自動的にロールバックしてくれます。
Jestのひな形は作ってある前提で、該当のtest()のコールバック関数内に以下を書いていきます。

  1. DBに入れたいデータを作成

    users.test.ts
        const mockUser: User = {
            email: "petaxa@gmail.com",
            name: "petaxa",
            password: "$12365gjoiwe",
            verifyEmailHash: null,
            passResetHash: null,
            loginStatus: 1,
            verified: 1,
            id: 1,
            createdAt: new Date("2023-09-05T10:00:00Z"),
            updatedAt: new Date("2023-09-05T11:00:00Z"),
        };
        ```
    
    
  2. jest-prismaのPrismaClient作成
    型は通常渡すPrismaClientの型にアサーションする
    最悪の回避方法でした。自作のPrismaClientがあるときは、ちゃんと設定する方法がありました。
    設定すれば型で怒られません。
    方法は以下から

https://zenn.dev/link/comments/457026ed33f485

users.test.ts
const jestPrismaClient = jestPrisma.client as typeof customizedPrisma;
  1. jest-prismaのPrismaClientでcreatecrateManyを実行

    users.test.ts
    await jestPrismaClient.user.create({
         data: mockUser,
     });
    
  2. テスト対象関数を実行 この時、引数に渡すPrismaClientはjest-prismaもの

    users.test.ts
     const result = await findUniqueUserAbsoluteExist(
         { id: 1 },
         jestPrismaClient
     );
    
  3. expectを実行し、結果をチェック

    users.test.ts
    expect(result).toEqual(mockUser);
    
petaxa | いちむらゆうまpetaxa | いちむらゆうま

jest-prismaで自作のPrismaClientを使うように設定した

jest-prismaのGitHubリポジトリに説明があった。
結構簡単に書いてあったので、補いながら訳しながら。
ソースコードは自分のものを使っています。(nは多い方がいいという気持ちから)

参考: Github. Quramy. jest-prisma -Use customized PrismaClient instance.
https://github.com/Quramy/jest-prisma?tab=readme-ov-file#use-customized-prismaclient-instance

自作のPrismaClientインスタンスをjest-prismaに設定する

通常のPrismaで、以下のように自作のPrismaClientを使うことがあるでしょう。

src/services/prismaClients/index.ts
/**
 * PrismaClientを拡張
 * user.create時にpasswordをhash化する。
 */
export const customizedPrisma = prisma.$extends(extention);

以下の手順でこれをjest-prismaで使えるようにしていきます。

  1. global.jestPrismaの型を定義する

    jestPrismaの型をJestPrisma<typeof 自作PrismaClient>にする

    src/jest/jest-prisma.d.ts
    import type { JestPrisma } from "@quramy/jest-prisma-core";
    import { customizedPrisma } from "@/services/prismaClients";
    
    declare global {
        var jestPrisma: JestPrisma<typeof customizedPrisma>;
    }
    
  2. tsconfig.jsonに先ほどの型定義ファイルを追加する

    • includeに先ほどの型定義ファイルを追加
    • もしなかったら、compilerOptions.typesに@types/jestを追加しておく
    tsconfig.json
    {
        "include": [
            // (中略)
            "src/jest/jest-prisma.d.ts"
        ],
        // (中略)
        "compilerOptions": {
            "types": ["@types/jest"],
            // (中略)
        }
        // (後略)
    }
    
  3. setupFilesAfterEnvを利用してjest-prisma環境に自作PrismaClientを設定

    setFilesAfterEnvとは

    • 各テストファイルが実行される前に(セットアップのためなどに)実行するモジュールへのパスリスト
    • テストフレームワークが環境にインストールされた直後で、 テストコードそのものが実行される前に実行する

    参考: JEST Docs. Jestの設定 -setupFilesAfterEnv

    やること

    1. セットアップのためのコードを作成

      • いい感じのところに設定用のファイル(src/jest/setupAfterEnv.ts)を作る
      • jestPrisma.initializeClientに自作PrismaClientを渡す
      src/jest/setupAfterEnv.ts
      import { customizedPrisma } from "@/services/prismaClients";
      
      jestPrisma.initializeClient(customizedPrisma);
      
    2. setupFilesAfterEnvへそのファイルのパスを渡す

      • jest.config.jsを編集する
      • setupFilesAfterEnvにファイルパスを渡す
      • もしなかったら、testEnvironmentも設定する
      jest.config.js
      /** @type {import('jest').Config} */
      module.exports = {
          // (中略)
          testEnvironment: "@quramy/jest-prisma/environment",
          setupFilesAfterEnv: ["<rootDir>/src/jest/setupAfterEnv.ts"],
      };
      

使うときは...

単純にjestPrisma.clientを利用するだけ
ちょっと前の記事で言ってた、JestPrismaにas typeof 自作関数をつけると型の問題を回避できますは、最悪の回避でした。反省。

hoge.test.ts
    test("プロフィールが存在する場合、データを返す", async () => {
        // テストデータ
        const mockUser: User = {
            email: "petaxa@gmail.com",
            name: "petaxa",
            id: 1,
            createdAt: new Date("2023-09-05T10:00:00Z"),
            updatedAt: new Date("2023-09-05T11:00:00Z"),
        };
        const mockProfile: Profile = {
            sex: 1,
            height: 150,
            birthday: new Date("2023-09-05T10:00:00Z"),
            id: 1,
            userId: 1,
            createdAt: new Date("2023-09-05T10:00:00Z"),
            updatedAt: new Date("2023-09-05T11:00:00Z"),
        };

        // テストデータをDBに格納
        const jestPrismaClient = jestPrisma.client;
        await jestPrismaClient.user.create({
            data: mockUser,
        });
        await jestPrismaClient.profile.create({
            data: mockProfile,
        });

        // 想定されるデータ
        const expestProfile: Profile = {
            sex: 1,
            height: 150,
            birthday: new Date("2023-09-05T00:00:00Z"),
            id: 1,
            userId: 1,
            createdAt: new Date("2023-09-05T19:00:00Z"),
            updatedAt: new Date("2023-09-05T20:00:00Z"),
        };

        // テスト実行
        const result = await findUniqueProfileAbsoluteExist(
            { id: 1 },
            jestPrismaClient
        );
        expect(result).toEqual(expestProfile);
    });
petaxa | いちむらゆうまpetaxa | いちむらゆうま

APIで扱うタイムゾーンをUTCにする

JSTで受け取り、JSTで...というのがフロントにとって楽だと思い、PrismaClientを拡張して頑張っていましたが、テストコードのことや集計系(count, aggregate)のメソッドのことを考えると、メリットのわりにつらいことが多いと思ったので、UTCとJSTとの変換処理をなくしました。

これからの使い方ですが、2点あると思います。

  • APIのパラメータの要件でDateをUTCとする
  • APIのパラメータの要件でDateをJSTとし、設定上はUTCだけど暗黙の了解でJSTとしてコードを書いていく

結論から言うと、前者の仕様にします。

フロントエンドを想像すると、後者のがうれしいかなという気持ちはあります。
タイムゾーンを変換する必要がなくなるので。
海外向けにサービスを行うときには変換が必要ですが、膨大な量の英訳は今のところちょっと無理筋かなと思います。
ただ、ものは正しく使うべきな気がします。
フロントで1つ変換処理が増えるだけとも言えるので、前者にします。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

jest-prismaで、自作のPrismaClientと生のPrismaClientを一緒に使いたい(うまくいかない)

経過をメモっていきます。

とりあえず、今わかっていること

  • @quramy/jest-prisma-corePrismaEnvironmentDelegateクラスのpreSetupで新たなjestPrismaを作れそう
  • その引数がわからない
    • 型は、JestEnvironmentConfigEnvironmentContext
    • 値をどこからとってくれば良いかわからない
    • @jest/environmentの型なので、jestの何かで取得できるのではと勘繰っている
import type {
    JestEnvironmentConfig,
    EnvironmentContext,
} from "@jest/environment";
import { PrismaEnvironment } from "@quramy/jest-prisma";
import { PrismaEnvironmentDelegate } from "@quramy/jest-prisma-core";

const config: JestEnvironmentConfig = {};
const context: EnvironmentContext = {};

const env = new PrismaEnvironment(config, context);
const delegate = new PrismaEnvironmentDelegate(config, context);

globalThis.rowJestPrisma = delegate.preSetup();
petaxa | いちむらゆうまpetaxa | いちむらゆうま

nodeModule内のコードを引っ張ってきてみる

結論: ダメだった

手順

  1. node_modules\@quramy\jest-prisma\lib\environment.jsから全部コードをコピーして、新たなファイルを作る

    myEnvironment.js
    "use strict";
    
    var __importDefault = (this && this.__importDefault) || function (mod) {
        return (mod && mod.__esModule) ? mod : { "default": mod };
    };
    Object.defineProperty(exports, "__esModule", { value: true });
    const jest_environment_jsdom_1 = __importDefault(require("jest-environment-jsdom"));
    const jest_prisma_core_1 = require("@quramy/jest-prisma-core");
    class PrismaEnvironment extends jest_environment_jsdom_1.default {
        constructor(config, context) {
            super(config, context);
            this.delegate = new jest_prisma_core_1.PrismaEnvironmentDelegate(config, context);
        }
        async setup() {
            const jestPrisma = await this.delegate.preSetup();
            await super.setup();
            this.global.jestPrisma = jestPrisma;
        }
        handleTestEvent(event) {
            return this.delegate.handleTestEvent(event);
        }
        async teardown() {
            await Promise.all([super.teardown(), this.delegate.teardown()]);
        }
    }
    exports.default = PrismaEnvironment;
    
    
  2. 新たな(生の)jestPrismaをglobalに定義する
    型情報を記述して、先ほどコピーしたコードを編集する

    型情報: jest-prisma.d.tsに型情報を追加

    jest-prisma.d.ts
    import type { JestPrisma } from "@quramy/jest-prisma-core";
    import { customizedPrisma } from "@/services/prismaClients";
    import { PrismaClient } from "@prisma/client";
    
    declare global {
        var jestPrisma: JestPrisma<typeof customizedPrisma>;
    +    var rowJestPrisma: JestPrisma<PrismaClient>;
    }
    

    コードの編集: myEnvironment.jsで、新たなjestPrismaの定義先を変更する

    myEnvironment.js
    "use strict";
    
    var __importDefault = (this && this.__importDefault) || function (mod) {
        return (mod && mod.__esModule) ? mod : { "default": mod };
    };
    Object.defineProperty(exports, "__esModule", { value: true });
    const jest_environment_jsdom_1 = __importDefault(require("jest-environment-jsdom"));
    const jest_prisma_core_1 = require("@quramy/jest-prisma-core");
    class PrismaEnvironment extends jest_environment_jsdom_1.default {
        constructor(config, context) {
            super(config, context);
            this.delegate = new jest_prisma_core_1.PrismaEnvironmentDelegate(config, context);
        }
        async setup() {
            const jestPrisma = await this.delegate.preSetup();
            await super.setup();
    -        this.global.jestPrisma = jestPrisma;
    +        this.global.rowJestPrisma = jestPrisma;
        }
        handleTestEvent(event) {
            return this.delegate.handleTestEvent(event);
        }
        async teardown() {
            await Promise.all([super.teardown(), this.delegate.teardown()]);
        }
    }
    exports.default = PrismaEnvironment;
    
    
  3. jest.config.jsに新たな定義を作る
    その際、testEnvironmentにはmyEnvironment.jsのファイルパスを書いておく

    jest.config.js
    /** @type {import('jest').Config} */
    module.exports = {
        preset: "ts-jest",
        testEnvironment: "myEnvironment.js",
        moduleNameMapper: {
            "@/(.*)": "<rootDir>/src/$1",
        },
        setupFilesAfterEnv: ["<rootDir>/src/jest/setupAfterEnv.ts"],
    };
    
  4. 動かしてみる

    以下のようなエラーが出てうまくいかない
    ※そもそも、jest.config.jsに2つmodule.exportsがあるのもだめかもしれない

    root@2d9a8aa3a840:/project# npm test src/services/prismaClients/__tests__/index.test.ts 
    
    > recordvisceraapi@0.0.0 test
    > jest src/services/prismaClients/__tests__/index.test.ts
    
    ● Validation Error:
    
      Test environment myEnv.ts cannot be found. Make sure the testEnvironment configuration option points to an existing node module.
    
      Configuration Documentation:
      https://jestjs.io/docs/configuration
    
petaxa | いちむらゆうまpetaxa | いちむらゆうま

一旦諦めました;;

偶然たまたま私のPrismaClientでは取得系のメソッドにカスタムを施していないので、そのままテストで使うことにしました。
うーん、、、。忘れたころに変更をして、良くないことが起こる気がします。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

Jestで、throwを使ったエラーの受け取りができない

エラーを投げるケースのテストを記述していたのですが、テストがPassできません。
似ているメソッドを色々試したけど、何だかうまくいきません。
原因や根本的な解決策はわかっていませんが、メモとして記録します。

テスト対象の関数、テストコードの全文は以下です。

ソースコード全文

テスト対象関数

authService.ts
import {
    TOKEN_NOT_DISCREPANCY,
    TOKEN_NOT_FOUND,
} from "@/consts/responseConsts";
import type { Request, Response, NextFunction } from "express";
import { verify } from "jsonwebtoken";
import type { JwtPayload } from "jsonwebtoken";
import { basicHttpResponce } from "./utilResponseService";
import { findUniqueUserAbsoluteExist } from "./prismaService";
import { USER_LOGIN_STATUS } from "@/consts/db";
import { customizedPrisma } from "./prismaClients";
import { errorResponseHandler } from "./errorHandlingService";
import {
    UNSPECIFIED_USER_ID,
    UNSPECIFIED_USER_ID_TYPE,
} from "@/consts/logConsts";

/**
 * トークン認証
 * ログイン済のユーザーのみにしかみれないページにミドルウェアとして使う
 * x-auth-tokenという名前でheaderからtokenを取得する。
 * jwtからデコードしたuserIdをbodyに追加する
 * デコードしたuserIdに該当するユーザーの存在チェックも行う。
 * 存在しない場合400エラー
 * @param req
 * @param res
 * @param next
 * @returns
 */
export const auth = async (req: Request, res: Response, next: NextFunction) => {
    const funcName = auth.name;
    //トークン認証
    const jwt = req.header("x-auth-token");

    // tokenがない場合、400エラー
    if (!jwt) {
        const HttpStatus = 400;
        const responseStatus = false;
        const responseMsg = TOKEN_NOT_FOUND.message;
        return basicHttpResponce(res, HttpStatus, responseStatus, responseMsg);
    }

    // PrivateKeyがないときはエラー
    const privateKey = process.env.JWTPRIVATEKEY;
    if (!privateKey) {
        throw new Error("auth: 環境変数が足りません");
    }

    let userId: number | UNSPECIFIED_USER_ID_TYPE = UNSPECIFIED_USER_ID.message;

    try {
        // tokenをデコード
        const decoded = (await verify(jwt, privateKey)) as JwtPayload;

        // userの有無を確認
        const user = await findUniqueUserAbsoluteExist(
            { id: decoded.id },
            customizedPrisma
        );
        userId = user.id;

        // ログアウトまたは退会済みの場合、400エラー
        if (
            user.loginStatus === USER_LOGIN_STATUS.logout ||
            user.loginStatus === USER_LOGIN_STATUS.deactived
        ) {
            const HttpStatus = 400;
            const responseStatus = false;
            const responseMsg = TOKEN_NOT_DISCREPANCY.message;
            return basicHttpResponce(
                res,
                HttpStatus,
                responseStatus,
                responseMsg
            );
        }

        // reqのbodyにuserIdを追加
        req.body.userId = decoded.id;
        next();
    } catch (e) {
        errorResponseHandler(e, userId, req, res, funcName);
    }
};

テストコード全文

authService.test.ts
import type { Request, Response, NextFunction } from "express";
import { auth } from "@/services/authService";
import { verify } from "jsonwebtoken";
import { findUniqueUserAbsoluteExist } from "@/services/prismaService";
import { USER_LOGIN_STATUS } from "@/consts/db";
import { basicHttpResponce } from "@/services/utilResponseService";

// auth内で使う関数, 変数をモック化
jest.mock("jsonwebtoken", () => ({
    ...jest.requireActual("jsonwebtoken"),
    verify: jest.fn(),
}));
jest.mock("@/services/prismaService", () => ({
    ...jest.requireActual("@/services/prismaService"),
    findUniqueUserAbsoluteExist: jest.fn(),
}));
jest.mock("@/services/utilResponseService", () => ({
    ...jest.requireActual("@/services/utilResponseService"),
    basicHttpResponce: jest.fn(),
}));

// 有効なトークンとユーザーの設定
const VALID_TOKEN = "VALID_TOKEN";
const DECODED_TOKEN = { id: 1 };
const ACTIVE_USER = {
    email: "petaxa@gmail.com",
    name: "petaxa",
    password: "$12365gjoiwe",
    verifyEmailHash: null,
    passResetHash: null,
    loginStatus: USER_LOGIN_STATUS.login,
    verified: 1,
    id: 1,
    createdAt: new Date("2023-09-05T10:00:00Z"),
    updatedAt: new Date("2023-09-05T11:00:00Z"),
};

describe("authの単体テスト", () => {
    let mockRequest: Partial<Request>;
    let mockResponse: Partial<Response>;
    let nextFunction: NextFunction;

    beforeEach(() => {
        jest.clearAllMocks();
        // request, response, nextをモック化
        mockRequest = {
            header: jest.fn(),
            body: {
                userId: "",
            },
        };
        nextFunction = jest.fn();

        // auth内の関数をモック化
        // トークン取得
        mockRequest.header = jest.fn().mockReturnValue(VALID_TOKEN);
        // トークンのデコード
        (verify as jest.Mock).mockResolvedValue(DECODED_TOKEN);
        // user取得
        (findUniqueUserAbsoluteExist as jest.Mock).mockResolvedValue(
            ACTIVE_USER
        );
    });

    afterEach(() => {
        jest.restoreAllMocks();
    });

    test("正常", async () => {
        // テスト対象実行
        await auth(
            mockRequest as Request,
            mockResponse as Response,
            nextFunction
        );

        // verifyが実行されたことを確認
        expect(verify).toHaveBeenCalledWith(VALID_TOKEN, expect.anything());
        // findUniqueUserAbsoluteExistが実行されたことを確認
        expect(findUniqueUserAbsoluteExist).toHaveBeenCalledWith(
            { id: DECODED_TOKEN.id },
            expect.anything()
        );
        // userIdが正しくセットされていることを確認
        expect(mockRequest.body.userId).toBe(DECODED_TOKEN.id);
        // next()が呼ばれたことを確認
        expect(nextFunction).toHaveBeenCalled();
    });

    test("tokenがない", async () => {
        // auth内の関数をモック化
        // トークン取得(取得できない)
        mockRequest.header = jest.fn().mockReturnValueOnce(null);
        // レスポンス作成
        (basicHttpResponce as jest.Mock).mockResolvedValueOnce("400Error");

        // テスト対象実行
        await auth(
            mockRequest as Request,
            mockResponse as Response,
            nextFunction
        );

        // 正しい引数でbasicHttpResponceが呼ばれたことを確認
        expect(basicHttpResponce).toHaveBeenCalledWith(
            mockResponse,
            400,
            false,
            "トークンが見つかりません。"
        );
    });

    test("PrivateKeyがない", async () => {
        // JWTPRIVATEKEYをmock化
        const originalEnvVar = process.env.JWTPRIVATEKEY;
        process.env.JWTPRIVATEKEY = undefined;

        // reject.throwだとうまく動かない
        try {
            // テスト実行
            await auth(
                mockRequest as Request,
                mockResponse as Response,
                nextFunction
            );
        } catch (e) {
            expect(e).toBeInstanceOf(Error);
            if (e instanceof Error) {
                expect(e.message).toBe("auth: 環境変数が足りません");
            }
        } finally {
            // 環境変数を元に戻す
            process.env.JWTPRIVATEKEY = originalEnvVar;
        }
    });

    test("userがlogoutしている", async () => {
        const logoutUser = {
            email: "petaxa@gmail.com",
            name: "petaxa",
            password: "$12365gjoiwe",
            verifyEmailHash: null,
            passResetHash: null,
            loginStatus: USER_LOGIN_STATUS.logout,
            verified: 1,
            id: 1,
            createdAt: new Date("2023-09-05T10:00:00Z"),
            updatedAt: new Date("2023-09-05T11:00:00Z"),
        };

        // user取得をmock化
        (findUniqueUserAbsoluteExist as jest.Mock).mockResolvedValueOnce(
            logoutUser
        );

        // テスト対象実行
        await auth(
            mockRequest as Request,
            mockResponse as Response,
            nextFunction
        );

        // 正しい引数でbasicHttpResponceが呼ばれたことを確認
        expect(basicHttpResponce).toHaveBeenCalledWith(
            mockResponse,
            400,
            false,
            "トークンが一致しません。"
        );
    });

    test("userが退会している", async () => {
        const deactivedUser = {
            email: "petaxa@gmail.com",
            name: "petaxa",
            password: "$12365gjoiwe",
            verifyEmailHash: null,
            passResetHash: null,
            loginStatus: USER_LOGIN_STATUS.deactived,
            verified: 1,
            id: 1,
            createdAt: new Date("2023-09-05T10:00:00Z"),
            updatedAt: new Date("2023-09-05T11:00:00Z"),
        };

        // user取得をmock化
        (findUniqueUserAbsoluteExist as jest.Mock).mockResolvedValueOnce(
            deactivedUser
        );

        // テスト対象実行
        await auth(
            mockRequest as Request,
            mockResponse as Response,
            nextFunction
        );

        // 正しい引数でbasicHttpResponceが呼ばれたことを確認
        expect(basicHttpResponce).toHaveBeenCalledWith(
            mockResponse,
            400,
            false,
            "トークンが一致しません。"
        );
    });
});

今回は「PrivateKeyがない」というケースにて起こったことを記録していきます。
ここでは、.envの該当の変数を読み、それが不適な値だった時に適切なエラーを投げているかを確認します。
テスト対象の関数は非同期の関数なので、await except().regect.toThrow()を利用してエラーのテストを試みたがうまくいかなかったという話です。

結論 -最終的にどういうコードに落ち着いたか

try...catch文を利用し、エラーのinstanceとメッセージをチェックすることで代用しました。
該当のテストケースのテストコードは以下のようになりました。

テストケース全文
authService.test.ts -PrivateKeyがない
    test("PrivateKeyがない", async () => {
        // JWTPRIVATEKEYをmock化
        const originalEnvVar = process.env.JWTPRIVATEKEY;
        process.env.JWTPRIVATEKEY = undefined;

        // reject.throwだとうまく動かない
        try {
            // テスト実行
            await auth(
                mockRequest as Request,
                mockResponse as Response,
                nextFunction
            );
        } catch (e) {
            expect(e).toBeInstanceOf(Error);
            if (e instanceof Error) {
                expect(e.message).toBe("auth: 環境変数が足りません");
            }
        } finally {
            // 環境変数を元に戻す
            process.env.JWTPRIVATEKEY = originalEnvVar;
        }
    });

想定通り動作しないパターン

動作しなかったコードを以下に示します。

一番やりたいパターンでやってみる

一番最初に取り組んだパターンです。

envの変数を確実にもとに戻したかったので、try...finally構文の中にexpectを配置します。
※以降のコードの内、試したパターンを説明したコードでは共通部分(envのmock化、該当ケース外のコード等)を省略しています。

authService.test.ts -PrivateKeyがない
        try {
            // テスト実行
            await expect(
                auth(
                    mockRequest as Request,
                    mockResponse as Response,
                    nextFunction
                )
            ).rejects.toThrow("auth: 環境変数が足りません");
        } finally {
            // 環境変数を元に戻す
            process.env.JWTPRIVATEKEY = originalEnvVar;
        }
Console
  ● authの単体テスト › PrivateKeyがない

    expect(received).rejects.toThrow()

    Received promise resolved instead of rejected
    Resolved to value: undefined

      137 |         try {
      138 |             // テスト実行
    > 139 |             await expect(
          |                   ^
      140 |                 auth(
      141 |                     mockRequest as Request,
      142 |                     mockResponse as Response,

      at expect (node_modules/expect/build/index.js:113:15)
      at Object.<anonymous> (src/services/__tests__/authService.test.ts:139:19)

Consoleの文言を見ると、実行した関数から返ってくるPromiseが、rejectではなくresolveになっている様子です。

元の関数で、reject関数を実行する

恐らく仕組みを理解していないせいで、なぜなのかがわからない。
ということで、仕組みをググっていたらドンピシャっぽい記事が出てきました。
Qiita. @takayukioda(Takayuki Oda). Promiseを返す関数で throw する時の注意点.
https://qiita.com/takayukioda/items/df0e0320d803bf28ba22

await expect(update(1, new Date(), new Date(0)).rejects.toThrow()
これは端的に言えば「例外を投げていればOK」なのですが、きちんと読もうとすると「Reject関数が呼ばれて、その中に Error があるか」なんですね。

Reject 関数に Error があればいいということなので、 throw new Error('Since cannot be before until') の部分を reject 関数を使う形に書き直せば治ります。
もう一つの方法として、関数に async キーワードをつけることです。

ただ、引用の最後の一文の通り、asyncキーワードをつけていれば問題ないっぽいです。
今回はつけているので、問題ないはず。ですが、症状が似ている気がするのでreject関数を呼ぶ方法を試してみます。

authService.ts -Errorをthrowするところ
    // PrivateKeyがないときはエラー
    const privateKey = process.env.JWTPRIVATEKEY;
    if (!privateKey) {
        return Promise.reject(new Error("auth: 環境変数が足りません"));
    }
Console
  ● authの単体テスト › PrivateKeyがない

    expect(received).rejects.toThrow()

    Received promise resolved instead of rejected
    Resolved to value: undefined

      137 |         try {
      138 |             // テスト実行
    > 139 |             await expect(
          |                   ^
      140 |                 auth(
      141 |                     mockRequest as Request,
      142 |                     mockResponse as Response,

      at expect (node_modules/expect/build/index.js:113:15)
      at Object.<anonymous> (src/services/__tests__/authService.test.ts:139:19)

だめでした。
さすがに関係なさそうです。

try...finallyを外してみる

envを確実にロールバックするためにつけていたtry...finallyを外してみます

authService.test.ts -PrivateKeyがない
        await expect(
            auth(mockRequest as Request, mockResponse as Response, nextFunction)
        ).rejects.toThrow("auth: 環境変数が足りません");
        process.env.JWTPRIVATEKEY = originalEnvVar;
Console
  ● authの単体テスト › PrivateKeyがない

    expect(received).rejects.toThrow()

    Received promise resolved instead of rejected
    Resolved to value: undefined

      165 |
      166 |         // 動作しない
    > 167 |         await expect(
          |               ^
      168 |             auth(mockRequest as Request, mockResponse as Response, nextFunction)
      169 |         ).rejects.toThrow("auth: 環境変数が足りません");
      170 |         process.env.JWTPRIVATEKEY = originalEnvVar;

      at expect (node_modules/expect/build/index.js:113:15)
      at Object.<anonymous> (src/services/__tests__/authService.test.ts:167:15)

全く同じエラーです。
関係はないみたい。

何もしないcatchを入れてみる

try...finallyぼ構文に戻し、やけくそで意味のないcatchを入れてみました。
結論から言うと、想定通り動きました。

authService.test.ts -PrivateKeyがない
        try {
            // テスト実行
            await expect(
                auth(
                    mockRequest as Request,
                    mockResponse as Response,
                    nextFunction
                )
            ).rejects.toThrow("auth: 環境変数が足りません");
        } catch (e) {
        } finally {
            // 環境変数を元に戻す
            process.env.JWTPRIVATEKEY = originalEnvVar;
        }
Console
 PASS  src/services/__tests__/authService.test.ts (19.457 s)
  authの単体テスト
    ✓ 正常 (292 ms)
    ✓ tokenがない (3 ms)
    ✓ PrivateKeyがない (3 ms)
    ✓ userがlogoutしている (2 ms)
    ✓ userが退会している (2 ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        40.387 s
Ran all test suites matching /src\/services\/__tests__\/authService.test.ts/i.

色々考えてみます。
とりあえず、catchしたeをログに出力しました。

{
    "message": "\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mrejects\u001b[2m.\u001b[22mtoThrow\u001b[2m()\u001b[22m\n\nReceived promise resolved instead of rejected\nResolved to value: \u001b[31mundefined\u001b[39m"
}

謎のメッセージだと思いましたが、今までコンソールに出ていたメッセージでした。
メッセージに色の指定を含めたものっぽいです。
それらを除くと以下のようになります。

expect(
    
received

).
rejects
.
toThrow
()


Received promise resolved instead of rejected
Resolved to value: 
undefined

参考: C言語でターミナルで表示される文字をカラー表示させる

単純にエラーを握りつぶしているだけっていうことになりそうです。
でもなんでこれでエラーがPassするのかがよくわかりません。うーん。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

expectに関数を渡せばうまくいく?

結論から言うとうまくいきませんでした。

着想等々

ドキュメントで、expectに渡す値を「中で対象の関数を実行する関数」にしていました。

expect(() => compileAndroidCode()).toThrow();

Jest. はじめに>Matcherを使用する #例外
https://jestjs.io/ja/docs/using-matchers#例外

別の関数のテストコードで例外処理が上手くいかず、調べていたら発見しました。
例の通りに実装したら想定通り動くようになったので、こっちでも試してみます。

単純にラップしてみる

authService.test.ts -PrivateKeyがない
        try {
            // テスト実行
            await expect(async() => {
                await auth(
                    mockRequest as Request,
                    mockResponse as Response,
                    nextFunction
                );
            }).rejects.toThrow("auth: 環境変数が足りません");
        } finally {
            // 環境変数を元に戻す
            process.env.JWTPRIVATEKEY = originalEnvVar;
        }
Console
  ● authの単体テスト › PrivateKeyがない

    expect(received).rejects.toThrow()

    Received promise resolved instead of rejected
    Resolved to value: undefined

      137 |         try {
      138 |             // テスト実行
    > 139 |             await expect(async() => {
          |                   ^
      140 |                 await auth(
      141 |                     mockRequest as Request,
      142 |                     mockResponse as Response,

      at expect (node_modules/expect/build/index.js:113:15)
      at Object.<anonymous> (src/services/__tests__/authService.test.ts:139:19)

うーん、同じ。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

解決策として提示していたテストは、なんの意味もないテストだった

このスレッドの先頭の解決策として示していたコードは、何のテストにもなっていませんでした。

該当のテストコード
authService.test.ts -PrivateKeyがない
test("PrivateKeyがない", async () => {
        // JWTPRIVATEKEYをmock化
        const originalEnvVar = process.env.JWTPRIVATEKEY;
        process.env.JWTPRIVATEKEY = undefined;

        // reject.throwだとうまく動かない
        try {
            // テスト実行
            await auth(
                mockRequest as Request,
                mockResponse as Response,
                nextFunction
            );
        } catch (e) {
            expect(e).toBeInstanceOf(Error);
            if (e instanceof Error) {
                expect(e.message).toBe("auth: 環境変数が足りません");
            }
        } finally {
            // 環境変数を元に戻す
            process.env.JWTPRIVATEKEY = originalEnvVar;
        }
    });

原因

端的に言えば、テスト対象関数がErrorを投げていなかったということになります。
エラーを投げていないので、どのexpectも通りません。
結果、テストがOKになってしまいます。
驚くべき程アホですが、これの結果は以下の2点を示します。

  • スレッドの主題である「jestのrejects.toThrowを使ったテストができない」のは当然であった
  • envをmock化する方法が間違っていた

原因の本質的な特定は簡単でした。
テスト対象関数で、envの変数の中身とその型を出力してみると、String型のundefindという文字列が保存されていました。
該当のenvの変数の有無を確認し、なかった場合にエラーを投げるという仕様ですので、当たり前ですがこれではエラーは投げられません。

修正

まずはenvの方を修正します。
テストコードのenvの変数に入れる値を空文字に変更しました。
これで、テストが想定通りになっているかを確認します。
適当なところでファイルに文字列を出力、エラーメッセージをexpectと食い違うようにし、正しく指摘されるかを確認したところ、すべて想定通りに動作しました。

authService.test.ts -PrivateKeyがない
    test("PrivateKeyがない", async () => {
        // JWTPRIVATEKEYをmock化
        const originalEnvVar = process.env.JWTPRIVATEKEY;
-       process.env.JWTPRIVATEKEY = undefind;
+       process.env.JWTPRIVATEKEY = "";

        // reject.throwだとうまく動かない
        // https://zenn.dev/link/comments/bf4b7474f6d598
        try {
            // テスト実行
            await auth(
                mockRequest as Request,
                mockResponse as Response,
                nextFunction
            );
        } catch (e) {
            expect(e).toBeInstanceOf(Error);
            if (e instanceof Error) {
-               expect(e.message).toBe("auth: 環境変数が足りません");
+               expect(e.message).toBe("wrong message");
            }
        } finally {
            // 環境変数を元に戻す
            process.env.JWTPRIVATEKEY = originalEnvVar;
        }
    });

Consoleの出力結果

  ● authの単体テスト › PrivateKeyがない

    expect(received).toBe(expected) // Object.is equality

    Expected: "wrong message"
    Received: "auth: 環境変数が足りません"

      128 |             expect(e).toBeInstanceOf(Error);
      129 |             if (e instanceof Error) {
    > 130 |                 expect(e.message).toBe("wrong message");
          |                                   ^
      131 |             }
      132 |         } finally {
      133 |             // 環境変数を元に戻す

      at Object.<anonymous> (src/services/__tests__/authService.test.ts:130:35)

メッセージが違う旨のエラーが出ました。

この状態で、reject.throwの構文に変更してみます。

authService.test.ts -PrivateKeyがない
    test("PrivateKeyがない", async () => {
        // JWTPRIVATEKEYをmock化
        const originalEnvVar = process.env.JWTPRIVATEKEY;
        process.env.JWTPRIVATEKEY = "";

        // reject.throwだとうまく動かない
        // https://zenn.dev/link/comments/bf4b7474f6d598
        try {
            // テスト実行
-           await auth(
-               mockRequest as Request,
-               mockResponse as Response,
-               nextFunction
-           );
-       } catch (e) {
-           expect(e).toBeInstanceOf(Error);
-           if (e instanceof Error) {
-               expect(e.message).toBe("wrong message");
-           }
        } finally {
            // 環境変数を元に戻す
            process.env.JWTPRIVATEKEY = originalEnvVar;
        }
        try {
            // テスト実行
+           await expect(
+               auth(
+                   mockRequest as Request,
+                   mockResponse as Response,
+                   nextFunction
+               )
+           ).rejects.toThrow("wrong message");
        } finally {
            // 環境変数を元に戻す
            process.env.JWTPRIVATEKEY = originalEnvVar;
        }
    });

Consoleの出力結果

  ● authの単体テスト › PrivateKeyがない

    expect(received).rejects.toThrow(expected)

    Expected substring: "wrong message"
    Received message:   "auth: 環境変数が足りません"

          44 |     const privateKey = process.env.JWTPRIVATEKEY;
          45 |     if (!privateKey) {
        > 46 |         throw new Error("auth: 環境変数が足りません");
             |               ^
          47 |     }
          48 |
          49 |     let userId: number | UNSPECIFIED_USER_ID_TYPE = UNSPECIFIED_USER_ID.message;

          at auth (src/services/authService.ts:46:15)
          at Object.<anonymous> (src/services/__tests__/authService.test.ts:140:21)

      143 |                     nextFunction
      144 |                 )
    > 145 |             ).rejects.toThrow("wrong message");
          |                       ^
      146 |         } finally {
      147 |             // 環境変数を元に戻す
      148 |             process.env.JWTPRIVATEKEY = originalEnvVar;

      at Object.toThrow (node_modules/expect/build/index.js:218:22)
      at Object.<anonymous> (src/services/__tests__/authService.test.ts:145:23)

こちらも、同様にメッセージが違う旨のエラーが出ました。
これで、完璧ですね。

ちなみに

ミスの根本である、「env変数にundefindを代入するとstringで保存される」に関しては、調べてまとめたいなと思います。いつか...

petaxa | いちむらゆうまpetaxa | いちむらゆうま

Jestで、jest.mockを解除する

jest.mockを利用してモジュール全体をmock化した後、そのモジュール内の任意の関数について、mock化を解除する方法を記します。
同一モジュール内のA, B, Cという関数があり、CでA, Bを実行しているようなとき、A, Bの単体テストでは通常通り実行し、Cの単体テストで実行するA, Bはmock化したかったです。
少し力技だが、あらかじめモジュール全体をmock化し、A, Bの単体テストの先頭でそのmock化を解除するような方法を執りました。

ひとつ断りを入れると、上記の方法でCのテストを行うことは叶いませんでした。
原因があまりはっきりわからないですが、同一モジュールの関数のみ、関数内でmock関数として実行されていないようです。(つまり、A, Bがmock化されていない)
mock化した別モジュールの関数を実行すると、想定通りmock化されていることは確認できました。
また、isMockFunctionで確認すると、mock化されていることも確認しました。(ただ、これはテストコード内の話なのであまり有用な情報ではないと思います)

ともあれ、jest.mockを利用し、その後個別にmock化を解除したいときもあるだろうと思い、文章に起こしてみます。

実装例

mock化の解除にはjest.unmockを利用します。
ドキュメントに記載はない(示した以外の場所にはあるかもしれない)ですが、これを実行すると、それ以降で読み込んだモジュールについて、モック化がされていないものが読み込まれるというものらしいです。(参考記事があった気がしますが、失くしてしまいました。見つかれば追記します。)
つまり、以下のような流れになるはずです。

jest.mockでモック化
↓
jest.unmockでモック化を解除
↓
再度モジュールを読み込み
↓
再度読み込んだモジュール内の該当の関数を利用し、テスト

これをコードに落とし込むと以下のようになります。
targetModuleというモジュール内の関数A, Bをmock化し、Aの単体テスト時にAのmock化を解除したものを取得しています。

targetModule.test.ts
import { A, B, C } from "./targetModule";

// Cのテストのためにmock化
// 各単体テストの時はmockを無効にする
jest.mock("./targetModule", () => ({
    ...jest.requireActual("./targetModule"),
    A: jest.fn(),
    B: jest.fn(),
}));

describe("Aのテスト", () => {
    let unmockA: typeof A;
    beforeEach(() => {
        // mock化を解除して再度読み込み
        jest.unmock("./targetModule");
        const { A } = require("./targetModule");

        unmockA = A;

        jest.clearAllMocks();
    });

    afterEach(() => {
        jest.restoreAllMocks();
    });

    test("Errorインスタンスの処理", () => {
        // テスト対象実行
        unmockA();

        expect(unmockA).toBe("hoge");
    });
});
petaxa | いちむらゆうまpetaxa | いちむらゆうま

クラスメソッドをモック化する

クラスメソッドをモック化し、それが正しい引数で呼ばれたかをチェックする方法をメモします。
モックファクトリーを利用する方法は、いまいちつかめていません。
つまり、該当のクラス以外を生かしておく(jest.requireActualを利用する)方法がいまいちわからないわけです。
公式ドキュメントには記述があるので、うまくやればうまくいくはずです。

結論

自動モックを利用し、theAutomaticMock.mock.instances[index].methodName.mock.callsから実行時の引数の情報を取得する。

loggerService.test.ts
jest.mock("@/services/loggerClass");
describe("logErrorのテスト", () => {
    beforeEach(() => {
        jest.clearAllMocks();
    });
    afterEach(() => {
        jest.restoreAllMocks();
    });
    test("正常", () => {
        // テスト対象実行
        const responseMsg = "message-logError";
        const userId = 1;
        const req: Partial<Request> = {
            body: {
                ip: "ip-logError",
                method: "ip-method",
                path: "ip-originalUrl",
                body: "ip-body",
            },
        };
        const HttpStatus = 100;
        const funcName = "funcName-logError";
        logError(responseMsg, userId, req as Request, HttpStatus, funcName);

        const expectMessage = PROCESS_FAILURE.message(funcName);
        const expectLogBody = {
            userId,
            ipAddress: req.ip,
            method: req.method,
            path: req.originalUrl,
            body: req.body,
            status: String(HttpStatus),
            responseMsg,
        };

        const loggerInstance = (CustomLogger as jest.Mock).mock.instances[0];
        expect(loggerInstance.error.mock.calls[0]).toEqual([第一引数 , 第二引数]);
    });
});
petaxa | いちむらゆうまpetaxa | いちむらゆうま

jestで、関数を含むオブジェクトを返却する関数をまるっとモック化する

以下のような感じの関数を全部モック化し、その実行を検知します

target.ts
const createTransport = () => {
    return {
        sendMail: () => {
            // 色々な処理
        }
    }
}

今回はnodemailerの関数をmock化しています。
コード例はそれに基づきます。

mock化

mock化はjest.mockで定義していきます。

target.test.ts
jest.mock("nodemailer", () => ({
    createTransport: jest.fn().mockImplementation(() => ({
        sendMail: jest.fn(),
    })),
}));

createTransportjest.fnに、その返り値にsendMailを持つオブジェクトを定義します。
sendMailjest.fnとします。

実行したのかを確認

.mock.results[0].valueを利用し、createTransportの返り値を取得し、利用します。
返り値のsendMailには、[Function mockConstructor]が入っているっぽいです。
そのため、返り値のsendMailを引数に、expectを実行すればよさそうです。
やってみます

target.test.ts
        const transport = (createTransport as jest.Mock).mock.results[0].value;
        expect(transport.sendMail).toHaveBeenCalledWith({
            from,
            to,
            subject,
            text,
        });

テストに通りました。
ちなみに、createTransportを実行し、その返り値のsendMailexpectに...だとうまくいきませんでした。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

巨大なtry...catchの是非

恐らく、自明に悪いと思われる巨大なtry...catch、現在のコードではこれが多用されている
が、これには自分なりの主張もあるため、それらを整理することを目的に以下を執筆する。

私が欲しいもの

巨大try...catchにするうえで、私が満たしたかったものが以下になります。

  • 思い出すのを簡単にしたい
  • if (e instanceof ○○)を何回も書きたくない

これだけでは意味が分からな過ぎるので、現在のコードと照らしてひとつづつ解説していきます。

思い出すのを簡単にしたい

ほとんど同じ機能(並列した機能など)を追加する際、同じような体裁を用いてコードを書きたいですが、そのために高負荷な記憶を呼び出すのは厳しいです。
既存の周りのファイルをパラっと見て、「あーそうだったな」と書けるくらいざっくりしたものが良いと。
現在、「エラーをcatchした際に実行する関数」を1つ作っておき、この中ですべてのエラーインスタンスにおける処理を記述しています。
こうすれば、エラーが起こる処理の間はtry...catchで囲んで置き、catchした際は1つだけ存在するそれ用の関数を実行するだけで済みます。
これは、あるエラーが投げられた時の処理たちを一目で見たいという欲求も満たしてくれそうです。

if (e instanceof ○○)を何回も書きたくない

これはそのままです。
これを回避するためにcatch用の関数を作成し使っています。
ちなみに、個別の関数に分けても実装上は問題なさそうです。

課題感

こんな意図だよとは言ったものの、巨大なtry...catchに対するデメリットは依然ぴんぴんしています。
そちら側から考えてみます。
ぱっと列挙すると以下のような感じです。

  • どこでどんなエラーが投げられているのかわからない
  • エラーを意図に反して握りつぶすミスが起こりやすい

こんな感じでしょうか。

どこでどんなエラーが投げられているのかわからない

これは、現在のコードでこれがわかる必要があるのかがわからず、とりあえず自分が思う方を実装してみたという経緯があります。
catchされた後の処理は専用の関数に隠蔽されていると考えると、別にどこで何が投げられているのか、ここではどの集合のエラーしか投げられないのかを感知する必要はないのではないかと思ったわけです。
何かしらのエラーは発生し、それに応じた対応を専用の関数に書いてある。これだけなのだから...ということです。
ただ、これは2点目のミスを引き起こします。

エラーを意図に反して握りつぶすミスが起こりやすい

恐らくこれが最大で最悪の問題でしょう。
そもそも、巨大なtry...catchをなくそうという言説の根幹を担っているのはこの部分だと勝手に思っています。
また、これはまとめている最中に気づきましたが、「すべてのエラーを落とさずに処理する必要はない」わけです。
APIなので、○○の時は404で..とか、○○の時は400で...とかを考えていて単純なことを見落としていました。
単純なバグによるエラー、末端のエラーなんかは落とすのが適切なときがあるのです。
これは単純なだけに私としては大きな気づきで、一気に巨大try...catchをやめて何か折衷案を探すべきだという考えに傾いてきました。

いろいろな案

いざ、巨大try...catchをやめようと思ったものの、若干気になるところはあります。
それがcatchする際に行う処理が記述された関数です。
これさえやればOKのオールインワンでゴッドな関数。
巨大try...catchをやめるという視座に立つとこれは適していないように思います。
そもそも、「どこでどんなエラーが投げられているのかわからない」という課題感もあるわけで、このまま使い続けるとその課題は解決しません。

解決法は3点ありそうです

  1. if文+ビジネスロジックが書いてある関数実行
  2. 条件分岐まで任せるオールインワン構成で、専用の関数をエラーの数だけ用意
  3. そのまま使い続ける

1. if文+ビジネスロジックが書いてある関数実行

一番すっきり書けそうです。
if文で排他的ということもわかりやすいし、そもそも何をしたいのかも明確な気がします。

try{
  // 何かの処理
} catch(e) {
  if( e instanceof HogeError) {
    // HogeErrorのハンドリング関数実行
  }
}

このとき、その他のエラーハンドリングに関しては、インターナルサーバーエラーを出したいエラーにしっかりフォーカスするべきだと考える。

末端のエラーなんかは落とすのが適切なときがあるのです

の考えから。

2. 条件分岐まで任せるオールインワン構成で、専用の関数をエラーの数だけ用意

ちょっとエレガントさが微妙な気がします。
条件分岐まで任せているのに、関数がエラーの数だけ用意されているのも意味が分かりません。
複数来そうなとき、どうするかもぱっと思いつくタイプの解決策がなさそうで、可読性も悪そうです

3. そのまま使い続ける

何も無いならこれですが、1が良さそうなのでこれにするべきな気がします。
やはり、どこでどんなエラーが投げられているのかわからないのはよくなさそうです。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

フォルダ構成大掃除

ちっさなアプリばかりを作っていたせいか、現状のコードが行き当たりばったりのキメラになってしまっています。
特に、浅い知識でControllerとServiceという名のフォルダを作り、それっぽいものをその場のみの判断で書き加えていたため、何が何だかわからなくなってしまっています。
そこで、以下のように役割を決め、作り直していきます

  • Controller: リクエストを受け取ったときに動かす関数を記述する場所
  • Service: Controllerと1対1で存在し、該当のリクエストのビジネスロジックを記述する場所
  • Utils: 各機能を横断するように利用される、共通化できるコードを記述する場所

以下のように進めました
ファイル移動+名称変更 → tsc --noEmitでエラーチェック & 解消 → jestでテスト実行 & failed解消

petaxa | いちむらゆうまpetaxa | いちむらゆうま

Controller, Serviceを正しくしよう

大掃除 で触れたとおり、これらがとんでもないことになっています。
足りない部分や開発していてつらい部分を洗い出しながら、正しくなるように整理していきます。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

バリデーションがつらい!

バリデーションがあまりうまく記述できていませんでした。
数多の強引な共通化と全体を俯瞰せずに傍若無人に書いた個別のコードが混ざり、後で読み返せるクオリティをはるかに下回っていました。
所謂、低レベルなオレオレライブラリ状態です。
フォルダ構成を整理しているのと同タイミングの今、新たにライブラリを導入するのには良い機会だと思い、express-validatorを導入しました。
理由は以下の通りです。

  • Expressの名を冠しているのは安心。かつ情報も多くヒットする
  • Expressの、ミドルウェアを積み重ねように沿った仕様になっている
  • ドキュメントにTypeScriptの記述が豊富

導入方針

ざっくり、以下のような感じで導入しようかと考えています。

  1. Serviceの、各機能のフォルダ内にvalidate.tsを作成し、それぞれのバリデーションを記述する。
  2. ここでexportしたものを、各routesのミドルウェアに追加する

仕様についての雑感をまとめておく

せっかくなので、メモ程度にドキュメントを読んだ雑感をまとめていこうと思います。
ただの感想です。

  • body('')でbody全体にバリデーションを掛けられるの便利
  • スキーマでバリデーションを行う方法もあるらしい。統一するべきな気がする。
    • メリット/デメリットを整理したい。
    • とりあえず(最初は簡単な機能を扱うので)chainで書いておく
    • DOC: api/check-schema
  • カスタムバリデータでメールが使用中かをチェックしていた。ここまでやっていいの?
    • 今回のプロジェクトでは、「渡されたuserIdのuserが存在するのか」とか。
    • メールに関してもサンプルコードと同じようなバリデーションは必要。
    • これらもバリデーションであるから、問題なくここで行ってよいのか。(たぶん)
    • DOC: customizing #Implementing a custom validator
  • check()は利用せず、body(), param()などを利用するべき(だと思う)
    • 複数のリクエストロケーションに全く同じ意味を持つパラメータがあるのはおかしい気がする
    • 意図しない項目へのバリデーションを防ぐためにも、範囲は必要最低限にするべき
    • DOC: api/check #check
petaxa | いちむらゆうまpetaxa | いちむらゆうま

Prismaの知識をメモする!

使った型を中心に、知識を記録しておく。
一通り実装が終わったら、型について体系的にまとめたいし、Prismaのコードを出典にして紹介も行いたい。(それくらい知識を付けたい)

petaxa | いちむらゆうまpetaxa | いちむらゆうま

あるテーブルを取得したときの結果

selectや、updateの返り値で取得したあるレコードについて、その型を示すもの
Prisma.$テーブル名Payload["scalars"]

例: Prisma.$UserPayload["scalars"]

petaxa | いちむらゆうまpetaxa | いちむらゆうま

serviceをエンドポイントごとにファイル分けしようと思う

Service: Controllerと1対1で存在し、該当のリクエストのビジネスロジックを記述する場所
フォルダ構成大掃除

としていたため、CRUDそれぞれをすべて同じファイルに記述していました(というかその予定でした)。
ひとつ作ってみたところ、同名の関数を作りたくなることとファイルが膨大になってしまうことからServiceはCRUDをそれぞれ1つずつファイルを用意することにしました。

フォルダ構成

/services/分類/endpoints/ にエンドポイントの分だけファイルを配置します。
例えば、ユーザーの日次記録のServiceは、以下のような構成になります

services
└── users
    └── bowelMovements
        ├── endpoints
        │   ├── regist.ts
        │   ├── read.ts
        │   ├── edit.ts
        │   ├── delete.ts
        │   └── count.ts
        └── validate.ts

※validate.tsにexpress-validatorで使うバリデーションのルールを記述します

importするとき

それぞれのファイルを名前空間インポートを行います。
これは、この考えの発端である「同名関数を許容する」に基づくものです。
副作用として、コントローラ先頭で使っている関数を一覧できなくなってしまいます。
が、Serviceに記述されている関数はすべて利用されるはずですので、それぞれのファイルで代替すれば問題ないはずです。
意図せずServiceから削除し忘れていた関数があったとしても、それは名前付きインポートでもエラーを出してくれるわけではないので許容すると決めました。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

入れ子になっているメソッドをJestでMock化する

今回のプロジェクトではPrismaのモデルクエリの実行で利用する機会が多いです。
PrismaClient.user.update()みたいなやつです。

結論

jest.mock("@/utils/prismaClients", () => ({
  customizedPrisma: {
    user: {
      update: jest.fn().mockImplementation(() => ({
        name: "mock-name",
        email: "mock@email",
        password: "mock-password",
        verifyEmailHash: null,
        passResetHash: "mock-hash",
        verified: 1,
        loginStatus: 1,
        id: 1,
        createdAt: new Date(),
        updatedAt: new Date(),
      })),
    },
  },
}));

customizedPrisma@/utils/prismaClientsで拡張し、exportしています。
(よく考えると、こうしないとJestでMock化できないかも...?)

入れ子のメソッドを...という話に戻すと、該当のメソッドまでオブジェクトのプロパティを辿り、対象のメソッドにjest.fn()を付けるだけです。
今回はそれに加えて動作についても定義しています。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

APIで空のパラメータを返却する際はnullを返す

コントローラの整理の一環で、Expressのreq, resにエンドポイントごとにカスタマイズした型を設定しています。
その時、Resへの型定義を見直し、空のパラメータにはnullを設定することに決めました。

根拠としては、

  • RFC8259で定義されている空のデータを表すリテラルはnullだから
  • 返却されたJSONのパラメータ自体が、リクエストによって変化するのは正しくなさそうだし使いづらそうだから
  • 全くドキュメントを読まなくても、一旦APIを叩いてみて、そのパラメータがあれば何かしらの方法で設定できると明示できるから。

あたりです。

petaxa | いちむらゆうまpetaxa | いちむらゆうま

取得系APIのレスポンスで、デフォルト値についてレスポンスではどう扱うのか検討する

取得系APIのレスポンスの中で、初期値が決まっているものがあります。
limit(取得数上限)とoffset(取得開始位置)です。
これらはリクエストパラメータで設定されていない場合、システムで定義されている初期値でクエリが実行されます。
この時(設定されなかったとき)、レスポンスにはどのように返すのが適切なのか検討してみる。

まず、択は以下のふたつのどちらかになりそう。

  • nullで返す
  • デフォルトの値を返す

意味の方面からそれぞれを表し直すと、

  • リクエスト時に設定した値について通告する
  • リクエストによって取得されたデータについて、その条件を通告する

となるかなと。

さて、今一度レスポンスの意味を見直してみようと思います。
APIで空のデータを返す際はnullを使うと決めました。
その中身を見てみると、

根拠としては、

  • RFC8259で定義されている空のデータを表すリテラルはnullだから
  • 返却されたJSONのパラメータ自体が、リクエストによって変化するのは正しくなさそうだし使いづらそうだから
  • 全くドキュメントを読まなくても、一旦APIを叩いてみて、そのパラメータがあれば何かしらの方法で設定できると明示できるから。

これを鑑みると、APIの振る舞いを明示するため、または予測しやすくするためというのがレスポンスで満たしたい方向です。
それらの思想以って今回の議題を考えてみると、デフォルトの値を返すがよさそうです。
ということで、、

取得系APIで、デフォルト値があるパラメータが指定されなかった場合、パラメータにはデフォルト値を入れてレスポンスする

リクエストで指定されなかった場合、デフォルトの値が存在するようなパラメータについて、クエリ実行時の条件についてのレスポンスパラメータにはデフォルト値、つまり実行時の値をセットする。

理由は以下のようなもの

  • APIの振る舞いを明示するため。また、予測しやすいため。
  • JSONの空データへの仕様を鑑み、その思想と統一するため
petaxa | いちむらゆうまpetaxa | いちむらゆうま

オブジェクトを分割代入すると不要なプロパティを取り除ける

しかも型もちゃんとつく。
おどろきすぎる。

経緯としては、あまりコードがすっきりしなくてダメもとでGPT-4に聞いてみたこと。
https://chat.openai.com/share/288befcb-1bbd-47c1-b23e-811018813833

コード詳細

リクエストで飛んできたクエリのオブジェクトから不要なプロパティ(fields, sorts, limit, offset)を取り除きたい。
それ以外の型はリクエストによって違うので、いい感じに受け取れるようになっている型(QueryTypeBasedOnReadRequest)をextendsする形で利用する。

QueryTypeBasedOnReadRequest
export type QueryTypeBasedOnReadRequest<
  Fields extends string,
  Sorts extends string,
  Filters extends Record<string, string | number | Date | null | undefined>,
> = {
  fields: Fields[] | undefined;
  sorts: Sorts[] | undefined;
  limit: number | undefined;
  offset: number | undefined;
} & Filters;
分割代入をやってみる
export const omitExceptFilters = <
  Query extends QueryTypeBasedOnReadRequest<any, any, Record<string, any>>,
>(
  query: Query,
) => {
  // フィルター条件以外を取り除く
  const { fields, sorts, limit, offset, ...filters } = query;
  // filters: Omit<Query, "fields" | "sorts" | "limit" | "offset">

  return filters;
};

ちゃんと取り除いたプロパティがOmitされている。。。