新卒研修で初めてPHP+Laravelのコードを書いて以来、ずっとLaravel(稀にPython)で仕事をしてきた。しかし風の噂によると、社内でもTypeScriptを使ったプロジェクトが増えてきていて需要があるのはJavaやTypeScriptらしい。
せっかくClaudeでコードを書ける環境にいるので、勉強しながら記事を書くことにした。 AIにコードを書かせる過程で浮かんだ疑問も載せたので、似た境遇にある人は読んでみてほしい。
本記事を書く前に、Claudeを使ってゲーム(TypeScript+Phaser)をTDDで書いてみたが、動くものができたものの、ただ書かせているだけで学びが薄かった。また、Phaserという未知のフレームワークのモックを書かれてもテストコードを書く習慣が今までなかったためにモックが正しいのか判断できないという問題点があった。
そのタイミングで、コーディングエージェントにテストコードを書かせるワークショップが社内で開催された。
記事を書くにはワークショップで出されたお題よりもシンプルなお題のほうが分かりやすいと考えたので、自分の反省とワークショップの内容をAIに渡してカリキュラムをAIに考えさせた結果、UIなしのビジネスロジックだけに絞って以下の流れで書くことにした。
カリキュラム
- Session 1: 送料計算(同値クラス・境界値の体感)
- Session 2: ポイント計算(会員ランク × 誕生日月)
- Session 3: AI比較実験(丸投げ指示 vs 同値クラス表を渡した指示)
- Session 4: 残業代ドメインをAIで学ぶ
- Session 5: 残業代をTDDで実装
ワークショップでは、AIがコードを書く前に、同値クラスと境界値の分析をさせて、それを人間が仕様書や公式ドキュメント(政府が出している公式情報など)と照らし合わせてチェックし、その結果をもとにAIにコードを書かせた。
同値クラスと境界値を意識してテストを設計するのは、バグが起きやすい箇所を網羅するための基本的な考え方である。本記事でもこの進め方を踏襲する。
同値クラスとは、テスト対象の入力値を「同じ処理結果になるグループ」に分けたもの。グループ内はどの値でも同じ動作をするという前提のもと、グループごとに代表値を1つ選んでテストする。
境界値とは、クラスの境目にある値のこと。0〜2,999円は送料500円、3,000〜9,999円は送料300円という仕様なら、2,999円と3,000円が境界値にあたる。バグは境界付近で最も起きやすいため、境界値はテストに必ず含める。
ワークショップでは高額医療費制度における費用計算という仕様の理解が難しいドメインだったので、今回はもっと簡単な送料計算でテストコードを書かせることにした。
料金体系はAIが考え出した以下の内容とする。
| 購入金額 | 送料 |
|---|---|
| 0〜2,999円 | 500円 |
| 3,000〜9,999円 | 300円 |
| 10,000円以上 | 0円(送料無料) |
AIには同値クラスについての説明は済ませており、問題が単純なので境界値を漏らさず代表値として挙げている。
テストを3グループに分けました。これが同値クラス分割です。
クラス内の値はどれも同じ結果になるため、代表値1つでも本来は足りますが、境界値(2,999/3,000/9,999/10,000)を意識的にテストに含めています。境界値はバグが最も起きやすい場所です。
| クラス | 代表値 | 期待する送料 |
|---|---|---|
| 0〜2,999円 | 0, 1,500, 2,999 | 500円 |
| 3,000〜9,999円 | 3,000, 5,000, 9,999 | 300円 |
| 10,000円以上 | 10,000, 20,000 | 0円 |
そこからAIが作成したコードが次の内容である。
AIには同値クラスと境界値を考慮してテストコードを書くよう指示しているので、各同値クラスの代表値と、境界値を使ってテストコードが書かれている。
export function calcShipping(price: number): number {
if (price >= 10000) { return 0; }
if (price >= 3000) { return 300; }
return 500;
}
import { describe, it, expect } from "vitest";
import { calcShipping } from "./shipping";
describe("calcShipping: 送料計算", () => {
// 同値クラス1: 0〜2,999円 → 500円
it("購入金額が0円のとき、送料は500円", () => {
expect(calcShipping(0)).toBe(500);
});
it("購入金額が1,500円のとき、送料は500円", () => {
expect(calcShipping(1500)).toBe(500);
});
it("購入金額が2,999円のとき、送料は500円", () => {
expect(calcShipping(2999)).toBe(500);
});
// 同値クラス2: 3,000〜9,999円 → 300円
it("購入金額が3,000円のとき、送料は300円", () => {
expect(calcShipping(3000)).toBe(300);
});
it("購入金額が5,000円のとき、送料は300円", () => {
expect(calcShipping(5000)).toBe(300);
});
it("購入金額が9,999円のとき、送料は300円", () => {
expect(calcShipping(9999)).toBe(300);
});
// 同値クラス3: 10,000円以上 → 0円
it("購入金額が10,000円のとき、送料は0円", () => {
expect(calcShipping(10000)).toBe(0);
});
it("購入金額が20,000円のとき、送料は0円", () => {
expect(calcShipping(20000)).toBe(0);
});
});
export function calcShipping(price: number): number {
export — 他のファイルからこの関数を使えるようにする。PHPのアクセス修飾子 public に相当する。何も宣言しない場合はprivateと同じく、外からは呼び出せない関数になる。price: number — 引数の型を明示。PHPの引き数の型宣言と順序が逆で違和感があったが、よく考えたらTypeScriptのほうが引数も返り値も型が後ろに書かれていて統一性がある。PHPUnitの慣習(tests/ ディレクトリ)に見慣れていると、テストファイルとソースファイルが同じディレクトリにあることに違和感を覚える。調べてみると、Vitest / Jestなどモダンなテストフレームワークでは同居が主流らしい。ファイルを移動するときにテストも一緒に移動できること、「このファイルにテストがあるか」が一目でわかることが主な理由で、Vite / Next.jsなどのプロジェクトテンプレートも同居を採用している。
分離が向いているのはE2EテストやAPIテストなどユニットテスト以外のテスト、または大規模プロジェクトで本番コードとテストを完全に別ビルドしたい場合とされている。
import文はPHPの use クラス名; とほぼ同じ概念で、このファイルで外部のクラス・関数を使えるようにするための宣言である。
// PHP
use PHPUnit\Framework\TestCase;
// TypeScript
import { describe, it, expect } from "vitest";
{} で囲むのは、vitestライブラリの中から特定のものだけ取り出すためで、PHPの use が1クラスずつ書くのに対し、TypeScriptは1行でまとめて取り出せる。
| 名前 | 役割 | PHPUnit での対応 |
|---|---|---|
describe |
テストのグループ化。関連するテストをまとめる | TestCaseクラスに相当 |
it |
1つのテストケース。「〜のとき〜になる」を書く | public function test〜() メソッドに相当 |
expect |
期待値を検証する | $this->assertEquals() に相当 |
具体的に対応させると次のようになる。
// PHP + PHPUnit
class ShippingTest extends TestCase
{
public function test購入金額が0円のとき送料は500円(): void
{
$this->assertEquals(500, calcShipping(0));
}
}
// TypeScript + Vitest
describe("calcShipping: 送料計算", () => {
it("購入金額が0円のとき、送料は500円", () => {
expect(calcShipping(0)).toBe(500);
});
});
次に、ポイント計算のテストコードを書かせる。
Session 1は「1つの条件(金額)」だったが、今回は2つの条件の組み合わせを考える。仕様としては、会員ランク × 誕生日月でポイント倍率が変わる。全組み合わせは 3ランク × 2(通常月/誕生日月)= 6パターン。
これを整理する手法をデシジョンテーブルという。
| 会員ランク | 通常月 | 誕生日月 |
|---|---|---|
| 通常会員 | 1倍 | 2倍 |
| シルバー | 2倍 | 4倍 |
| ゴールド | 3倍 | 6倍 |
ポイント計算式: 購入金額 × 倍率 × 1%
(例:10,000円 × ゴールド × 誕生日月 = 10,000 × 6 × 0.01 = 600pt)
type Rank = "normal" | "silver" | "gold";
const MULTIPLIER: Record<Rank, { normal: number; birth: number }> = {
normal: { normal: 1, birth: 2 },
silver: { normal: 2, birth: 4 },
gold: { normal: 3, birth: 6 },
};
export function calcPoint(price: number, rank: Rank, isBirthMonth: boolean): number {
const multiplier = isBirthMonth ? MULTIPLIER[rank].birth : MULTIPLIER[rank].normal;
return Math.floor(price * multiplier * 0.01);
}
テストコード
import { describe, it, expect } from "vitest";
import { calcPoint } from "./point";
describe("calcPoint: ポイント計算", () => {
// 通常会員
describe("通常会員", () => {
it("通常月: 1,000円 → 10pt(1倍)", () => {
expect(calcPoint(1000, "normal", false)).toBe(10);
});
it("誕生日月: 1,000円 → 20pt(2倍)", () => {
expect(calcPoint(1000, "normal", true)).toBe(20);
});
});
// シルバー会員
describe("シルバー会員", () => {
it("通常月: 1,000円 → 20pt(2倍)", () => {
expect(calcPoint(1000, "silver", false)).toBe(20);
});
it("誕生日月: 1,000円 → 40pt(4倍)", () => {
expect(calcPoint(1000, "silver", true)).toBe(40);
});
});
// ゴールド会員
describe("ゴールド会員", () => {
it("通常月: 1,000円 → 30pt(3倍)", () => {
expect(calcPoint(1000, "gold", false)).toBe(30);
});
it("誕生日月: 1,000円 → 60pt(6倍)", () => {
expect(calcPoint(1000, "gold", true)).toBe(60);
});
});
// 金額の境界値
it("購入金額0円のとき、ポイントは0pt", () => {
expect(calcPoint(0, "gold", true)).toBe(0);
});
});
type Rank = "normal" | "silver" | "gold";
PHPでいうEnumに近い。rank引数にこの3つ以外の文字列を渡すとコンパイルエラーになる。
TypeScriptには、よく使う型のパターンをあらかじめ用意したユーティリティ型が標準で組み込まれている。Record<K, V> はその一つで、「キーが K、値が V のオブジェクト型」を表す。他にも Pick・Partial・Readonly など実務でよく登場する。PHPには対応する概念がないが、連想配列に型をつけたものに近い。
const MULTIPLIER: Record<Rank, { normal: number; birth: number }> = { ... };
Record<Rank, ...> とすることで、MULTIPLIER のキーが必ず "normal" | "silver" | "gold" の3つでなければならないことが型で保証される。ここでは、倍率テーブルがそのままコードになっている。
デシジョンテーブルとの対応
仕様表がそのままMULTIPLIERオブジェクトになっている。
| ランク | 通常月 | 誕生日月 |
|---|---|---|
| normal | 1 | 2 |
| silver | 2 | 4 |
| gold | 3 | 6 |
Record<K, V> の <K, V> の部分をジェネリクスという。型を引数のように受け取れる仕組みで、K や V は型の仮引数にあたる。
ジェネリクスがなければ、同じ処理を型ごとに別の関数として書くか、型チェックを諦めて any を使うしかない。
// 型ごとに関数を作ると量が増えすぎる
function identityString(value: string): string { return value; }
function identityNumber(value: number): number { return value; }
// anyは型チェックを放棄するため安全でない
function identity(value: any): any { return value; }
const result = identity("hello");
result.toFixed(2); // 文字列なのに数値メソッド → 実行時エラーになるがコンパイル時に気づけない
ジェネリクスを使うと、型を引数として渡すことで any なしに1つの関数で複数の型に対応できる。
// ジェネリクスを使った例
function identity<T>(value: T): T { return value; }
const str = identity("hello"); // strはstring型として推論される
const num = identity(42); // numはnumber型として推論される
str.toFixed(2); // コンパイルエラー → 実行前に気づける
Record<K, V> もその一例で、K に Rank、V に { normal: number; birth: number } を渡すことで具体的な型になる。
Record<K, V> を調べていると、ドキュメントによって T と書かれていたり Type と書かれている点が気になった。調べると、これらはすべて型の仮引数で、PHPでいう関数の引数名と同じように名前は自由につけられる。T はTypeの略で最もよく使われる慣習で、TypeScript公式のソースコードが途中から T → Type に書き換えたため混在している。U はアルファベット順でTの次というだけで、2つ目の型引数に使う慣習らしい。
| 名前 | よく使われる意味 |
|---|---|
T |
最初の型(Type) |
U |
2番目の型 |
K |
キー(Key)— Record<K, V> など |
V |
値(Value) |
続いて、コードは書かずに、AIに渡すプロンプトの違いによって結果がどう変わるか実験を行う。
ここでは、プロンプトをChatGPTに渡し、その結果を分析する。
お題は「割引計算」を使う。 関数シグネチャは両方に渡して、プロンプトの差分は「同値クラス表があるかどうか」だけにする。
| 条件 | 割引率 |
|---|---|
| 購入金額が5,000円未満 | 割引なし(0%) |
| 購入金額が5,000円以上 | 5% |
| 会員かつ5,000円以上 | 10% |
実験1: AIに仕様と要望を丸投げする。
ECサイトの割引計算関数 calcDiscount のテストを Vitest で書いてください。
関数シグネチャ:
calcDiscount(price: number, isMember: boolean): number
戻り値: 割引後の金額仕様:
- 購入金額が5,000円未満は割引なし
- 購入金額が5,000円以上は5%割引
- 会員かつ5,000円以上は10%割引
実験2: 同値クラス表とともに仕様と要望を指示する。
実験1の結果:ECサイトの割引計算関数 calcDiscount のテストを Vitest で書いてください。
関数シグネチャ:
calcDiscount(price: number, isMember: boolean): number
戻り値: 割引後の金額以下の同値クラス表をもとに、境界値を含む網羅的なテストを書いてください。
【同値クラス表】
クラス 購入金額 会員 割引率 代表値 A 0〜4,999円 どちらでも 0% 3,000円 B 5,000円以上 非会員 5% 8,000円 C 5,000円以上 会員 10% 8,000円 【境界値】
- 4,999円(割引なし)
- 5,000円(割引開始)
import { describe, it, expect } from "vitest";
import { calcDiscount } from "./calcDiscount";
describe("calcDiscount", () => {
it("5,000円未満は割引なし(非会員)", () => {
expect(calcDiscount(4999, false)).toBe(4999);
});
it("5,000円未満は割引なし(会員)", () => {
expect(calcDiscount(4999, true)).toBe(4999);
});
it("5,000円ちょうどは5%割引(非会員)", () => {
expect(calcDiscount(5000, false)).toBe(5000 * 0.95);
});
it("5,000円以上は5%割引(非会員)", () => {
expect(calcDiscount(10000, false)).toBe(10000 * 0.95);
});
it("5,000円ちょうどは10%割引(会員)", () => {
expect(calcDiscount(5000, true)).toBe(5000 * 0.9);
});
it("5,000円以上は10%割引(会員)", () => {
expect(calcDiscount(10000, true)).toBe(10000 * 0.9);
});
});
実験2の結果:
import { describe, it, expect } from 'vitest'
import { calcDiscount } from './calcDiscount'
describe('calcDiscount', () => {
// --- 同値クラス A: 0〜4,999円(割引なし) ---
describe('class A: price < 5000 (no discount)', () => {
it('代表値: 3,000円・非会員 → 割引なし', () => {
expect(calcDiscount(3000, false)).toBe(3000)
})
it('代表値: 3,000円・会員 → 割引なし', () => {
expect(calcDiscount(3000, true)).toBe(3000)
})
it('境界値: 4,999円・非会員 → 割引なし', () => {
expect(calcDiscount(4999, false)).toBe(4999)
})
it('境界値: 4,999円・会員 → 割引なし', () => {
expect(calcDiscount(4999, true)).toBe(4999)
})
it('下限: 0円 → 割引なし', () => {
expect(calcDiscount(0, false)).toBe(0)
})
})
// --- 同値クラス B: 5,000円以上 & 非会員(5%) ---
describe('class B: price >= 5000 & non-member (5% discount)', () => {
it('代表値: 8,000円・非会員 → 5%割引', () => {
expect(calcDiscount(8000, false)).toBe(7600)
})
it('境界値: 5,000円・非会員 → 5%割引', () => {
expect(calcDiscount(5000, false)).toBe(4750)
})
})
// --- 同値クラス C: 5,000円以上 & 会員(10%) ---
describe('class C: price >= 5000 & member (10% discount)', () => {
it('代表値: 8,000円・会員 → 10%割引', () => {
expect(calcDiscount(8000, true)).toBe(7200)
})
it('境界値: 5,000円・会員 → 10%割引', () => {
expect(calcDiscount(5000, true)).toBe(4500)
})
})
})
出力されたテスト数は実験1が6件、実験2が9件だった。
差がわかりやすいのはクラスAの扱いで、実験1は 4999 の境界値しかテストしていない。
実験2は 3000(代表値)と 4999(境界値)の両方を押さえていた。境界値だけでは「そのクラス全体の動作を確認した」とは言えないので、代表値も必要である。
テストの構造にも違いが出た。実験1はケースがフラットに並んでいるだけだが、実験2は describe でクラスごとにグループ化されていて、どのテストがどの仕様に対応しているかが分かりやすい。
// 実験2: どの仕様に対するテストか一目でわかる
describe('Class A: price < 5000 (no discount)')
describe('Class B: price >= 5000 & non-member (5% discount)')
describe('Class C: price >= 5000 & member (10% discount)')
仕様が単純だったので実験1でも境界値に気づいているが、Session 2のランク×誕生日月のように条件の組み合わせが増えると、表なしでは代表値の抜けや構造のなさが問題になっていくだろう。また、仕様が変わったときに、どのテストを直せばいいか追いにくくなる。
AIには先に同値クラスと境界値の分析をさせてからコードを書かせると、テストの網羅性と読みやすさが変わるということがわかった。
ここでは、身近だが詳しくは知らない労働基準法の残業代ルールをAIと一緒に理解しながら、同値クラス表を作る。
AIに労働基準法の残業代ルールについて尋ねると、次のような計算方法だと回答された。 給与明細に25%という表記を見たことがあるひとも多いだろう。
残業代の割増賃金率(労働基準法):
| 種別 | 条件 | 割増率 |
|---|---|---|
| 時間外労働 | 法定労働時間(8h)超 | +25% |
| 深夜労働 | 22時〜翌5時 | +25% |
| 休日労働 | 法定休日(週1日) | +35% |
| 時間外+深夜 | 8h超かつ22時〜5時 | +50%(25+25) |
| 休日+深夜 | 法定休日かつ22時〜5時 | +60%(35+25) |
なんとなく理解したところで、Claude(Web版)に「残業代の同値クラス表を作って」と指示して詳細に調べてみる。 Session3の学びを活かして、AIが出力した表の質を人間がレビューする。
日本の労働基準法に基づく残業代計算の関数をTDDで実装するために、同値クラス表を作ってください。
考慮すべき条件:
- 労働時間(法定労働時間8時間を超えるか)
- 時間帯(深夜22時〜翌5時か)
- 休日(法定休日か)
表には以下を含めてください:
- 同値クラスの名前
- 各条件の組み合わせ
- 割増率
- 代表的なテストケース例
以下が、日本の労働基準法に基づく残業代計算の同値クラス表です。3つの条件(時間外・深夜・休日)の全8組み合わせに加え、TDDで重要な境界値・異常系もまとめています。
ポイント: 法定休日労働は全時間が35%割増のため、8時間超でも追加の時間外割増(25%)は発生しません。ただし深夜割増(25%)は休日割増と重複適用されます。
※割増率の計算ルール: 時間外 +25%、深夜 +25%、休日 +35%。休日の時間外割増は重複しない(休日35%が適用)。深夜割増は休日・時間外いずれとも重複適用される。
クラス5と6、クラス7と8はそれぞれ同一割増率だが、入力条件が異なるため別クラスとしてテストすべき。
TDDで実装する際の進め方としては、まずクラス1(通常勤務)の最もシンプルなケースでグリーンにし、次にクラス2→3→4と条件を1つずつ追加していくのがおすすめです。クラス5〜8の休日系は「休日労働では時間外割増が重複しない」というルールが肝なので、クラス5と6が同じ135%になることを明示的にテストすると安心です。
境界値(B1〜B5)は各正常系クラスのテストが通った後に追加すると、エッジケースでの不具合を効率的に潰せます。
公式情報と照らし合わせてAIが言っていることが正しいか確認する。
計算式が労基か厚生労働省のホームページかどこか1か所に記載されているものと思っていたが、なぜか都道府県ごとに資料があって、小難しい文章で書かれていたりする。・・・正直、読みたくない。
厚生労働省 兵庫労働局 賃金・割増賃金
厚生労働省 兵庫労働局 小企業の事業主の皆さまへ
厚生労働省 東京労働局 しっかりマスター 割増賃金編
読み比べると、AIの表には月60時間を超える時間外労働の割増率が抜けていることがわかった。AIはルールを整理するのは得意だが、自分の出力に何が欠けているかは自分では気づかない。
表のそれ以外の部分はよくできていた。3条件の8組み合わせが網羅されていて、8h・8h1分・21:59→22:00などの境界値も具体的に挙げていたし、「休日労働に時間外割増は重複しない」という重要なルールも正しく説明していた。異常系(0h、負の値)も考慮できている。
月60時間超の観点が抜けていた分、同値クラスがさらに増えることになるので、作り直す必要がある。Session 3でAI比較実験は済んでいるので、Claude Codeに作り直させて一緒に確認してから実装に入る。
| # | クラス名 | 時間外(>8h) | 月60h超 | 深夜(22-5時) | 法定休日 | 割増率 |
|---|---|---|---|---|---|---|
| 1 | 通常勤務 | — | — | — | — | 100% |
| 2 | 時間外のみ(60h以内) | ✓ | — | — | — | 125% |
| 3 | 時間外のみ(60h超) | ✓ | ✓ | — | — | 150% |
| 4 | 深夜のみ | — | — | ✓ | — | 125% |
| 5 | 時間外+深夜(60h以内) | ✓ | — | ✓ | — | 150% |
| 6 | 時間外+深夜(60h超) | ✓ | ✓ | ✓ | — | 175% |
| 7 | 休日のみ(昼間) | — | — | — | ✓ | 135% |
| 8 | 休日+深夜 | — | — | ✓ | ✓ | 160% |
注: 休日労働に時間外割増は重複しない(休日35%が優先)。休日労働は月60時間超のカウント対象外。
| # | 境界 | テストケース |
|---|---|---|
| B1 | 8hちょうど | 割増なし(100%) |
| B2 | 8h+1分 | 時間外割増(125%)開始 |
| B3 | 月60hちょうど | 125%のまま |
| B4 | 月60h+1分 | 150%に切り替わる |
| B5 | 21:59→22:00 | 深夜割増(+25%)開始 |
| B6 | 4:59→5:00 | 深夜割増終了 |
| # | ケース | 期待 |
|---|---|---|
| E1 | 労働時間0h | 残業代 0円 |
| E2 | 負の労働時間 | エラー |
| E3 | 基本時給0以下 | エラー |
Session 4で作成した表をもとに、テストコードを書かせてみる。
AIがまず最初に出力したのが、働いた時間が1時間固定で給与を計算するテストコード。
これだと「何時から何時まで働いたのか」をもとにして計算ができない。
type OvertimeParams = {
hours: number;
hourlyWage: number;
isOvertime: boolean; // 法定労働時間(8h)超か
isNight: boolean; // 深夜(22〜5時)か
isHoliday: boolean; // 法定休日か
isOver60h: boolean; // 月60時間超か
};
export function calcOvertimePay(params: OvertimeParams): number {
const { hours, hourlyWage, isOvertime, isNight, isHoliday, isOver60h } = params;
if (hours < 0) throw new Error("労働時間は0以上である必要があります");
if (hourlyWage <= 0) throw new Error("基本時給は1以上である必要があります");
const multiplier = getMultiplier({ isOvertime, isNight, isHoliday, isOver60h });
return hours * hourlyWage * multiplier;
}
type MultiplierParams = Pick<OvertimeParams, "isOvertime" | "isNight" | "isHoliday" | "isOver60h">;
function getMultiplier({ isOvertime, isNight, isHoliday, isOver60h }: MultiplierParams): number {
// 法定休日: 時間外割増は重複しない(35%固定)、深夜は重複適用
if (isHoliday) { return isNight ? 1.60 : 1.35; }
// 深夜割増(+25%)は時間外割増と重複適用
const nightBonus = isNight ? 0.25 : 0;
// 月60h超は時間外割増が+50%、以内は+25%
if (isOvertime) {
const overtimeBonus = isOver60h ? 0.50 : 0.25;
return 1 + overtimeBonus + nightBonus;
}
// 時間外なし(深夜のみ、または通常勤務)
return 1 + nightBonus;
}
働いた時間が1時間固定で給与を計算するテストコード
import { describe, it, expect } from "vitest";
import { calcOvertimePay } from "./overtime";
// 基本時給1,000円・1時間で計算(倍率の確認がしやすい)
const BASE_WAGE = 1000;
describe("calcOvertimePay: 残業代計算", () => {
// クラス1: 通常勤務 → 100%
describe("クラス1: 通常勤務(割増なし)", () => {
it("時間外・深夜・休日すべてなし → 1,000円/h", () => {
expect(
calcOvertimePay({
hours: 1,
hourlyWage: BASE_WAGE,
isOvertime: false,
isNight: false,
isHoliday: false,
isOver60h: false,
})
).toBe(1000);
});
});
// クラス2: 時間外のみ(月60h以内)→ 125%
describe("クラス2: 時間外のみ(月60h以内)", () => {
it("時間外あり・月60h以内 → 1,250円/h", () => {
expect(
calcOvertimePay({
hours: 1,
hourlyWage: BASE_WAGE,
isOvertime: true,
isNight: false,
isHoliday: false,
isOver60h: false,
})
).toBe(1250);
});
});
// クラス3: 時間外のみ(月60h超)→ 150%
describe("クラス3: 時間外のみ(月60h超)", () => {
it("時間外あり・月60h超 → 1,500円/h", () => {
expect(
calcOvertimePay({
hours: 1,
hourlyWage: BASE_WAGE,
isOvertime: true,
isNight: false,
isHoliday: false,
isOver60h: true,
})
).toBe(1500);
});
});
// クラス4: 深夜のみ(8h以内)→ 125%
describe("クラス4: 深夜のみ(8h以内)", () => {
it("深夜・時間外なし → 1,250円/h", () => {
expect(
calcOvertimePay({
hours: 1,
hourlyWage: BASE_WAGE,
isOvertime: false,
isNight: true,
isHoliday: false,
isOver60h: false,
})
).toBe(1250);
});
});
// クラス5: 時間外+深夜(月60h以内)→ 150%
describe("クラス5: 時間外+深夜(月60h以内)", () => {
it("時間外あり・深夜・月60h以内 → 1,500円/h", () => {
expect(
calcOvertimePay({
hours: 1,
hourlyWage: BASE_WAGE,
isOvertime: true,
isNight: true,
isHoliday: false,
isOver60h: false,
})
).toBe(1500);
});
});
// クラス6: 時間外+深夜(月60h超)→ 175%
describe("クラス6: 時間外+深夜(月60h超)", () => {
it("時間外あり・深夜・月60h超 → 1,750円/h", () => {
expect(
calcOvertimePay({
hours: 1,
hourlyWage: BASE_WAGE,
isOvertime: true,
isNight: true,
isHoliday: false,
isOver60h: true,
})
).toBe(1750);
});
});
// クラス7: 休日のみ(昼間)→ 135%
describe("クラス7: 休日のみ(昼間)", () => {
it("法定休日・深夜なし → 1,350円/h", () => {
expect(
calcOvertimePay({
hours: 1,
hourlyWage: BASE_WAGE,
isOvertime: false,
isNight: false,
isHoliday: true,
isOver60h: false,
})
).toBe(1350);
});
it("法定休日・8h超でも割増率は同じ135%(時間外割増は重複しない)", () => {
expect(
calcOvertimePay({
hours: 1,
hourlyWage: BASE_WAGE,
isOvertime: true,
isNight: false,
isHoliday: true,
isOver60h: false,
})
).toBe(1350);
});
});
// クラス8: 休日+深夜 → 160%
describe("クラス8: 休日+深夜", () => {
it("法定休日・深夜 → 1,600円/h", () => {
expect(
calcOvertimePay({
hours: 1,
hourlyWage: BASE_WAGE,
isOvertime: false,
isNight: true,
isHoliday: true,
isOver60h: false,
})
).toBe(1600);
});
});
// 境界値
describe("境界値", () => {
it("B3: 月60hちょうどは125%(超えていない)", () => {
expect(
calcOvertimePay({
hours: 1,
hourlyWage: BASE_WAGE,
isOvertime: true,
isNight: false,
isHoliday: false,
isOver60h: false,
})
).toBe(1250);
});
it("B4: 月60h超は150%", () => {
expect(
calcOvertimePay({
hours: 1,
hourlyWage: BASE_WAGE,
isOvertime: true,
isNight: false,
isHoliday: false,
isOver60h: true,
})
).toBe(1500);
});
});
// 異常系
describe("異常系", () => {
it("E1: 労働時間0hは0円", () => {
expect(
calcOvertimePay({
hours: 0,
hourlyWage: BASE_WAGE,
isOvertime: false,
isNight: false,
isHoliday: false,
isOver60h: false,
})
).toBe(0);
});
it("E2: 負の労働時間はエラー", () => {
expect(() =>
calcOvertimePay({
hours: -1,
hourlyWage: BASE_WAGE,
isOvertime: false,
isNight: false,
isHoliday: false,
isOver60h: false,
})
).toThrow();
});
it("E3: 基本時給0以下はエラー", () => {
expect(() =>
calcOvertimePay({
hours: 1,
hourlyWage: 0,
isOvertime: false,
isNight: false,
isHoliday: false,
isOver60h: false,
})
).toThrow();
});
});
});
そこで、引数を「開始・終了時刻(分)」にして、内部で8h超・深夜帯を自動判定する設計に変更し、最初に作成した境界値を利用するように指示した。
次に出力された内容では同値クラスごとのテストと境界値のテストを別々に書いていたり、そのせいで結果が同じになるテストが重複していたりしていたので追加で修正させた。
さらに、何時から何時までという表記を眺めていると、22時や5時をまたぐシフト(例:20:00〜23:00)のテストが抜けていることにたまたま気づいて追加した。送料や金額と違い、時間は「またぐ」ことができる。「この観点でテストが不足していますか?」と聞けばAIも気づける話ではあるが、AIは渡した表に従ってコードを書いたら終わりで、自分からは疑問を持って立ち止まらない。
最終的なコードは次のような内容になった。
type WorkSession = {
startMinutes: number; // 開始時刻(分)例: 9:00 → 540
endMinutes: number; // 終了時刻(分)例: 翌2:00 → 1560
hourlyWage: number; // 基本時給(円)
isHoliday: boolean; // 法定休日か
monthlyOvertimeMinutes: number; // 月の累計残業時間(分)
};
const LEGAL_DAILY_MINUTES = 480; // 法定労働時間: 8h
const MONTHLY_OT_THRESHOLD = 60 * 60; // 月60h = 3600分
const NIGHT_START = 22 * 60; // 深夜開始: 22:00 = 1320分
const NIGHT_END = 5 * 60; // 深夜終了: 5:00 = 300分
export function calcOvertimePay(session: WorkSession): number {
const { startMinutes, endMinutes, hourlyWage, isHoliday, monthlyOvertimeMinutes } = session;
if (endMinutes <= startMinutes) throw new Error("終了時刻は開始時刻より後にしてください");
if (hourlyWage <= 0) throw new Error("基本時給は1以上にしてください");
if (monthlyOvertimeMinutes < 0) throw new Error("月累計残業時間は0以上にしてください");
const totalMinutes = endMinutes - startMinutes;
// 法定内(最初の8h)と残業(8h超)に分ける
const regularEnd = startMinutes + Math.min(totalMinutes, LEGAL_DAILY_MINUTES);
const regularNight = calcNightMinutes(startMinutes, regularEnd);
const regularDay = (regularEnd - startMinutes) - regularNight;
let overtimeDay = 0;
let overtimeNight = 0;
if (totalMinutes > LEGAL_DAILY_MINUTES) {
const overtimeStart = startMinutes + LEGAL_DAILY_MINUTES;
overtimeNight = calcNightMinutes(overtimeStart, endMinutes);
overtimeDay = (endMinutes - overtimeStart) - overtimeNight;
}
// 月60hを「超えた」場合に150%(3600分ちょうどはまだ超えていない)
const isOver60h = monthlyOvertimeMinutes > MONTHLY_OT_THRESHOLD;
const ratePerMin = hourlyWage / 60;
let total = 0;
if (isHoliday) {
// 休日: 時間外割増は重複しない(35%固定)、深夜のみ+25%追加
total += regularDay * ratePerMin * 1.35;
total += regularNight * ratePerMin * 1.60;
total += overtimeDay * ratePerMin * 1.35;
total += overtimeNight * ratePerMin * 1.60;
} else {
const otRate = isOver60h ? 1.50 : 1.25;
const otNightRate = isOver60h ? 1.75 : 1.50;
total += regularDay * ratePerMin * 1.00;
total += regularNight * ratePerMin * 1.25;
total += overtimeDay * ratePerMin * otRate;
total += overtimeNight * ratePerMin * otNightRate;
}
return Math.floor(total);
}
// 指定範囲(分)のうち深夜帯(22:00-5:00)に含まれる分数を返す
function calcNightMinutes(start: number, end: number): number {
let nightMins = 0;
let pos = start;
while (pos < end) {
const dayBase = Math.floor(pos / 1440) * 1440;
const dayOffset = pos - dayBase;
const chunkEnd = Math.min(end, dayBase + 1440);
const chunkEndOffset = chunkEnd - dayBase;
// 深夜帯: 0:00-5:00 (0-300) と 22:00-24:00 (1320-1440)
nightMins += overlap(dayOffset, chunkEndOffset, 0, NIGHT_END);
nightMins += overlap(dayOffset, chunkEndOffset, NIGHT_START, 1440);
pos = chunkEnd;
}
return nightMins;
}
function overlap(a: number, b: number, c: number, d: number): number {
return Math.max(0, Math.min(b, d) - Math.max(a, c));
}
最終的なテストコード
import { describe, it, expect } from "vitest";
import { calcOvertimePay } from "./overtime";
// 時刻は分で表す。例: 9:00 → H(9)=540、翌2:00 → NEXT(2)=1560
const H = (h: number, m = 0) => h * 60 + m;
const NEXT = (h: number, m = 0) => (24 + h) * 60 + m;
const WAGE = 1000;
describe("calcOvertimePay: 残業代計算", () => {
// --- 正常系 ---
describe("クラス1: 通常勤務(割増なし)", () => {
it("平日9:00-15:00(6h, 代表値)→ 6,000円", () => {
expect(
calcOvertimePay({ startMinutes: H(9), endMinutes: H(15), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toBe(6000);
});
it("平日9:00-17:00(8hちょうど, 上限境界)→ 8,000円", () => {
expect(
calcOvertimePay({ startMinutes: H(9), endMinutes: H(17), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toBe(8000);
});
it("21:00-22:00(深夜開始の直前まで)→ 割増なし 1,000円", () => {
expect(
calcOvertimePay({ startMinutes: H(21), endMinutes: H(22), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toBe(1000);
});
it("5:00-6:00(深夜終了の直後から)→ 割増なし 1,000円", () => {
expect(
calcOvertimePay({ startMinutes: H(5), endMinutes: H(6), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toBe(1000);
});
});
describe("クラス2: 時間外のみ(月60h以内)→ 125%", () => {
it("平日9:00-17:01(8h+1分, 下限境界)→ 8,020円", () => {
expect(
calcOvertimePay({ startMinutes: H(9), endMinutes: H(17, 1), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toBe(8020);
});
it("平日9:00-20:00(11h, 代表値)→ 11,750円", () => {
expect(
calcOvertimePay({ startMinutes: H(9), endMinutes: H(20), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toBe(11750);
});
it("平日9:00-20:00(月累計60hちょうど, 上限境界)→ 125%のまま 11,750円", () => {
expect(
calcOvertimePay({ startMinutes: H(9), endMinutes: H(20), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 3600 })
).toBe(11750);
});
});
describe("クラス3: 時間外のみ(月60h超)→ 150%", () => {
it("平日9:00-20:00(月累計60h+1分, 下限境界)→ 150%に切り替わる 12,500円", () => {
expect(
calcOvertimePay({ startMinutes: H(9), endMinutes: H(20), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 3601 })
).toBe(12500);
});
it("平日9:00-20:00(月累計100h, 代表値)→ 12,500円", () => {
expect(
calcOvertimePay({ startMinutes: H(9), endMinutes: H(20), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 6000 })
).toBe(12500);
});
});
describe("クラス4: 深夜のみ(8h以内)→ 125%", () => {
it("22:00-23:00(深夜開始直後)→ 深夜割増 1,250円", () => {
expect(
calcOvertimePay({ startMinutes: H(22), endMinutes: H(23), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toBe(1250);
});
it("22:00-翌1:00(3h, 代表値)→ 3,750円", () => {
expect(
calcOvertimePay({ startMinutes: H(22), endMinutes: NEXT(1), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toBe(3750);
});
it("4:00-5:00(深夜終了の直前まで)→ 深夜割増 1,250円", () => {
expect(
calcOvertimePay({ startMinutes: H(4), endMinutes: H(5), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toBe(1250);
});
});
describe("クラス5: 時間外+深夜(月60h以内)→ 昼間125% / 深夜150%", () => {
it("平日9:00-翌0:00(15h, 代表値)→ 17,250円", () => {
expect(
calcOvertimePay({ startMinutes: H(9), endMinutes: NEXT(0), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toBe(17250);
});
});
describe("クラス6: 時間外+深夜(月60h超)→ 昼間150% / 深夜175%", () => {
it("平日9:00-翌0:00(月累計3601分, 代表値)→ 19,000円", () => {
expect(
calcOvertimePay({ startMinutes: H(9), endMinutes: NEXT(0), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 3601 })
).toBe(19000);
});
});
describe("クラス7: 休日のみ → 135%(時間外割増は重複しない)", () => {
it("法定休日9:00-17:00(8h, 代表値)→ 10,800円", () => {
expect(
calcOvertimePay({ startMinutes: H(9), endMinutes: H(17), hourlyWage: WAGE, isHoliday: true, monthlyOvertimeMinutes: 0 })
).toBe(10800);
});
it("法定休日9:00-20:00(11h, 8h超でも135%のまま)→ 14,850円", () => {
expect(
calcOvertimePay({ startMinutes: H(9), endMinutes: H(20), hourlyWage: WAGE, isHoliday: true, monthlyOvertimeMinutes: 0 })
).toBe(14850);
});
});
describe("クラス8: 休日+深夜 → 160%", () => {
it("法定休日22:00-翌2:00(4h, 代表値)→ 6,400円", () => {
expect(
calcOvertimePay({ startMinutes: H(22), endMinutes: NEXT(2), hourlyWage: WAGE, isHoliday: true, monthlyOvertimeMinutes: 0 })
).toBe(6400);
});
});
// --- 時間帯をまたぐケース ---
describe("時間帯をまたぐケース", () => {
it("20:00-23:00(22時をまたぐ, 昼2h+深夜1h)→ 3,250円", () => {
// 昼間2h: 2,000円 + 深夜1h: 1,250円
expect(
calcOvertimePay({ startMinutes: H(20), endMinutes: H(23), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toBe(3250);
});
it("4:00-6:00(5時をまたぐ, 深夜1h+昼1h)→ 2,250円", () => {
// 深夜1h: 1,250円 + 昼間1h: 1,000円
expect(
calcOvertimePay({ startMinutes: H(4), endMinutes: H(6), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toBe(2250);
});
});
// --- 異常系 ---
describe("異常系", () => {
it("E2: 終了時刻 <= 開始時刻 → エラー", () => {
expect(() =>
calcOvertimePay({ startMinutes: H(17), endMinutes: H(9), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toThrow();
});
it("E3: 基本時給0円 → エラー", () => {
expect(() =>
calcOvertimePay({ startMinutes: H(9), endMinutes: H(17), hourlyWage: 0, isHoliday: false, monthlyOvertimeMinutes: 0 })
).toThrow();
});
it("E4: 月累計残業時間が負 → エラー", () => {
expect(() =>
calcOvertimePay({ startMinutes: H(9), endMinutes: H(17), hourlyWage: WAGE, isHoliday: false, monthlyOvertimeMinutes: -1 })
).toThrow();
});
});
});
このプロジェクトを通じて分かったことを整理する。
AIが自動ではしてくれないこと:
まず同値クラスと境界値の分析をAIにさせて、その結果を人間が仕様書と照らし合わせてチェックし、修正した表をもとにAIにコードを書かせる。この方針で進めると、AIの出力品質が上がり、人間のレビューも効きやすくなる。
このプロジェクトで身についたのはTypeScriptの文法より、知らないドメインをAIと一緒に整理して、同値クラスで設計してテストを書く進め方だと思う。知らない言語でもAIに聞きながらならだいたい前に進めるが、何を聞くか・AIの出力のどこを疑うかは自分で判断しないといけない。その判断軸が少し身についた。
同値クラスと境界値を軸にテストを設計するアプローチはTypeScriptに限らず使えるし、AIに先に分析させて人間がレビューする流れは、チームで新しいドメインや技術に取り組むときにも利用できる。実際にやってみてわかったことを、チームに広げていきたい。