UseCaseの単体テストを書くときに気をつけていること

概要

UseCaseの単体テストを書くときに、個人的に気をつけたいことをメモします。
モックをなるべく使わない考え方もありますが、今回はモック使用前提で書いています。

UseCase(アプリケーション層)でドメイン層の値をテストしてしまっている

DDDにおいて、例えば注文情報を取得するユースケースを考えた場合、注文情報の具体的な処理はドメイン層で行われ、ユースケースはそのドメイン層を呼び出すだけの役割を持つと思います。

このとき、ユースケースのテストを作成する際に、ドメイン層をモック化せずにそのまま利用すると、ユースケースのテストがドメイン層に依存することになります。その結果、ドメイン層の仕様変更に伴い、ユースケース層のテストまで影響を受けて落ちてしまう可能性があります。

ユースケースのテストではドメイン層をモック化し、各層がそれぞれの関心事に集中できるようにするのが良いかと思っています。

現在の開発における型の扱いの変遷をTSとPHPで振り返ってみた

概要

これは大した記事ではないのですが、TypescriptやPHPを普段業務で開発している中で、そういえば型の扱いって、昔はゆるふわだったけどどんどん整備されて変わっていくよなあと思ったので、自分で振り返ってみました。
自分の経験が主な材料であり主観100% なので、ポエム的な記事になります。

7~8年前

まだTSやPHPの型安全が成熟する前で、ある意味牧歌的な開発をしていた時代(7〜8年くらい前)

Typescript

Javascriptの名残りでanyが日常的に使われている。 TS自体が黎明期で、型安全を意識した開発は少なくとも自分の周りでは見受けられませんでした。

PHP

2015年のPHP7で型が本格的にサポートされましたが、やはり特に型を指定しないコードが多かったように思いました。 https://www.php.net/manual/ja/migration70.new-features.php

3~4年前

Typescript

この頃はもうTS自体がかなり普及していて、フロント開発をする人は型安全を意識した開発になっていたように思います。
TSに関しては今もこの時期も、もうそんなに変わらないかな?という印象があります。

PHP

型サポートが強くなったPHP7に加えて、さらに型サポートされたPHP8がリリースされたので、型を指定しない書き方は大分減ったように思います。
ただ配列に色々なものをとりあえず突っ込んで、何が入っているのか分からない連想配列なんかは、まだちょくちょく見る、という感じだったかなと(現場によると思いますが)。

最近

Typescript

TSが順当に進化していって、より型安全に書けるようになっていると思います。
またTSの話とはズレるかもしれませんが、Unit Testを書いている現場も増えたように思いました。
さらに、stringやintの型を指定するだけではなく、type Status = Success | Failureのように意図した値しか入らないような型安全を意識した書き方も増えた気がします。

PHP

DDDの話になるので、型そのものの話とはちょっとズレてるかもしれません。 DDD(ドメイン駆動開発)が普及したことによって、ただ型を指定するだけではなくて、業務的にあり得る値しか入らないようなコードが多くなったように思います。

例えば電車賃を入れる変数の型として、ただintを指定するだけでは電車賃としてあり得ない数字(マイナスの数字や電車賃として存在しない大きな値)を入れることが可能になってしまいます。
そこでマイナスの値を弾いたり、電車賃として妥当な金額のみを入れられるようなオブジェクトを作って、電車賃オブジェクト $電車賃のような形で安全なコードになるようにしています。

まとめ

自分の経験則だけのポエム記事ですが、今までの自分の周りの変遷をまとめたくなったので、駄文を書いてみました。

Next.js App RouterでAPIを実装してクライアントから呼び出す手順

概要

個人開発でNext.jsのApp Routerを使用してAPIを実装してクライアントからAPIを呼ぶ実装をしたので、そのやり方を記載します。
使用バージョンは"next": "14.2.5"です。
pagesディレクトリで区切っていた時とは少しやり方が異なっています。

詳しい説明は公式で載せられていますのでご参考まで。

nextjs.org

サーバーサイド

まずルーティングは以下のようになります。

src/
└── app/
    └── api/
        └── summarize/ // 自分が実装したいAPIの名前
            └── route.ts

こうすることでクライアントサイドから以下の形で呼び出すことが可能になります。

      const res = await fetch('/api/summarize', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ url }),
      })

API(src/app/api/summarize/route.ts)の実装は以下です。
ここではGeminiを使っていますが、そこについては割愛します。

import { GoogleGenerativeAI } from '@google/generative-ai'
import { NextResponse, NextRequest } from 'next/server'

