2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TDDをYouTubeで学んで、実際にTypeScriptで試してみた

2
Posted at

はじめに

TDD Boot Camp 2020 Online #1 基調講演/ライブコーディング を見て、「これは実際に手を動かさないと身につかない」と感じ、TypeScript + Jest で3つのお題に挑戦してみました。

TypeScriptは普段から使っており、Jestも経験あり。TDD自体は今回が初挑戦です。

使ったツール・環境

  • TypeScript
  • Jest
  • お題はClaude(AI)に作ってもらいました

TDDの基本的な流れ

  1. Claudeにお題を作ってもらう:次の目標を考える
  2. その目標を示すテストを書く
  3. そのテストを実行して失敗させる(Red)
  4. 目的のコードを書く
  5. 2で書いたテストを成功させる(Green)
  6. テストが通るままでリファクタリングを行う(Refactor)
  7. 1~6を繰り返す

Sprint 1: カテゴリのラベル変換

お題

レシピには "japanese" / "italian" / "chinese" の3つのカテゴリがある。
getCategoryLabel(category: string): string を実装せよ。

  • "japanese" → "和食"
  • "italian" → "イタリアン"
  • "chinese" → "中華"
  • それ以外 → "その他"

TODO整理

最初はざっくりこう書きました。

- "japanese"の時は"和食"に変換する
- "italian"の時は"イタリアン"に変換する
- "chinese"の時は"中華"に変換する
- それ以外は"その他"に変換する

「それ以外」が曖昧すぎると気づき、具体的な値に直しました。

- カテゴリに存在しない値の時は"その他"に変換する
  - "ラーメン"の時は"その他"に変換する
  - "カレー"の時は"その他"に変換する
- "japanese"の時は"和食"に変換する
- "italian"の時は"イタリアン"に変換する
- "chinese"の時は"中華"に変換する

TODOを具体的な値で書くことで、そのままテストケースとして使えるようになりました。

テストコード

features\tdd__test__\sprint1.test.ts
import { getCategoryLabel } from "../util";

describe("getCategoryLabelメソッドはカテゴリをラベル名に変換する", () => {
  describe('カテゴリに存在しない値の時は"その他"に変換する', () => {
    test('"ラーメン"の時は"その他"に変換する', () => {
      const sut = getCategoryLabel("ラーメン");
      expect(sut).toBe("その他");
    });
  });

  test('"japanese"の時は"和食"に変換する', () => {
    const sut = getCategoryLabel("japanese");
    expect(sut).toBe("和食");
  });

  test('"italian"の時は"イタリアン"に変換する', () => {
    const sut = getCategoryLabel("italian");
    expect(sut).toBe("イタリアン");
  });

  test('"chinese"の時は"中華"に変換する', () => {
    const sut = getCategoryLabel("chinese");
    expect(sut).toBe("中華");
  });
});

実装

features\tdd\util.ts
const categoryLabelMap: Map<string, string> = new Map([
  ["japanese", "和食"],
  ["italian", "イタリアン"],
  ["chinese", "中華"],
]);
export function getCategoryLabel(category: string): string {
  return categoryLabelMap.get(category) ?? "その他";
}

やってしまった失敗・気づき

  • ソースコードを先に書いてしまった。TDDはテストファーストが原則なので、実装したい気持ちをぐっと抑える必要がありました
  • TODOの「それ以外」を最初に具体化できていなかったため、テストを書く段階で詰まりました。TODOは具体的な値まで落とし込むのが大事

Sprint 2: 調理時間によるソート

お題

レシピオブジェクトの配列を受け取り、調理時間(cookingTimeMinutes)が短い順に並び替えた新しい配列を返す sortRecipesByCookingTime(recipes: Recipe[]): Recipe[] を実装せよ。
調理時間が同じ場合は元の順序を維持すること。

TODO整理

- 調理時間が短い順に並び替えたレシピオブジェクトの配列を返す
  - 10分(ID:1), 3分(ID:2), 7分(ID:3) → IDが[2, 3, 1]の順で返る
  - 調理時間が同じ場合は元の順序を維持する
    - 10分(ID:1), 3分(ID:2), 10分(ID:3) → IDが[2, 1, 3]の順で返る
- 返却値は新しい配列である
  - 元の配列とは別の配列が返る
  - 元の配列の順序が変わらない
- 境界値
  - 空配列を渡すと空配列が返る

テストコード

features\tdd__test__\sprint2.test.ts
import { Recipe } from "../Recipe";
import { sortRecipesByCookingTime } from "../util";

