テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その1

なぜこの記事を書いたか 動くものをまず作って、そこにテストを後付けしようとした。しかし、できなかった。 具体的にはこういう壁にぶつかった。 ModelなどのDB接続の切り替えがうまくいかない Controllerをテストしようにもいろいろおかしくなる 悩んだ結果、気づいたことがある。そもそもそんなものは単体テストじゃない。 ロジックをControllerやModelに全部書いていたのが悪かった。 「じゃあ最初からそう書けばいいじゃないか」という話だが、それが難しい。雑魚プログラマにいきなりクリーンな設計はできない。 なので発想を逆にした。テスト前提でコードを書く。 テストが書けない場所にはロジックを書かない。それを体で覚えるために、今回のハンズオンを始めた。 方針 生成AIに実装ヒントと次のステップを教えてもらいながら、コードは自分で書く。 正直、コード自体はAIに書かせてもいいと思っている。ただ、設計の考え方は頭に入れる必要があるので、写経しながら理解を深めている。 テストする場所の原則はシンプルだ。 フレームワークで用意された便利クラスはテストしない DBやAPIなど外界と接する場所はテストしない 自分で書いた純粋なTS部分だけをテストする 作るもの 読書レビューサイト。Todoアプリはビジネスロジックがほぼ存在しないのでテストの練習に向いていない。読書レビューサイトなら本の状態遷移(積読→読中→読了)などのルールが自然に生まれるので、テストの旨味がある。 技術スタック Next.js SQLite + Prisma vitest ディレクトリ構成 src/ domain/ entity/ valueobject/ policy/ service/ repository/ この構成の考え方は以下の通り。 層 役割 entity 概念そのもの(Book, User) valueobject 値の制約を持つクラス(ISBN, Rating) policy ビジネスルールを切り出したクラス service 複数クラスをまたぐフロー repository DBとのアダプタ(副作用をここに閉じ込める) repositoryはCI4でいうModelに近い立ち位置だ。 ただし決定的な違いがある。CI4のModelはActiveRecordパターンでクラス自身がDBを知っているため切り離せないが、repositoryはInterfaceと実装を分けることで差し替え可能にする。これがDI(依存性の注入)の肝で、テスト時にMockに差し替えられる。 vitestのセットアップ npm install -D vitest @vitejs/plugin-react vite-tsconfig-paths npm install -D @testing-library/react @testing-library/jest-dom vitest.config.ts import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tsconfigPaths from 'vite-tsconfig-paths' export default defineConfig({ plugins: [react(), tsconfigPaths()], test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], }, }) package.jsonのscriptsに追加。 ...

March 4, 2026 · 2 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その2

前回のおさらい その1では ValueObject・Policy・Entity を作った。ポイントは「フレームワークを一切使わないので、テストがただの関数呼び出しになる」という体験だった。 今回はいよいよ Service 層に入る。ここが設計の核心だ。 複数のクラスをまたぐフローを、DI(依存性の注入)を使って DB から切り離す。 今回追加したもの src/ domain/ entity/ User.ts # 追加 Review.ts # 追加 valueobject/ UserName.ts # 追加 ReviewComment.ts # 追加 repository/ BookRepository.ts # 追加(Interface) service/ BookShelfService.ts # 追加 User と Review を追加する まず Entity の準備。今回は単純なので ValueObject から作る。 UserName export class UserName { constructor(public readonly value: string) { if (!value || value.trim().length === 0) { throw new Error("UserNameは空にできない"); } if (value.length > 50) { throw new Error("UserNameは50文字以内"); } } } trim() してから空チェックしているのがポイント。スペースだけのユーザー名を弾く。 ReviewComment export class ReviewComment { constructor(public readonly value: string) { if (!value || value.trim().length === 0) { throw new Error("ReviewCommentは空にできない"); } if (value.length > 1000) { throw new Error("ReviewCommentは1000文字以内"); } } } User Entity import { UserName } from "../valueobject/UserName"; export class User { constructor( public readonly id: string, public readonly name: UserName, ) {} } User 自体はシンプルだ。ロジックがない。ビジネスルールが増えれば changeXxx() メソッドが生える設計だが、今は id と name を持つだけでいい。 ...

March 4, 2026 · 4 min