export async function POST(req: NextRequest) {
  const body = await req.json()
  const { url } = body
  const apiKey = process.env.API_KEY
  if (!apiKey) {
    return NextResponse.json({ message: 'API_KEY is not set' }, { status: 500 })
  }
  const genAI = new GoogleGenerativeAI(apiKey)
  const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' })
  const prompt = `${url}の動画を詳細に要約してください。`
  try {
    const result = await model.generateContent(prompt)
    const response = result.response
    return NextResponse.json({ response: await response.text() })
  } catch (error) {
    return NextResponse.json(
      { message: 'API通信に失敗しました。' },
      { status: 500 }
    )
  }
}

この実装では'/api/summarize'のPOSTメソッドを実装しています。
リクエストとレスポンスはNextResponse, NextRequestを使用しています。

Web/API/Responseを拡張してより便利な関数を用意したもののようです。

Functions: NextRequest | Next.js

Functions: NextResponse | Next.js

クライアントサイド

クライアントサイドでは用意されたAPIを使うだけです。
'use client'と明示しないとAPIを呼び出すときにクライアントから呼び出してと注意されます。

'use client'
export default function Home() {
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const url = e.currentTarget.url.value

    try {
      const res = await fetch('/api/summarize', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ url }),
      })

      if (!res.ok) {
        throw new Error('サーバーからの応答が失敗しました。')
      }

      const data = await res.json()
      console.log('要約結果:', data.response)
    } catch (error) {
      console.error('エラーが発生しました:', error)
    }
  }

MySQLのBooleanとtinyintとtinyint(1)の違いについて

概要

MySQL(Ver8.0を想定)のBooleanとtinyintとtinyint(1)について整理します。
まず大前提として、MySQLのテーブルではBooleanはtinyintとして扱われています。

参考:MySQL :: MySQL 8.0 リファレンスマニュアル :: 11.9 その他のデータベースエンジンのデータ型の使用

じゃあtinyintは0, 1を表すのかというと、そうでもないのでSQLを書いて確認していきます。

Create table test (
  boolCol boolean,
  tinyIntCol tinyint,
  tinyIntOneCol tinyint(1)
);

describe test;

上記のSQLを実行するとbooleanはtinyint(1)としてカラムが作成されました。
これはbooleanは実質はtinyint(1)であるためです。

参考:MySQL :: MySQL 8.0 リファレンスマニュアル :: 11.1.1 数値データ型の構文

では実際に値をInsertしてみます。
tinyint(1)は0, 1 しか格納されないイメージを持つ方もいると思いますが、どうでしょうか。

insert into test values (127, 127, 127);

select * from test;

MySQLのtinyintは、-128 ~ 127まで格納できるため、このInsertは通ります。
なので、MySQLのbooleanは、true, falseや0, 1のみを表現するものではないことを留意しておいた方が良さそうです。

MySQL :: MySQL 8.0 リファレンスマニュアル :: 11.1.2 整数型 (真数値) - INTEGER、INT、SMALLINT、TINYINT、MEDIUMINT、BIGINT

配送システムで考えるテーブル設計と正規化の流れ

概要

テーブルを正規化して設計していく流れについて、配送システムを例にして記載していきたいと思います。
なお、必ずこのように設計すべき、という主張ではなく、自分はこう考えてやっている、という趣旨の記事になります。
あくまで流れを書いており、created, modifiedのカラムを省略したり、ログテーブルについては言及していません。

要件

では配送システムのテーブルについて考えます。
配送には配送方法とサイズが複数あって、組み合わせによって料金が異なる、という要件があるとします。

1テーブルで設計する場合の設計と問題点

実際の作業では最初からテーブルを正規化して分けますが、いったん一つのテーブルに全て詰め込む形で出してみます。

設計例

荷物ID 荷物名 サイズ名 サイズ範囲 配送方法 料金(円)
1 商品A 60サイズ 60cm以下 通常配送 500
2 商品B 80サイズ 80cm以下 速達 1000
3 商品C 100サイズ 100cm以下 通常配送 900
4 商品D 60サイズ 60cm以下 速達 800
5 商品E 120サイズ 120cm以下 通常配送 1100

問題となりうること

このテーブルで開発して運用することも可能ではあると思いますが、正規化していないのでいくつか問題が発生し得ます。
まず貨物の配送が一つ発生するごとに、サイズ名やサイズ範囲、配送方法と料金の情報がInsertされて、テーブルのデータ量が増えていきます。
それぞれマスターテーブルを持てば、関連するマスターのIDだけ保持すれば良くなります。