describe("sortRecipesByCookingTimeメソッド", () => {
  describe("調理時間が短い順に並び替えたレシピオブジェクトの配列を返す", () => {
    test("調理時間が10分(ID:1),3分(ID:2),7分(ID:3)のレシピオブジェクトを渡すとidが[2,3,1]の並びの配列が返却される", () => {
      const recipes = [
        new Recipe(1, 10, "カレー", []),
        new Recipe(2, 3, "カレー", []),
        new Recipe(3, 7, "カレー", []),
      ];
      const sut = sortRecipesByCookingTime(recipes);
      expect(sut.map((x) => x.id)).toEqual([2, 3, 1]);
    });
  });

  describe("調理時間が同じ場合は元の順序を維持する", () => {
    test("調理時間が10分(ID:1),3分(ID:2),10分(ID:3)のレシピオブジェクトを渡すとidが[2,1,3]の並びの配列が返却される", () => {
      const recipes = [
        new Recipe(1, 10, "カレー", []),
        new Recipe(2, 3, "カレー", []),
        new Recipe(3, 10, "カレー", []),
      ];
      const sut = sortRecipesByCookingTime(recipes);
      expect(sut.map((x) => x.id)).toEqual([2, 1, 3]);
    });

    test("調理時間が10分(ID:1),10分(ID:2),10分(ID:3)のレシピオブジェクトを渡すとidが[1,2,3]の並びの配列が返却される", () => {
      const recipes = [
        new Recipe(1, 10, "カレー", []),
        new Recipe(2, 10, "カレー", []),
        new Recipe(3, 10, "カレー", []),
      ];
      const sut = sortRecipesByCookingTime(recipes);
      expect(sut.map((x) => x.id)).toEqual([1, 2, 3]);
    });
  });

  describe("返却値は新しい配列である", () => {
    test("元の配列とは別の配列が返却される", () => {
      const recipes = [new Recipe(1, 10, "カレー", [])];
      const sut = sortRecipesByCookingTime(recipes);
      expect(sut).not.toBe(recipes);
    });

    test("元の配列の順序が変更されない", () => {
      const recipes = [
        new Recipe(1, 10, "カレー", []),
        new Recipe(2, 3, "カレー", []),
      ];
      sortRecipesByCookingTime(recipes);
      expect(recipes.map((x) => x.id)).toEqual([1, 2]);
    });
  });

  describe("境界値", () => {
    test("空配列を渡すと空配列が返却される", () => {
      const sut = sortRecipesByCookingTime([]);
      expect(sut).toEqual([]);
    });
  });
});

features\tdd\util.ts
import { Recipe } from "./Recipe";


export function sortRecipesByCookingTime(recipes: Recipe[]): Recipe[] {
  return recipes.toSorted(
    (a, b) => a.cookingTimeMinutes - b.cookingTimeMinutes,
  );
}

実装

features\tdd\Recipe.ts
export class Recipe {
  readonly cookingTimeMinutes: number;
  readonly id: number;
  readonly name: string;
  ingredients: string[];

  constructor(
    id: number,
    cookingTimeMinutes: number,
    name: string,
    ingredients: string[],
  ) {
    this.id = id;
    this.cookingTimeMinutes = cookingTimeMinutes;
    this.name = name;
    this.ingredients = ingredients;
  }
}

やってしまった失敗・気づき

  • 「調理時間プロパティが存在すること」をテストに含めてしまいました。ただこれは エンティティ(Recipe クラス)側のテストで検証すべき内容で、ソート関数のテストに含める必要はなかったです
  • 「新しい配列を返す」という仕様はテストに盛り込みました。振る舞いの検証としてはやや細かいですが、お題に明記されていたので実装しました

Sprint 3: 材料の絞り込み

お題

レシピの配列と手持ちの材料リスト(string[])を受け取り、必要な材料(ingredients)がすべて手持ちに含まれているレシピだけを返す filterRecipesByIngredients(recipes: Recipe[], available: string[]): Recipe[] を実装せよ。

TODO整理

「材料」というキーワードが2種類登場するため、言葉を分けて整理しました。

【前提】
- Recipe[] と 手持ちの材料リスト(availableIngredients: string[])を受け取る

【TODO】
- レシピの材料がすべて手持ちの材料に含まれるレシピだけを返す
  - "カレー"のレシピとその材料をすべて含む手持ちリストを渡す → カレーを返す
  - "カレー"のレシピとその材料を一部だけ含む手持ちリストを渡す → 空配列を返す
  - "カレー"と"ラーメン"のレシピとカレーの材料だけを含む手持ちリストを渡す → カレーを返す
  - "カレー"と"ラーメン"のレシピとその材料をすべて含む手持ちリストを渡す → カレーとラーメンを返す
- 境界値
  - 空配列のレシピを渡す → 空配列を返す
  - 空配列の手持ちリストを渡す → 空配列を返す

テストコード

features\tdd__test__\sprint3.test.ts
import { Recipe } from "../Recipe";
import { filterRecipesByIngredients } from "../util";

