クローン病患者向け体調記録アプリを作っています
アプリを作り始めました
2023/08頃にクローン病を発症したので、それ用の健康管理アプリを作り始めました。
病床に臥しながら、Viteのテンプレートをすこーしだけ改変したフロントエンドと、GASで体温と排便の記録をスプレッドシートに書き込んでいくアプリを作りました。
ひとまず1カ月くらいで日常生活ができるようになったので、これを原案に初めてWebアプリを公開してみようということになりました。
とはいえ、公開できる体を成していないのでそれらを作っていこうというのが今回のお話です。
途方もなく間違っている見積もりで3割くらい進んだ気がするのですが、やる気が今にも消えそうなので様々公開して外的圧力を醸成しようという魂胆で書き始めました。
ひとまず、今までをダイジェストでお届けします。
※お断り: 技術の採用理由は私ができるからと名前がかっこいいからくらいしかありません。いつかちゃんと選べるくらいちゃんとした人になろうと思います。
使っている技術
時系列で説明する前に、(今のところにはなりますが)使っている技術やら環境やらを列挙しておきます。
フロントエンド
- TypeScript
- Vue
- Nuxt (使った方が良い気がするので検討しています)
- 色々未定
バックエンド
- TypeScript
- Express
- Prisma
- Nodemailer
サーバ、DB、その他
- ConohaVPS
- Route 53
- nginx
- MySQL
- Docker
- Figma
0. プロトタイプ
病床で作ったプロトタイプがどういうものかを軽く説明します。
- GAS+スプレッドシートで書き込み作業をAPI化
- (編集はおろか、削除の機能もありません。ミスったら元気なときに手で直します。)
- Viteをセットアップしたときのテンプレートを一部改変して各種入力欄を設置
- 入力内容を渡してAPIを叩き、スプレッドシートへ記録
- 基本はスマホで操作する
- 認証等は何もなし。僕のパソコンにローカルで起動して、家庭のLAN内に公開してスマホで使う。
- もちろん、外では使えない(酷く不便。)
1. ドメインを準備
まず取り組んだのがドメインの購入です。
お金を払えばやる気が無尽蔵に湧いてくると、その時は信じていたからです。
Route 53
ドメインなんて買ったことがないので、とりあえずRoute 53で買って、管理をすることにしました。
全てなんとなくで選びましたが、無料を喧伝しているサービスはシステムがちょっと姑息で嫌でした。
功罪はある気がしますが、何事も経験ということで。
サブドメインを切る
テスト環境やら、本番環境やらはよくわからない(うえにまだそこに行きつくまでに時間がかかるだろう)ということで、ひとまずAPI用のサブドメインだけ切っておきました。
AWSのドキュメント通りに作業しました。
ボタンを押して必要事項を入力するだけでできました。
レコードタイプが色々あるみたいです(Wikipedia. 「DNSレコードタイプの一覧」)
今回はAレコードのみ作りました。
2. Webサーバーを準備
もともとConohaにVPSサーバーを借りていたので、これを利用しようということになりました。
Webサーバーソフトウェアにはnginxを採用しました。
速くて強いとうわさを聞いたので。
ここでやったことは
- nginxでWebサーバーを構築して
- LetsEncryptでSSL/TLSに対応した
という感じです。
以下の2つの記事の手順通りに作業したらできました。
アイデアノート101010.Fun. 「Ubuntuにnginxをインストールしてウェブサーバーを立ち上げる」
Qiita. @akubi0w1(あくび). 「Ubuntu + nginx + LetsEncryptでSSL/TLSを設定する」
3. デザイン
普通は機能を考えるとは思うんですが、うまくまとまらなかったのでFigmaで画面を作りながら機能をまとめていくという強硬手段を執りました。
Figmaを触るのも初めてだったので、とりあえずがむしゃらです。
最終的に、画面下部にメニューバーがあるタイプのデザインをベースに各機能をデザインしました。
デザインもFigmaのデータも無茶苦茶だと思いますが、厚顔無恥を装って公開しておきます。
Figmaの公開リンク
NERV防災アプリの情報設計や彩度、明度のバランスを参考にしたり能力が足らずできなかったりしました。
その他有名なスマホアプリから知見を盗みは貼りを繰り返しています。
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
FROM node:18
WORKDIR /project
docker-compose.yaml
recordVisceraApiのボリューム、mysqlのボリュームを用意しました。
working_dirを設定しないとexec
でボリューム内に入れなかった記憶があります。
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
7. DBとの接続を行う
ORMにPrismaを利用して、スキーマの作成、DB操作を行える環境を整えました。
DB設計を行っていないことに疑問を持つかと思いますが、順序に間違いはありません。
Prismaでスキーマを書く際に設計を行いました。
(というか、設計しながらスキーマの記法に則ってメモをしていったという感じです。)
実に非効率。手戻りがすごい多かったです。
8. 文字化けを修正する
接続テストの結果、日本語を格納しようとすると文字化けすることがわかりました。
show variables like "chara%";
で文字コードを確認すると、utf-8になっていなかったので変更します。
ここで手で変更しても、まっさらにして立ち上げ直したら再設定が必要なので根本的な解決を望みます。
どうやら、my.cnf
という、mysqlの設定ファイルがあるようです。
これを作成し、docker起動時にマウントするようにすればよいみたい。
やってみます
[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
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
9. プロトタイプの機能を作成
プロトタイプで実装していた機能を、こちらで作り替えます。
バック、フロントどちらも換装して動作の確認を行いました。
- 体温記録
- トイレの記録
これらの参照、登録を行えるようにします。
バックエンド
- DBにテーブルを追加
- API作成
フロントエンド
- GASの部分を自作のAPIに換装
9. DB設計を行う
前述のとおり、schema.prismaにスキーマを書きながら設計を行いました。
概要は4. 機能の列挙
になると思います。
ここにはschema.prismaとprismaに作ってもらったER図(後述します)を貼り付けます。
※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に設定を書いています。
...
// ER図の生成
generator erd {
provider = "prisma-erd-generator"
theme = "forest"
output = "ERD.md" // 出力するファイルのpath
includeRelationFromFields = true
}
...
10. APIの各機能を作成
現在はこの作業の途中です。
作成した機能を列挙します。
自作のPrismaClientにはuserを扱う際にパスワードのハッシュ化、すべてのクエリでタイムゾーンの変更を行う機能をつけています。
また、医療情報は暗号化して保存する予定のため、その機能も追加する予定です。
現在、PrismaClientはつねに自作のものを利用するという運用にしています。
正しいかはわからないですが、ハッシュ化し忘れ、暗号化し忘れとか洒落にならないのでこれなら絶対に防げるかなという意図です。
アカウント周り(一部)実装
- アカウント作成
- メール認証
- ログイン
- プロフィール編集
- パスワード変更
- パスワードリセット
- ログアウト
トイレの記録
- 取得
- 追加
- 編集
- 削除
- 回数/日の算出
今日の体調
- 取得
- 登録
- 編集
- 削除
11. DBの初期データを自動投入できるように整備
実際には10. APIの各機能を作成
の途中でやっていたことですが、shでsqlファイルの中身を全部実行するように設定しました。
外から(exec
せずとも)実行できるように、shを二層に分けて実行しました。
以下の記事を参考に、フォルダ内のすべてのSQLファイルを実行するようにアレンジしてやってみました。
Qiita. @A-Kira(Akira Demizu). 「docker-compose でMySQL環境簡単構築」
#!/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"
#!/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個完成したら更新する
- 技術的に苦難を強いられたら更新する
つもりです。
Checkup_Mri, Checkup_CtのresultをNull許容にした
通院機能を作成中だが、通院予定の作成時、MRI, CTのレコードを追加しようとしたらresulutが必須だよと怒られました。
結論から言うと、Null許容に変更しました。
血液検査はNull許容になっているし。
(これは特定の検査結果を入れてもらえるようにカラムが定義されているので、その検査をしない場合に対応できるようにという意図です。タブン)
スキーマを設計したのが昔すぎるし、なにもメモもしていないのでなぜこうしたのか覚えていない。
ひとまず、今後はClinic_Reportが持っているCheckupに紐づいている検査が予定している/実施した検査という運用にしてみる。
Clinic_NoteのnoteもNull許可しました。
単に変更対象に列挙し忘れていただけです。
データを取得するような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から紐づく検査を...という感じです。
まだ実装もテストもしていないので、ほんとはこれではだめかもしれませんが、この方針でやってみます
データを取得するようなAPIで、年月日をそれぞれ受け取るようにした理由
はるか昔にたくさん考えて書いた仕様書に、クエリでもらう年月日をそれぞれ、year
, month
, day
でもらうようにしていました。
この時の意図を思い出せたので記録しておきます。
不都合あれば変えます。その程度の決定です。
端的に言えば、年月日のフォーマットがたくさんあるからです。
YYYY-MM-DD
とか、YYYYMMDD
とか、MM-DD-YYYY
とか、ぱっと思いつくだけでも文化圏によっていろいろあるなと。
こうすると、仕様書を読み込まないと使えないAPIになってしまいます。
これが嫌でした。
クエリの一覧をばーっとみて、使い方がほとんど想像つくようにしたかったです。
フロントを作るときのことを考えると、Date型をStringにキャストしたものを渡すのが一番楽ですが、APIを叩く側がどんな環境かはわからないので(感知しないで作るのが良いと聞いたので)、こうしました。
テストを書きます。
最近、テストのためのライブラリ?フレームワーク?っていうやつがあることがわかりました。
作り終わったらやろっかなぁと思っていましたが、すべてを中断してもうやります。
作っていたらバンバンミスが見つかるので、こりゃダメです。
べいびー一人で品質の維持管理は無理難題でした。(なんでできると思っていたのかよくわかりません。)
代表的って出てきた4つのテストをすべてやります。
機能が1個できたら(新たに叩けるAPIが完成したら)これらを行うこととします。
- 静的テスト
- ESLintでチェック
- Jestの実装が終わったらやる
- 単体テスト
- Jestを導入してテストコードを書く
- 結合テスト
- Jestを導入してテストコードを書く
- End to Endテスト
- Jestでできるっぽい?
- Zenn. Naoki Haba. Jest + Nest.jsで始める! E2Eテスト を参考にしてみます。
- テストコードの中でURLを叩けばいいのか...?
- 無理そうなら、ケースを作ってPostmanで頑張ります。
ディレクトリ構造
Zenn. takumi-n. 結局テストファイルはどこに置くのがベターなのか. のパターン2を採用しようと思う。
. └── src ├── __tests__ │ └── a.test.js ├── a.js └── dir1 ├── __tests__ │ └── b.test.js └── b.js
導入手順メモ
基本的には、サバイバルTypeScriptとJestのドキュメントを参考にしようと思います。
サバイバルTypeScript -Jestでテストを書こう
https://typescriptbook.jp/tutorials/jest
Jest -始めましょう
https://jestjs.io/ja/docs/getting-started
手順
-
Jestをインストール(Jestドキュメント)
npm install --save-dev jest@29.1.1
-
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で統一しました。 -
Jestの設定ファイルを作る(サバイバルTypeScript)
参考: Jestドキュメント -Jestの設定
コメント部分(/** @type {import("ts-jest/dist/types").InitialOptionsTsJest} */
)は廃止されたようなので削除しました。
代わりに、Jestドキュメントより、/** @type {import('jest').Config} */
を書きました。
詳しいことは調べてないです。もし機会があったら追記します。 -
package.json
にスクリプトを追加する(Jestドキュメント)package.json
のscriptsにtestを追加しました。package.json{ "scripts": { "test": "jest" } }
-
jestを試す(サバイバルTypeScript)
check.test.ts
を作成し、jestを実行してみるcheck.test.tstest("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秒、かかりすぎじゃない...?
起動も同じくらい遅いので、なにかがおかしい可能性があります。 -
check.test.tsはいらないので消します。
npmコマンド実行に時間かかりすぎ問題、解決せず
ググってみると、Docker関係の設定のせいで遅くなることがあるらしいので試してみた。
結果、変わらなかったのでこの変更は反映しないことにした。
docker-compose.yaml
で、node_modules
をボリュームマウントすると遅くなるようです。
以下の記事を参考に、docker-compose.yaml
を変更しました。
Qiita. @keito654. Dockerでnpm installが遅い問題を解決する.
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とか言っている記事もあったので、もしかしたらこのくらい普通なのかもしれない
JestでTypeScriptと同じエイリアスを使えるようにした。
そのままやってみたら(当たり前だけど)エラーが出たので、jest.config.jsにエイリアスの設定を追加した。
moduleNameMapper
を設定すればよいらしい。
/** @type {import('jest').Config} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
+ moduleNameMapper:{
+ "@/(.*)": "<rootDir>/src/$1"
+ }
};
以下のtsconfig.jsonと同じエイリアスにすることができた
"compilerOptions": {
"paths": {
"@/*": [
"src/*"
]
}
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()
に渡した値が変換されるだけの関数に置き換わる
ソースコード全文
テストコード
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("排便記録が見つかりません。"));
});
});
テスト対象関数
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
}
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.
これを利用してPrismaを利用している関数のテストをします。
今回は、取得系APIのサービス関数のテストにて、
みたいなことができるようなテストコードを書きました。
ググってみるとリレーションがしんどいっぽい雰囲気を感じますが、とりあえずこれでしばらくやってみます。
以下のような、prismaClientを引数で指定できる関数でテストします。
テスト対象関数
/**
* 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()
のコールバック関数内に以下を書いていきます。
-
DBに入れたいデータを作成
users.test.tsconst 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"), }; ```
-
jest-prismaのPrismaClient作成
型は通常渡すPrismaClientの型にアサーションする
最悪の回避方法でした。自作のPrismaClientがあるときは、ちゃんと設定する方法がありました。
設定すれば型で怒られません。
方法は以下から
const jestPrismaClient = jestPrisma.client as typeof customizedPrisma;
-
jest-prismaのPrismaClientで
create
やcrateMany
を実行users.test.tsawait jestPrismaClient.user.create({ data: mockUser, });
-
テスト対象関数を実行 この時、引数に渡すPrismaClientはjest-prismaもの
users.test.tsconst result = await findUniqueUserAbsoluteExist( { id: 1 }, jestPrismaClient );
-
expect
を実行し、結果をチェックusers.test.tsexpect(result).toEqual(mockUser);
jest-prismaで自作のPrismaClientを使うように設定した
jest-prismaのGitHubリポジトリに説明があった。
結構簡単に書いてあったので、補いながら訳しながら。
ソースコードは自分のものを使っています。(nは多い方がいいという気持ちから)
参考: Github. Quramy. jest-prisma -Use customized PrismaClient
instance.
自作のPrismaClientインスタンスをjest-prismaに設定する
通常のPrismaで、以下のように自作のPrismaClientを使うことがあるでしょう。
/**
* PrismaClientを拡張
* user.create時にpasswordをhash化する。
*/
export const customizedPrisma = prisma.$extends(extention);
以下の手順でこれをjest-prismaで使えるようにしていきます。
-
global.jestPrisma
の型を定義するjestPrismaの型を
JestPrisma<typeof 自作PrismaClient>
にするsrc/jest/jest-prisma.d.tsimport type { JestPrisma } from "@quramy/jest-prisma-core"; import { customizedPrisma } from "@/services/prismaClients"; declare global { var jestPrisma: JestPrisma<typeof customizedPrisma>; }
-
tsconfig.json
に先ほどの型定義ファイルを追加する- includeに先ほどの型定義ファイルを追加
- もしなかったら、compilerOptions.typesに
@types/jest
を追加しておく
tsconfig.json{ "include": [ // (中略) "src/jest/jest-prisma.d.ts" ], // (中略) "compilerOptions": { "types": ["@types/jest"], // (中略) } // (後略) }
-
setupFilesAfterEnv
を利用してjest-prisma環境に自作PrismaClientを設定setFilesAfterEnvとは
- 各テストファイルが実行される前に(セットアップのためなどに)実行するモジュールへのパスリスト
- テストフレームワークが環境にインストールされた直後で、 テストコードそのものが実行される前に実行する
参考: JEST Docs. Jestの設定 -setupFilesAfterEnv
やること
-
セットアップのためのコードを作成
- いい感じのところに設定用のファイル(
src/jest/setupAfterEnv.ts
)を作る -
jestPrisma.initializeClient
に自作PrismaClientを渡す
src/jest/setupAfterEnv.tsimport { customizedPrisma } from "@/services/prismaClients"; jestPrisma.initializeClient(customizedPrisma);
- いい感じのところに設定用のファイル(
-
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 自作関数
をつけると型の問題を回避できますは、最悪の回避でした。反省。
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);
});
APIで扱うタイムゾーンをUTCにする
JSTで受け取り、JSTで...というのがフロントにとって楽だと思い、PrismaClientを拡張して頑張っていましたが、テストコードのことや集計系(count
, aggregate
)のメソッドのことを考えると、メリットのわりにつらいことが多いと思ったので、UTCとJSTとの変換処理をなくしました。
これからの使い方ですが、2点あると思います。
- APIのパラメータの要件でDateをUTCとする
- APIのパラメータの要件でDateをJSTとし、設定上はUTCだけど暗黙の了解でJSTとしてコードを書いていく
結論から言うと、前者の仕様にします。
フロントエンドを想像すると、後者のがうれしいかなという気持ちはあります。
タイムゾーンを変換する必要がなくなるので。
海外向けにサービスを行うときには変換が必要ですが、膨大な量の英訳は今のところちょっと無理筋かなと思います。
ただ、ものは正しく使うべきな気がします。
フロントで1つ変換処理が増えるだけとも言えるので、前者にします。
jest-prismaで、自作のPrismaClientと生のPrismaClientを一緒に使いたい(うまくいかない)
経過をメモっていきます。
とりあえず、今わかっていること
-
@quramy/jest-prisma-core
のPrismaEnvironmentDelegate
クラスのpreSetup
で新たなjestPrismaを作れそう - その引数がわからない
- 型は、
JestEnvironmentConfig
とEnvironmentContext
- 値をどこからとってくれば良いかわからない
-
@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();
nodeModule内のコードを引っ張ってきてみる
結論: ダメだった
手順
-
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;
-
新たな(生の)jestPrismaをglobalに定義する
型情報を記述して、先ほどコピーしたコードを編集する型情報:
jest-prisma.d.ts
に型情報を追加jest-prisma.d.tsimport 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;
-
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"], };
-
動かしてみる
以下のようなエラーが出てうまくいかない
※そもそも、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
一旦諦めました;;
偶然たまたま私のPrismaClientでは取得系のメソッドにカスタムを施していないので、そのままテストで使うことにしました。
うーん、、、。忘れたころに変更をして、良くないことが起こる気がします。
Jestで、throwを使ったエラーの受け取りができない
エラーを投げるケースのテストを記述していたのですが、テストがPassできません。
似ているメソッドを色々試したけど、何だかうまくいきません。
原因や根本的な解決策はわかっていませんが、メモとして記録します。
テスト対象の関数、テストコードの全文は以下です。
ソースコード全文
テスト対象関数
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);
}
};
テストコード全文
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とメッセージをチェックすることで代用しました。
該当のテストケースのテストコードは以下のようになりました。
テストケース全文
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化、該当ケース外のコード等)を省略しています。
try {
// テスト実行
await expect(
auth(
mockRequest as Request,
mockResponse as Response,
nextFunction
)
).rejects.toThrow("auth: 環境変数が足りません");
} finally {
// 環境変数を元に戻す
process.env.JWTPRIVATEKEY = originalEnvVar;
}
● 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 する時の注意点.
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関数を呼ぶ方法を試してみます。
// PrivateKeyがないときはエラー
const privateKey = process.env.JWTPRIVATEKEY;
if (!privateKey) {
return Promise.reject(new Error("auth: 環境変数が足りません"));
}
● 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を外してみます
await expect(
auth(mockRequest as Request, mockResponse as Response, nextFunction)
).rejects.toThrow("auth: 環境変数が足りません");
process.env.JWTPRIVATEKEY = originalEnvVar;
● 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を入れてみました。
結論から言うと、想定通り動きました。
try {
// テスト実行
await expect(
auth(
mockRequest as Request,
mockResponse as Response,
nextFunction
)
).rejects.toThrow("auth: 環境変数が足りません");
} catch (e) {
} finally {
// 環境変数を元に戻す
process.env.JWTPRIVATEKEY = originalEnvVar;
}
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するのかがよくわかりません。うーん。
expectに関数を渡せばうまくいく?
結論から言うとうまくいきませんでした。
着想等々
ドキュメントで、expectに渡す値を「中で対象の関数を実行する関数」にしていました。
expect(() => compileAndroidCode()).toThrow();
別の関数のテストコードで例外処理が上手くいかず、調べていたら発見しました。
例の通りに実装したら想定通り動くようになったので、こっちでも試してみます。
単純にラップしてみる
try {
// テスト実行
await expect(async() => {
await auth(
mockRequest as Request,
mockResponse as Response,
nextFunction
);
}).rejects.toThrow("auth: 環境変数が足りません");
} finally {
// 環境変数を元に戻す
process.env.JWTPRIVATEKEY = originalEnvVar;
}
● 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)
うーん、同じ。
解決策として提示していたテストは、なんの意味もないテストだった
このスレッドの先頭の解決策として示していたコードは、何のテストにもなっていませんでした。
該当のテストコード
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と食い違うようにし、正しく指摘されるかを確認したところ、すべて想定通りに動作しました。
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の構文に変更してみます。
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で保存される」に関しては、調べてまとめたいなと思います。いつか...
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化を解除したものを取得しています。
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");
});
});
クラスメソッドをモック化する
クラスメソッドをモック化し、それが正しい引数で呼ばれたかをチェックする方法をメモします。
モックファクトリーを利用する方法は、いまいちつかめていません。
つまり、該当のクラス以外を生かしておく(jest.requireActualを利用する)方法がいまいちわからないわけです。
公式ドキュメントには記述があるので、うまくやればうまくいくはずです。
結論
自動モックを利用し、theAutomaticMock.mock.instances[index].methodName.mock.calls
から実行時の引数の情報を取得する。
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([第一引数 , 第二引数]);
});
});
jestで、関数を含むオブジェクトを返却する関数をまるっとモック化する
以下のような感じの関数を全部モック化し、その実行を検知します
const createTransport = () => {
return {
sendMail: () => {
// 色々な処理
}
}
}
今回はnodemailerの関数をmock化しています。
コード例はそれに基づきます。
mock化
mock化はjest.mockで定義していきます。
jest.mock("nodemailer", () => ({
createTransport: jest.fn().mockImplementation(() => ({
sendMail: jest.fn(),
})),
}));
createTransport
をjest.fn
に、その返り値にsendMail
を持つオブジェクトを定義します。
sendMail
もjest.fn
とします。
実行したのかを確認
.mock.results[0].value
を利用し、createTransport
の返り値を取得し、利用します。
返り値のsendMailには、[Function mockConstructor]が入っているっぽいです。
そのため、返り値のsendMailを引数に、expectを実行すればよさそうです。
やってみます
const transport = (createTransport as jest.Mock).mock.results[0].value;
expect(transport.sendMail).toHaveBeenCalledWith({
from,
to,
subject,
text,
});
テストに通りました。
ちなみに、createTransport
を実行し、その返り値のsendMail
をexpect
に...だとうまくいきませんでした。
巨大な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点ありそうです
- if文+ビジネスロジックが書いてある関数実行
- 条件分岐まで任せるオールインワン構成で、専用の関数をエラーの数だけ用意
- そのまま使い続ける
1. if文+ビジネスロジックが書いてある関数実行
一番すっきり書けそうです。
if文で排他的ということもわかりやすいし、そもそも何をしたいのかも明確な気がします。
try{
// 何かの処理
} catch(e) {
if( e instanceof HogeError) {
// HogeErrorのハンドリング関数実行
}
}
このとき、その他のエラーハンドリングに関しては、インターナルサーバーエラーを出したいエラーにしっかりフォーカスするべきだと考える。
末端のエラーなんかは落とすのが適切なときがあるのです
の考えから。
2. 条件分岐まで任せるオールインワン構成で、専用の関数をエラーの数だけ用意
ちょっとエレガントさが微妙な気がします。
条件分岐まで任せているのに、関数がエラーの数だけ用意されているのも意味が分かりません。
複数来そうなとき、どうするかもぱっと思いつくタイプの解決策がなさそうで、可読性も悪そうです
3. そのまま使い続ける
何も無いならこれですが、1が良さそうなのでこれにするべきな気がします。
やはり、どこでどんなエラーが投げられているのかわからないのはよくなさそうです。
フォルダ構成大掃除
ちっさなアプリばかりを作っていたせいか、現状のコードが行き当たりばったりのキメラになってしまっています。
特に、浅い知識でControllerとServiceという名のフォルダを作り、それっぽいものをその場のみの判断で書き加えていたため、何が何だかわからなくなってしまっています。
そこで、以下のように役割を決め、作り直していきます
- Controller: リクエストを受け取ったときに動かす関数を記述する場所
- Service: Controllerと1対1で存在し、該当のリクエストのビジネスロジックを記述する場所
- Utils: 各機能を横断するように利用される、共通化できるコードを記述する場所
以下のように進めました
ファイル移動+名称変更 → tsc --noEmit
でエラーチェック & 解消 → jest
でテスト実行 & failed解消
Controller, Serviceを正しくしよう
大掃除 で触れたとおり、これらがとんでもないことになっています。
足りない部分や開発していてつらい部分を洗い出しながら、正しくなるように整理していきます。
バリデーションがつらい!
バリデーションがあまりうまく記述できていませんでした。
数多の強引な共通化と全体を俯瞰せずに傍若無人に書いた個別のコードが混ざり、後で読み返せるクオリティをはるかに下回っていました。
所謂、低レベルなオレオレライブラリ状態です。
フォルダ構成を整理しているのと同タイミングの今、新たにライブラリを導入するのには良い機会だと思い、express-validatorを導入しました。
理由は以下の通りです。
- Expressの名を冠しているのは安心。かつ情報も多くヒットする
- Expressの、ミドルウェアを積み重ねように沿った仕様になっている
- ドキュメントにTypeScriptの記述が豊富
導入方針
ざっくり、以下のような感じで導入しようかと考えています。
- Serviceの、各機能のフォルダ内に
validate.ts
を作成し、それぞれのバリデーションを記述する。 - ここでexportしたものを、各routesのミドルウェアに追加する
仕様についての雑感をまとめておく
せっかくなので、メモ程度にドキュメントを読んだ雑感をまとめていこうと思います。
ただの感想です。
-
body('')
でbody全体にバリデーションを掛けられるの便利- utilsとかに全体に掛けたいバリデーションを共通化させておけそう。
- DOC: field-selection #whole-ody selection
- スキーマでバリデーションを行う方法もあるらしい。統一するべきな気がする。
- メリット/デメリットを整理したい。
- とりあえず(最初は簡単な機能を扱うので)chainで書いておく
- DOC: api/check-schema
- カスタムバリデータでメールが使用中かをチェックしていた。ここまでやっていいの?
- 今回のプロジェクトでは、「渡されたuserIdのuserが存在するのか」とか。
- メールに関してもサンプルコードと同じようなバリデーションは必要。
- これらもバリデーションであるから、問題なくここで行ってよいのか。(たぶん)
- DOC: customizing #Implementing a custom validator
-
check()
は利用せず、body()
,param()
などを利用するべき(だと思う)- 複数のリクエストロケーションに全く同じ意味を持つパラメータがあるのはおかしい気がする
- 意図しない項目へのバリデーションを防ぐためにも、範囲は必要最低限にするべき
- DOC: api/check #check
Prismaの知識をメモする!
使った型を中心に、知識を記録しておく。
一通り実装が終わったら、型について体系的にまとめたいし、Prismaのコードを出典にして紹介も行いたい。(それくらい知識を付けたい)
あるテーブルを取得したときの結果
selectや、updateの返り値で取得したあるレコードについて、その型を示すもの
Prisma.$テーブル名Payload["scalars"]
例: Prisma.$UserPayload["scalars"]
Prismaのindex.d.tsのパス
わすれちゃうのでメモ
node_modules/.prisma/client/index.d.ts
モデルクエリ実行時の引数の型
Prisma.テーブル名クエリ名Args
例: Prisma.UserUpdateArgs
エラーについて
参照: https://www.prisma.io/docs/orm/reference/error-reference
@prisma/client/runtime/library
からimportできた。
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から削除し忘れていた関数があったとしても、それは名前付きインポートでもエラーを出してくれるわけではないので許容すると決めました。
入れ子になっているメソッドを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()
を付けるだけです。
今回はそれに加えて動作についても定義しています。
APIで空のパラメータを返却する際はnullを返す
コントローラの整理の一環で、Expressのreq, resにエンドポイントごとにカスタマイズした型を設定しています。
その時、Resへの型定義を見直し、空のパラメータにはnullを設定することに決めました。
根拠としては、
- RFC8259で定義されている空のデータを表すリテラルはnullだから
- 返却されたJSONのパラメータ自体が、リクエストによって変化するのは正しくなさそうだし使いづらそうだから
- 全くドキュメントを読まなくても、一旦APIを叩いてみて、そのパラメータがあれば何かしらの方法で設定できると明示できるから。
あたりです。
取得系APIのレスポンスで、デフォルト値についてレスポンスではどう扱うのか検討する
取得系APIのレスポンスの中で、初期値が決まっているものがあります。
limit(取得数上限)とoffset(取得開始位置)です。
これらはリクエストパラメータで設定されていない場合、システムで定義されている初期値でクエリが実行されます。
この時(設定されなかったとき)、レスポンスにはどのように返すのが適切なのか検討してみる。
まず、択は以下のふたつのどちらかになりそう。
- nullで返す
- デフォルトの値を返す
意味の方面からそれぞれを表し直すと、
- リクエスト時に設定した値について通告する
- リクエストによって取得されたデータについて、その条件を通告する
となるかなと。
さて、今一度レスポンスの意味を見直してみようと思います。
APIで空のデータを返す際はnullを使うと決めました。
その中身を見てみると、
根拠としては、
- RFC8259で定義されている空のデータを表すリテラルはnullだから
- 返却されたJSONのパラメータ自体が、リクエストによって変化するのは正しくなさそうだし使いづらそうだから
- 全くドキュメントを読まなくても、一旦APIを叩いてみて、そのパラメータがあれば何かしらの方法で設定できると明示できるから。
これを鑑みると、APIの振る舞いを明示するため、または予測しやすくするためというのがレスポンスで満たしたい方向です。
それらの思想以って今回の議題を考えてみると、デフォルトの値を返すがよさそうです。
ということで、、
取得系APIで、デフォルト値があるパラメータが指定されなかった場合、パラメータにはデフォルト値を入れてレスポンスする
リクエストで指定されなかった場合、デフォルトの値が存在するようなパラメータについて、クエリ実行時の条件についてのレスポンスパラメータにはデフォルト値、つまり実行時の値をセットする。
理由は以下のようなもの
- APIの振る舞いを明示するため。また、予測しやすいため。
- JSONの空データへの仕様を鑑み、その思想と統一するため
オブジェクトを分割代入すると不要なプロパティを取り除ける
しかも型もちゃんとつく。
おどろきすぎる。
経緯としては、あまりコードがすっきりしなくてダメもとでGPT-4に聞いてみたこと。
コード詳細
リクエストで飛んできたクエリのオブジェクトから不要なプロパティ(fields, sorts, limit, offset)を取り除きたい。
それ以外の型はリクエストによって違うので、いい感じに受け取れるようになっている型(QueryTypeBasedOnReadRequest)をextendsする形で利用する。
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されている。。。