<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Next.js on 怠惰技術ブログ</title>
    <link>/tags/next.js/</link>
    <description>Recent content in Next.js on 怠惰技術ブログ</description>
    <generator>Hugo -- 0.147.7</generator>
    <language>ja</language>
    <lastBuildDate>Thu, 05 Mar 2026 01:30:00 +0900</lastBuildDate>
    <atom:link href="/tags/next.js/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その5</title>
      <link>/posts/2026-03-04-test-driven-design-readmeter-5/</link>
      <pubDate>Thu, 05 Mar 2026 01:30:00 +0900</pubDate>
      <guid>/posts/2026-03-04-test-driven-design-readmeter-5/</guid>
      <description>&lt;h2 id=&#34;はじめに&#34;&gt;はじめに&lt;/h2&gt;
&lt;p&gt;&lt;a href=&#34;./part4&#34;&gt;Part4&lt;/a&gt; でカスタム例外クラスと残りのエンドポイントを実装し、APIが完成した。&lt;/p&gt;
&lt;p&gt;Part5では &lt;code&gt;src/app/page.tsx&lt;/code&gt; に簡易UIを実装する。バックエンドのロジックやテストには一切触れない。
フロントエンドからAPIを叩いて動くものを作るだけだ。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;実装方針&#34;&gt;実装方針&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;page.tsx&lt;/code&gt; をClient Componentにする。&lt;/p&gt;
&lt;p&gt;Server Componentでfetchする方法もあるが、ボタン操作のたびにstateを更新して再描画する必要がある。
今回のUIは「ボタンを押す → APIを叩く → 一覧を再取得して画面を更新する」という流れが中心なので、
&lt;code&gt;useState&lt;/code&gt; + &lt;code&gt;useEffect&lt;/code&gt; で管理するClient Componentのほうがシンプルだ。&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-typescript&#34; data-lang=&#34;typescript&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;use client&amp;#34;&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;ファイル先頭にこの1行を追加する。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;実装するui&#34;&gt;実装するUI&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;本の追加フォーム（id / title / isbn）&lt;/li&gt;
&lt;li&gt;本棚（Unread / Reading / Completed のグループ表示）&lt;/li&gt;
&lt;li&gt;各本へのアクションボタン
&lt;ul&gt;
&lt;li&gt;Unread → 「読み始める」ボタン&lt;/li&gt;
&lt;li&gt;Reading → 評価入力（1〜5）＋「読了にする」ボタン&lt;/li&gt;
&lt;li&gt;全ステータス → 削除ボタン&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;型定義&#34;&gt;型定義&lt;/h2&gt;
&lt;p&gt;APIレスポンスの型を定義する。&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-typescript&#34; data-lang=&#34;typescript&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;type&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;BookStatus&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;Unread&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;|&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;Reading&amp;#34;&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;|&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;Completed&amp;#34;&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;type&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;Book&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#a6e22e&#34;&gt;id&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;string&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#a6e22e&#34;&gt;title&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;string&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#a6e22e&#34;&gt;isbn&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;string&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#a6e22e&#34;&gt;status&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;BookStatus&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#a6e22e&#34;&gt;rating&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;number&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;|&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;null&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;};
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;バックエンドの &lt;code&gt;ReadingStatus&lt;/code&gt; は &lt;code&gt;as const&lt;/code&gt; で定義した文字列リテラルなので、そのまま使える。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;データ取得&#34;&gt;データ取得&lt;/h2&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-typescript&#34; data-lang=&#34;typescript&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;async&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;function&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;fetchBooks() {&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#66d9ef&#34;&gt;try&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;res&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;await&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;fetch&lt;/span&gt;(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;/api/books&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; (&lt;span style=&#34;color:#f92672&#34;&gt;!&lt;/span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;res&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;ok&lt;/span&gt;) &lt;span style=&#34;color:#66d9ef&#34;&gt;throw&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; Error(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;取得失敗&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;const&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;Book&lt;/span&gt;[] &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;await&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;res&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;json&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;setBooks&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;data&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  } &lt;span style=&#34;color:#66d9ef&#34;&gt;catch&lt;/span&gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;e&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;setError&lt;/span&gt;(&lt;span style=&#34;color:#a6e22e&#34;&gt;e&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;instanceof&lt;/span&gt; Error &lt;span style=&#34;color:#f92672&#34;&gt;?&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;e&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;message&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;不明なエラー&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  } &lt;span style=&#34;color:#66d9ef&#34;&gt;finally&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;setLoading&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;false&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;useEffect&lt;/span&gt;(() &lt;span style=&#34;color:#f92672&#34;&gt;=&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#a6e22e&#34;&gt;fetchBooks&lt;/span&gt;();
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}, []);
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;fetchBooks&lt;/code&gt; は追加・ステータス変更・削除の後にも呼ぶ。
サーバーの状態を正として再取得するシンプルな方針だ。
楽観的更新（Optimistic Update）は今回やらない。&lt;/p&gt;</description>
    </item>
    <item>
      <title>テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その4</title>
      <link>/posts/2026-03-04-test-driven-design-readmeter-4/</link>
      <pubDate>Thu, 05 Mar 2026 00:50:00 +0900</pubDate>
      <guid>/posts/2026-03-04-test-driven-design-readmeter-4/</guid>
      <description>&lt;h2 id=&#34;はじめに&#34;&gt;はじめに&lt;/h2&gt;