また毎回配送方法、サイズ、料金の情報を全てInsertすると、Insert時に配送方法、サイズ、料金に矛盾がないように厳密に管理する必要があります。
配送方法とサイズの組み合わせでどの料金になるか、料金マスターテーブルで管理すれば矛盾が発生しづらくなります。

また、サイズに対して重量という情報を新たに管理する必要が出たとします。
この時、テーブルが一つだと重量カラムを足して、既存のレコード全てにnull(ないしは適切な値)を入れるような処理になると思います。
これはサイズの情報を増やしたいだけなのに、荷物名、配送方法、料金も同じテーブルで管理しているため、全ての情報に影響が発生してしまっています。 サイズの情報を管理するマスターテーブルが存在していれば、基本はそのテーブルを更新して、必要ならほかのテーブルも更新するような流れにできます。

正規化して設計する

次に正規化する例を考えてみます。

荷物テーブル

荷物に紐づけられたサイズと配送方法を持っています。
料金に関しては、サイズと配送方法が決まれば取得できるので、後述する料金マスターテーブルからサイズIDと配送方法IDでJoinして取得する想定です。

ここではサイズIDと配送方法IDを持っていますが、サイズIDと配送方法IDの代わりに後述する料金テーブルのIDを持っても良いと思います(料金が決まればサイズIDと配送方法IDが特定できる構成のため)。

荷物ID 荷物名 サイズID 配送方法ID
1 商品A 1 1
2 商品B 2 2
3 商品C 3 1
4 商品D 1 2
5 商品E 4 1

外部キー制約
必ずマスターに存在するサイズと配送方法を入れることで、不正な値が入らないようにします。

    FOREIGN KEY (サイズID) REFERENCES サイズ(サイズID),
    FOREIGN KEY (配送方法ID) REFERENCES 配送方法(配送方法ID)

インデックスは主キーと外部キーに加えて 、サイズIDと配送方法IDの複合インデックスを貼る想定です。

サイズマスターテーブル

サイズに関する情報を管理します。
サイズ名やサイズ範囲が変更されたときや、新たなサイズに関する情報を管理するときは、基本的にこのテーブルを変更すれば対応できるようにします。

サイズID サイズ名 サイズ範囲
1 60サイズ 60cm以下
2 80サイズ 80cm以下
3 100サイズ 100cm以下
4 120サイズ 120cm以下
5 140サイズ 140cm以下

配送方法マスターテーブル

配送方法に関する情報を管理します。 こちらのサイズ同様に配送方法に関する情報の追加、更新、削除はこのテーブルで対応できるようにします。
例えば配送方法名が速達から速配に変わった場合、正規化していない場合は全レコードを更新する必要がありますが、このテーブルがあればID2の配送方法名を変えるだけで対応できます。

配送方法ID 配送方法名
1 通常配送
2 速達

料金マスターテーブル

サイズと配送方法を組み合わせた料金を管理します。

料金ID サイズID 配送方法ID 料金(円)
1 1 1 500
2 1 2 800
3 2 1 700
4 2 2 1000
5 3 1 900
6 3 2 1200
7 4 1 1100
8 4 2 1400
9 5 1 1300
10 5 2 1600

外部キー制約
必ずマスターに存在するサイズと配送方法を入れることで、不正な値が入らないようにします。

    FOREIGN KEY (サイズID) REFERENCES サイズ(サイズID),
    FOREIGN KEY (配送方法ID) REFERENCES 配送方法(配送方法ID)

インデックスは主キーと外部キーに加えて 、サイズIDと配送方法IDの複合インデックスを貼る想定です。

メリットとデメリット

メリットは正規化したことで1テーブルで記載した問題となりうることは起きないようになっていると思います。
またあとからテーブルを見た時に、マスターテーブルを見ればどのような配送方法、サイズ、料金があるのか把握しやすくなっています。

デメリットとしてはJoinが増えることですが、マスターテーブルはレコード数は少ないため、そこまで速度が遅くなるわけではないと思います。

フロントエンドをユニットテストでリファクタリングする例

概要

自分が業務でよく使う、テストのない.tsxファイルなどを処理を関数で切り出し、jestでリファクタリングする手順をメモします。

リファクタ前のコード

以下は日付のフォームとバリデーション処理です。
処理はtsxに書かれていてテストはありません。

日付バリデーションの条件は当日からプラス1日〜3日です。

本記事のコードはChatGPTに書いてもらいました

import React, { useState } from 'react';