describe("filterRecipesByIngredientsメソッド", () => {
  describe("必要なレシピの材料リスト(ingredients)がすべて手持ちの材料リストに含まれるレシピだけを配列にして返す", () => {
    describe("レシピ1件", () => {
      let recipes: Recipe[];
      beforeEach(() => {
        recipes = [
          new Recipe(1, 10, "カレー", [
            "にんじん",
            "たまねぎ",
            "じゃがいも",
            "豚肉",
            "カレールー",
          ]),
        ];
      });

      test('"カレー"のレシピとそのレシピの材料をすべて含む手持ちの材料リストを受け取る→カレーのレシピを返す', () => {
        const availableIngredients = [
          "にんじん",
          "たまねぎ",
          "じゃがいも",
          "豚肉",
          "ピーマン",
          "キャベツ",
          "カレールー",
        ];

        const sut = filterRecipesByIngredients(recipes, availableIngredients);
        expect(sut.map((x) => x.name)).toEqual(["カレー"]);
      });

      test('"カレー"のレシピとそのレシピの材料を一部含む手持ちの材料リストを受け取る→空配列を返す', () => {
        const availableIngredients = ["にんじん"];
        const sut = filterRecipesByIngredients(recipes, availableIngredients);
        expect(sut.length).toBe(0);
      });
    });

    describe("レシピ複数", () => {
      let recipes: Recipe[];
      beforeEach(() => {
        recipes = [
          new Recipe(1, 10, "カレー", [
            "にんじん",
            "たまねぎ",
            "じゃがいも",
            "豚肉",
            "カレールー",
          ]),
          new Recipe(1, 10, "ラーメン", ["麺", "スープ"]),
        ];
      });

      test('"カレー"と"ラーメン"のレシピとカレーのレシピの材料だけを含む手持ちの材料リストを受け取る→カレーのレシピを返す', () => {
        const availableIngredients = [
          "にんじん",
          "たまねぎ",
          "じゃがいも",
          "豚肉",
          "ピーマン",
          "キャベツ",
          "カレールー",
        ];

        const sut = filterRecipesByIngredients(recipes, availableIngredients);
        expect(sut.map((x) => x.name)).toEqual(["カレー"]);
      });

      test('"カレー"と"ラーメン"のレシピとそのレシピの材料をすべて含む手持ちの材料リストを受け取る→カレーとラーメンのレシピを返す', () => {
        const availableIngredients = [
          "にんじん",
          "たまねぎ",
          "じゃがいも",
          "豚肉",
          "ピーマン",
          "キャベツ",
          "カレールー",
          "麺",
          "スープ",
        ];
        const sut = filterRecipesByIngredients(recipes, availableIngredients);
        expect(sut.map((x) => x.name)).toEqual(["カレー", "ラーメン"]);
      });
    });

    describe("境界値", () => {
      test("空配列のレシピを受け取る→空配列を返す", () => {
        const recipes: Recipe[] = [];
        const availableIngredients = [
          "にんじん",
          "たまねぎ",
          "じゃがいも",
          "豚肉",
          "ピーマン",
          "キャベツ",
          "カレールー",
        ];
        const sut = filterRecipesByIngredients(recipes, availableIngredients);
        expect(sut.length).toBe(0);
      });

      test("空配列の材料リストを受け取る→空配列を返す", () => {
        const recipes: Recipe[] = [
          new Recipe(1, 10, "カレー", [
            "にんじん",
            "たまねぎ",
            "じゃがいも",
            "豚肉",
            "カレールー",
          ]),
        ];
        const availableIngredients: string[] = [];
        const sut = filterRecipesByIngredients(recipes, availableIngredients);
        expect(sut.length).toBe(0);
      });
    });
  });
});

実装

features\tdd\util.ts
import { Recipe } from "./Recipe";

export function filterRecipesByIngredients(
  recipes: Recipe[],
  availableIngredients: string[],
) {
  return recipes.filter(({ ingredients }) =>
    ingredients.every((x) => availableIngredients.includes(x)),
  );
}

やってしまった失敗・気づき

  • Recipe エンティティに name と ingredients プロパティを追加したことで、既存のテストにも影響が出ました。しかし Jestは型エラーを検出しないため、気づくのが遅れました。npx tsc を実行する習慣をつけることで対処しました

全体を通しての振り返り

よかったこと

「テストが保証してくれるから、実装を修正するのが怖くない」 という感覚を初めて体験できました。リファクタリング時に「壊してないか不安」がなくなるのは、想像以上に快適でした。

また、TODOを先に書くことで実装の迷いが激減しました。何を作ればいいかが明確な状態でコードを書けるので、集中できます。

難しかったこと

  • ソースを先に書きたくなる衝動との戦い。TDDに慣れるまでは意識的に抑える必要があります
  • エンティティ・値オブジェクト・ユーティリティ関数のどれとして切り出すか迷う場面がありました。TDDの文脈では「今求められていること以外は実装しない」が指針になりました

おわりに

動画を見ただけでは「なんとなく理解した気になる」だけで止まりがちなTDD。実際に手を動かすことで、テストの書き方・TODO整理の重要性・サイクルを回す感覚、どれも腑に落ちました。
TDDに興味はあるけど踏み出せていない方は、ぜひ小さいお題から試してみてください。

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?