&lt;p&gt;&lt;a href=&#34;./part1&#34;&gt;Part1&lt;/a&gt; でValueObject・Policy・Entityを作り、&lt;a href=&#34;./part2&#34;&gt;Part2&lt;/a&gt; でServiceをDI＋Mockテストで固めた。
&lt;a href=&#34;./part3&#34;&gt;Part3&lt;/a&gt; でPrismaを繋ぎ、&lt;code&gt;GET /api/books&lt;/code&gt; と &lt;code&gt;POST /api/books&lt;/code&gt; を動かした。&lt;/p&gt;
&lt;p&gt;Part4では残りのエンドポイントを実装する。やることは3つ。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;カスタム例外クラスの導入&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PATCH /api/books/:id/start&lt;/code&gt; と &lt;code&gt;PATCH /api/books/:id/complete&lt;/code&gt; の実装&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DELETE /api/books/:id&lt;/code&gt; の実装&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;そして「&lt;strong&gt;エラー種別ごとにHTTPステータスを整理する&lt;/strong&gt;」という設計判断を掘り下げる。&lt;/p&gt;
&lt;p&gt;また、Part3でPrisma v7特有のセットアップをしたが、v6以前と何が変わったのかをここで整理しておく。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;0-prisma-v7で何が変わったか&#34;&gt;0. Prisma v7で何が変わったか&lt;/h2&gt;
&lt;p&gt;Part3でPrismaをセットアップしたとき、v6以前と比べていくつか「見慣れない書き方」が必要だった。
v7は破壊的変更が多く、ネット上のv6時代の記事を参考にすると詰まる箇所がある。
ここで整理しておく。&lt;/p&gt;
&lt;p&gt;参考: &lt;a href=&#34;https://www.prisma.io/docs/guides/upgrade-prisma-orm/v7&#34;&gt;Upgrade to Prisma ORM 7 | Prisma Documentation&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&#34;generator-の変更&#34;&gt;generator の変更&lt;/h3&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-prisma&#34; data-lang=&#34;prisma&#34;&gt;// ❌ v6以前
generator client {
  provider = &amp;#34;prisma-client-js&amp;#34;
}