const DateForm: React.FC = () => {
  const [selectedDate, setSelectedDate] = useState('');
  const [error, setError] = useState('');

  const validateDate = (date: string): boolean => {
    const inputDate = new Date(date);
    const today = new Date();
    const tomorrow = new Date(today.getTime() + 86400000); // 1日後
    const threeDaysLater = new Date(today.getTime() + 3 * 86400000); // 3日後

    if (inputDate >= tomorrow && inputDate <= threeDaysLater) {
      setError('');
      return true;
    } else {
      setError('Please select a date that is 1 to 3 days from today.');
      return false;
    }
  };

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const newDate = event.target.value;
    if (validateDate(newDate)) {
      setSelectedDate(newDate);
    }
  };

  return (
    <div>
      <label htmlFor="date">Select a date: </label>
      <input
        type="date"
        id="date"
        value={selectedDate}
        onChange={handleChange}
      />
      {error && <div style={{ color: 'red' }}>{error}</div>}
    </div>
  );
};

export default DateForm;

リファクタリング

バリデーション処理を切り出す

まずはテストしやすいようにバリデーションの処理を関数に切り出します。
tsxもバリデーション処理を切り出したことで見やすくなりました。

// validation.ts
export const validateDate = (date: string): boolean => {
    const inputDate = new Date(date);
    const today = new Date();
    const tomorrow = new Date(today.getTime() + 86400000); // 1日後
    const threeDaysLater = new Date(today.getTime() + 3 * 86400000); // 3日後

    return inputDate >= tomorrow && inputDate <= threeDaysLater;
};

tsxファイル

import React, { useState } from 'react';
import { validateDate } from './validation'; // バリデーション関数をインポート

const DateForm: React.FC = () => {
  const [selectedDate, setSelectedDate] = useState('');
  const [error, setError] = useState('');

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const newDate = event.target.value;
    if (validateDate(newDate)) {
      setSelectedDate(newDate);
      setError('');
    } else {
      setError('Please select a date that is 1 to 3 days from today.');
    }
  };

  return (
    <div>
      <label htmlFor="date">Select a date: </label>
      <input
        type="date"
        id="date"
        value={selectedDate}
        onChange={handleChange}
      />
      {error && <div style={{ color: 'red' }}>{error}</div>}
    </div>
  );
};

export default DateForm;

jestを書く

日付の計算で、過去の日付などの異常値テストや、境界値の正常テストを追加しています。
これで処理の切り出しとユニットテストの導入まで行えました。

条件判定系は複雑になりがちなので、ユニットテストで担保しておくと安心ですね。

import { validateDate } from './validation';

describe('Date Validation', () => {
  const formatDate = (date: Date) => date.toISOString().split('T')[0];

  it('should accept a date exactly 1 day from today', () => {
    const tomorrow = new Date();
    tomorrow.setDate(tomorrow.getDate() + 1);
    expect(validateDate(formatDate(tomorrow))).toBe(true);
  });

  it('should accept a date exactly 3 days from today', () => {
    const threeDaysLater = new Date();
    threeDaysLater.setDate(threeDaysLater.getDate() + 3);
    expect(validateDate(formatDate(threeDaysLater))).toBe(true);
  });

  it('should reject a date today (boundary)', () => {
    const today = new Date();
    expect(validateDate(formatDate(today))).toBe(false);
  });

  it('should reject a date exactly 4 days from today (boundary)', () => {
    const fourDaysLater = new Date();
    fourDaysLater.setDate(fourDaysLater.getDate() + 4);
    expect(validateDate(formatDate(fourDaysLater))).toBe(false);
  });

  it('should reject a date in the past', () => {
    const yesterday = new Date();
    yesterday.setDate(yesterday.getDate() - 1);
    expect(validateDate(formatDate(yesterday))).toBe(false);
  });

  it('should reject a date far in the future', () => {
    const farFuture = new Date();
    farFuture.setDate(farFuture.getDate() + 100);
    expect(validateDate(formatDate(farFuture))).toBe(false);
  });

  it('should reject an invalid date format', () => {
    expect(validateDate('invalid-date')).toBe(false);
  });
});

array_count_valuesで配列内に要素がいくつあるか確認する

概要

PHPの配列で、配列の値と出現回数をSQLのGroup Byのように集計してカウントしたい時、array_count_values が便利です。

PHP: array_count_values - Manual

出力

[2,2,1,1,1,2,2]の配列の場合、2は4回出現して1は3回出現しています。 これをarray_count_valuesに渡すとkey: count対象の値, value: count数の配列を返してくれます。

$arr = [2,2,1,1,1,2,2];
$arr = array_count_values($arr);
var_dump($arr);
// array(2) {
//   [2]=>
//   int(4)
//   [1]=>
//   int(3)
// }