// ✅ v7
generator client {
  provider = &amp;#34;prisma-client&amp;#34;
  output   = &amp;#34;../src/generated/prisma&amp;#34;
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;v7では &lt;code&gt;prisma-client-js&lt;/code&gt; が廃止され &lt;code&gt;prisma-client&lt;/code&gt; に変わった。
Rustベースのエンジンを廃止しTypeScriptネイティブになったことに伴う変更だ。
また &lt;code&gt;output&lt;/code&gt; が必須になり、&lt;code&gt;node_modules&lt;/code&gt; への自動生成はなくなった。&lt;/p&gt;</description>
    </item>
    <item>
      <title>テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その3</title>
      <link>/posts/2026-03-04-test-driven-design-readmeter-3/</link>
      <pubDate>Wed, 04 Mar 2026 17:50:00 +0900</pubDate>
      <guid>/posts/2026-03-04-test-driven-design-readmeter-3/</guid>
      <description>&lt;h2 id=&#34;はじめに&#34;&gt;はじめに&lt;/h2&gt;
&lt;p&gt;&lt;a href=&#34;./part1&#34;&gt;Part1&lt;/a&gt; でValueObject・Policy・Entityを作り、&lt;a href=&#34;./part2&#34;&gt;Part2&lt;/a&gt; でServiceをDI＋Mockテストで固めた。
累計13テスト、全パスの状態だ。&lt;/p&gt;
&lt;p&gt;Part3ではいよいよDBを繋ぐ。やることは3つ。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Prismaセットアップ＋スキーマ定義&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PrismaBookRepository&lt;/code&gt; 実装（ドメインオブジェクトへの変換）&lt;/li&gt;
&lt;li&gt;Route HandlerでDIを組み立てる&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;そして最後に「&lt;strong&gt;テストを書かない層を意図的に決める&lt;/strong&gt;」という話をする。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;1-prismaセットアップ&#34;&gt;1. Prismaセットアップ&lt;/h2&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;npm install prisma @prisma/client
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;npx prisma init --datasource-provider sqlite
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;prisma/schema.prisma&lt;/code&gt; に Bookモデルを定義する。&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-prisma&#34; data-lang=&#34;prisma&#34;&gt;generator client {
  provider = &amp;#34;prisma-client-js&amp;#34;
}

datasource db {
  provider = &amp;#34;sqlite&amp;#34;
  url      = env(&amp;#34;DATABASE_URL&amp;#34;)
}

model Book {
  id     String  @id
  title  String
  isbn   String
  status String
  rating Int?
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;User&lt;/code&gt; と &lt;code&gt;Review&lt;/code&gt; はまだDBに持たない。Book の CRUD が動けば Part3 のゴール。&lt;/p&gt;
&lt;h3 id=&#34;なぜ-status-を-string-で持つのか&#34;&gt;なぜ &lt;code&gt;status&lt;/code&gt; を String で持つのか&lt;/h3&gt;
&lt;p&gt;Prismaは SQLiteで enum をネイティブサポートしていない。
そのため &lt;code&gt;status String&lt;/code&gt; で持ち、取り出し時に &lt;code&gt;as ReadingStatus&lt;/code&gt; でキャストする。&lt;/p&gt;</description>
    </item>
    <item>
      <title>テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その2</title>
      <link>/posts/2026-03-04-test-driven-design-readmeter-2/</link>
      <pubDate>Wed, 04 Mar 2026 02:00:00 +0900</pubDate>
      <guid>/posts/2026-03-04-test-driven-design-readmeter-2/</guid>
      <description>&lt;h2 id=&#34;前回のおさらい&#34;&gt;前回のおさらい&lt;/h2&gt;
&lt;p&gt;&lt;a href=&#34;/posts/readmeter-part1&#34;&gt;その1&lt;/a&gt;では ValueObject・Policy・Entity を作った。ポイントは「フレームワークを一切使わないので、テストがただの関数呼び出しになる」という体験だった。&lt;/p&gt;
&lt;p&gt;今回はいよいよ Service 層に入る。&lt;strong&gt;ここが設計の核心だ。&lt;/strong&gt; 複数のクラスをまたぐフローを、DI（依存性の注入）を使って DB から切り離す。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;今回追加したもの&#34;&gt;今回追加したもの&lt;/h2&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;src/
  domain/
    entity/
      User.ts              # 追加
      Review.ts            # 追加
    valueobject/
      UserName.ts          # 追加
      ReviewComment.ts     # 追加
  repository/
    BookRepository.ts      # 追加（Interface）
  service/
    BookShelfService.ts    # 追加
&lt;/code&gt;&lt;/pre&gt;&lt;hr&gt;
&lt;h2 id=&#34;user-と-review-を追加する&#34;&gt;User と Review を追加する&lt;/h2&gt;
&lt;p&gt;まず Entity の準備。今回は単純なので ValueObject から作る。&lt;/p&gt;
&lt;h3 id=&#34;username&#34;&gt;UserName&lt;/h3&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-typescript&#34; data-lang=&#34;typescript&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;export&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;UserName&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#66d9ef&#34;&gt;constructor&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;readonly&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;value&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;string&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; (&lt;span style=&#34;color:#f92672&#34;&gt;!&lt;/span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;value&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;||&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;value&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;trim&lt;/span&gt;().&lt;span style=&#34;color:#a6e22e&#34;&gt;length&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;===&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;0&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#66d9ef&#34;&gt;throw&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; Error(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;UserNameは空にできない&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;value&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;length&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;50&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#66d9ef&#34;&gt;throw&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; Error(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;UserNameは50文字以内&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;trim()&lt;/code&gt; してから空チェックしているのがポイント。スペースだけのユーザー名を弾く。&lt;/p&gt;
&lt;h3 id=&#34;reviewcomment&#34;&gt;ReviewComment&lt;/h3&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-typescript&#34; data-lang=&#34;typescript&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;export&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;ReviewComment&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#66d9ef&#34;&gt;constructor&lt;/span&gt;(&lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;readonly&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;value&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;string&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; (&lt;span style=&#34;color:#f92672&#34;&gt;!&lt;/span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;value&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;||&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;value&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;trim&lt;/span&gt;().&lt;span style=&#34;color:#a6e22e&#34;&gt;length&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;===&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;0&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#66d9ef&#34;&gt;throw&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; Error(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ReviewCommentは空にできない&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; (&lt;span style=&#34;color:#a6e22e&#34;&gt;value&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;length&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;&amp;gt;&lt;/span&gt; &lt;span style=&#34;color:#ae81ff&#34;&gt;1000&lt;/span&gt;) {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;      &lt;span style=&#34;color:#66d9ef&#34;&gt;throw&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;new&lt;/span&gt; Error(&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;ReviewCommentは1000文字以内&amp;#34;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  }
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id=&#34;user-entity&#34;&gt;User Entity&lt;/h3&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-typescript&#34; data-lang=&#34;typescript&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;import&lt;/span&gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;UserName&lt;/span&gt; } &lt;span style=&#34;color:#66d9ef&#34;&gt;from&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;../valueobject/UserName&amp;#34;&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;export&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;class&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;User&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  &lt;span style=&#34;color:#66d9ef&#34;&gt;constructor&lt;/span&gt;(
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;readonly&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;id&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;string&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;public&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;readonly&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;name&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;UserName&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;  ) {}
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;User&lt;/code&gt; 自体はシンプルだ。ロジックがない。ビジネスルールが増えれば &lt;code&gt;changeXxx()&lt;/code&gt; メソッドが生える設計だが、今は id と name を持つだけでいい。&lt;/p&gt;</description>
    </item>
    <item>
      <title>テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その1</title>
      <link>/posts/2026-03-04-test-driven-design-readmeter-1/</link>
      <pubDate>Wed, 04 Mar 2026 01:00:00 +0900</pubDate>
      <guid>/posts/2026-03-04-test-driven-design-readmeter-1/</guid>
      <description>&lt;h2 id=&#34;なぜこの記事を書いたか&#34;&gt;なぜこの記事を書いたか&lt;/h2&gt;
&lt;p&gt;動くものをまず作って、そこにテストを後付けしようとした。しかし、できなかった。&lt;/p&gt;
&lt;p&gt;具体的にはこういう壁にぶつかった。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ModelなどのDB接続の切り替えがうまくいかない&lt;/li&gt;
&lt;li&gt;Controllerをテストしようにもいろいろおかしくなる&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;悩んだ結果、気づいたことがある。&lt;strong&gt;そもそもそんなものは単体テストじゃない。&lt;/strong&gt; ロジックをControllerやModelに全部書いていたのが悪かった。&lt;/p&gt;
&lt;p&gt;「じゃあ最初からそう書けばいいじゃないか」という話だが、それが難しい。雑魚プログラマにいきなりクリーンな設計はできない。&lt;/p&gt;
&lt;p&gt;なので発想を逆にした。&lt;strong&gt;テスト前提でコードを書く。&lt;/strong&gt; テストが書けない場所にはロジックを書かない。それを体で覚えるために、今回のハンズオンを始めた。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;方針&#34;&gt;方針&lt;/h2&gt;
&lt;p&gt;生成AIに実装ヒントと次のステップを教えてもらいながら、コードは自分で書く。&lt;/p&gt;
&lt;p&gt;正直、コード自体はAIに書かせてもいいと思っている。ただ、設計の考え方は頭に入れる必要があるので、写経しながら理解を深めている。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;テストする場所の原則はシンプルだ。&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;フレームワークで用意された便利クラスはテストしない&lt;/li&gt;
&lt;li&gt;DBやAPIなど外界と接する場所はテストしない&lt;/li&gt;
&lt;li&gt;自分で書いた純粋なTS部分だけをテストする&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;作るもの&#34;&gt;作るもの&lt;/h2&gt;
&lt;p&gt;読書レビューサイト。Todoアプリはビジネスロジックがほぼ存在しないのでテストの練習に向いていない。読書レビューサイトなら本の状態遷移（積読→読中→読了）などのルールが自然に生まれるので、テストの旨味がある。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;技術スタック&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Next.js&lt;/li&gt;
&lt;li&gt;SQLite + Prisma&lt;/li&gt;
&lt;li&gt;vitest&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;ディレクトリ構成&#34;&gt;ディレクトリ構成&lt;/h2&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;src/
  domain/
    entity/
    valueobject/
    policy/
  service/
  repository/
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;この構成の考え方は以下の通り。&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;層&lt;/th&gt;
          &lt;th&gt;役割&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;entity&lt;/td&gt;
          &lt;td&gt;概念そのもの（Book, User）&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;valueobject&lt;/td&gt;
          &lt;td&gt;値の制約を持つクラス（ISBN, Rating）&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;policy&lt;/td&gt;
          &lt;td&gt;ビジネスルールを切り出したクラス&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;service&lt;/td&gt;
          &lt;td&gt;複数クラスをまたぐフロー&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;repository&lt;/td&gt;
          &lt;td&gt;DBとのアダプタ（副作用をここに閉じ込める）&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;repositoryはCI4でいうModelに近い立ち位置だ。&lt;/strong&gt; ただし決定的な違いがある。CI4のModelはActiveRecordパターンでクラス自身がDBを知っているため切り離せないが、repositoryはInterfaceと実装を分けることで差し替え可能にする。これがDI（依存性の注入）の肝で、テスト時にMockに差し替えられる。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;vitestのセットアップ&#34;&gt;vitestのセットアップ&lt;/h2&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-bash&#34; data-lang=&#34;bash&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;npm install -D vitest @vitejs/plugin-react vite-tsconfig-paths
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;npm install -D @testing-library/react @testing-library/jest-dom
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;vitest.config.ts&lt;/code&gt;&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;&#34;&gt;&lt;code class=&#34;language-typescript&#34; data-lang=&#34;typescript&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;import&lt;/span&gt; { &lt;span style=&#34;color:#a6e22e&#34;&gt;defineConfig&lt;/span&gt; } &lt;span style=&#34;color:#66d9ef&#34;&gt;from&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;vitest/config&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;import&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;react&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;from&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;@vitejs/plugin-react&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;import&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;tsconfigPaths&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;from&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;vite-tsconfig-paths&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;export&lt;/span&gt; &lt;span style=&#34;color:#66d9ef&#34;&gt;default&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;defineConfig&lt;/span&gt;({
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;plugins&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; [&lt;span style=&#34;color:#a6e22e&#34;&gt;react&lt;/span&gt;(), &lt;span style=&#34;color:#a6e22e&#34;&gt;tsconfigPaths&lt;/span&gt;()],
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#a6e22e&#34;&gt;test&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#a6e22e&#34;&gt;environment&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;jsdom&amp;#39;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#a6e22e&#34;&gt;globals&lt;/span&gt;: &lt;span style=&#34;color:#66d9ef&#34;&gt;true&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#a6e22e&#34;&gt;setupFiles&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;:&lt;/span&gt; [&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#39;./src/test/setup.ts&amp;#39;&lt;/span&gt;],
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    },
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;})
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;package.json&lt;/code&gt;のscriptsに追加。&lt;/p&gt;</description>
    </item>
  </channel>
</rss>
