テストコードってどうやって書いたらいいんだろう?

はじめに

はじめまして、ダニーです。
今回は業務で議題にあがった「テスト」に関してのブログになります。

経緯

皆さんテストコードってどう書いてどう状態を保ってますか?
今回なぜこの題材にしたかというと、業務で「テスト」の話題が上がったからです。
どんな話題かというと、

  • テスト書かれてるけどどういうテストなのかわからない
  • スキップされてるけどこれはどういう理由があってスキップされてるのかわからない
  • テストの粒度が統一されてない
  • テスト書かれてたり書かれてなかったりしてる
  • etc …

と、こんな感じで話題としては良くない方の話題が上がりました。

現状のテストコード達はどういう状況なのか?

※ここからはサンプルコードをベースに話を勧めます。(実際の業務コードはちょっとまずいので)

テスト書かれてるけどどういうテストなのかわからない

まず最初の話題はこちら。
これはこのテストコードがなんのテストをしたいのかテスト名を見て判断がつかないというものでした。
サンプルコード的にはこんな感じのコードが散らばっています。

describe("決済テスト", () => {
  it("カード決済テスト", () => {
    //  カード決済ロジックについてのテストコード
  });

    it("与信テスト", () => {
    //  与信ロジックについてのテストコード
  });

  it("NP決済テスト", () => {
    // ... NP決済ロジックについてのテストコード
  });

    // ...
});

一見「別に問題ないのでは?」と思うかもしれないですが、小さなテストだと問題ないですが、入り組んだロジックを含めたテストだと一つのテストが肥大化する傾向があり、そうなると中身のテストコードを全部読まないと結局「このテストは何してるの?」ってなってしまうという話です。

じゃーどうしたら良さそうか?🤔

まずはこのテスト何してるのか?って言うのをテスト名でしっかり書いてあげるだけでも十分効果ありだと思いました。

describe("決済テスト", () => {
  it("yyyy/mm/dd にカード決済を行い◯◯プランに契約が成功すること", () => {
    //  カード決済ロジックについてのテストコード
  });

  it("yyyy/mm/dd に契約終了予定のプランの◯日前に与信処理が成功すること", () => {
    //  与信ロジックについてのテストコード
  });

  it("yyyy/mm/dd に◯◯プランにNP決済で契約するための審査提出が成功すること", () => {
    // ... NP決済ロジックについてのテストコード
  });

  // ...
});

と、このように各テストが「どんなテストなのか?」をテスト名にしっかり記載することでテスト名見れば大体わかって、詳細を確認したいテストだけ調べることで確認作業の疲弊が削減できそうじゃないですか?
他にもテストの粒度を小さくしたりとかも有効ではあると思いますが、ビジネスロジックが絡むとどうしても小ささを保つのが難しいと感じているので、その辺いい考え方はないかと悩んでおります。(いい方法あれば知りたい)

スキップされてるけどこれはどういう理由があってスキップされてるのかわからない

テストは書かれてるんだけど、よく見るとスキップされてるものがあり、なんでスキップされてるのかが見ても分からず、結局一度実行して理由や原因を特定しないといけないってことがあります。
これは不要なら削除、もしくはテストが動くように修正するなど「最新の状態を保つ」を意識的に継続していくしかないですよね。(自分もできてないので自戒も込めて)
ど〜〜してもスキップした状態でテストを保持しておきたいなど理由があるならわかるようにコメント入れとくとかは必須かなと。(そもそもの前提がおかしい気がするので例外的な扱いでいいと思っている)

テストの粒度が統一されてない

フロントエンド側のテストで見受けられるのが、UI・Component・hooks などで粒度がバラけてしまっているテストが存在する・・・らしい。(フロントエンドエンジニアのご意見)
その辺の知見が乏しいので詳細は省きます。(説明ができない)

テスト書かれてたり書かれてなかったりしてる

実装とテストがリンクしてない状態ですね。

必ずしも一致するってわけでもないので、リンクしてない=悪 ではないと前もって言っておきますが、書かれてるのか書かれてないのかが把握できるいい方法がないかなとは思っている。(難しそうではある)

と、まぁ〜そういう感じでテスト周りに課題があるなぁ・・・と🤔

とはいえ、私もテスト設計やテスト実装についてなどアプローチに関して自信はなく、現在も現行のテストコードを参考にしてテストコードを書いてるって感じなんでちゃんと書けてるか?と聞かれると、正直疑問に思っている状態です。

これ学ぶチャンスなのでは?🤔

ということで、ここからがブログの本題(長〜いプロローグが終わった)
今回業務でテスト周りについて思うところが出たので、この機会にテストについて知ろうというのが今回となります。

テストについて知ろうというけど、何するの?

今回どうやってテストについて知ろうか調べてて、TDD Boot Camp の過去イベントに t_wadaさん こと 和田卓人さんのライブコーディング を視聴して、テスト設計から書き方までイメージしやすかったので、今回題材にして自分なりにまとめようと思います。

今回参考にさせていただく内容のリンクはこちらです。

TDD Boot Camp HP : http://devtesting.jp/tddbc/
TDD Boot Camp 2020 Online #1 基調講演/ライブコーディング 配信 : https://www.youtube.com/watch?v=Q-FJ3XmFlT8&t=1145s

テスト駆動開発ってなに?

「動作するきれいなコード」。
Ron Jeffries のこの簡潔な言葉が、テスト駆動開発(TDD)のゴールだ。
動作するきれいなコードはあらゆる意味で価値がある。

— Kent Beck(著)・和田卓人(訳) 『テスト駆動開発』まえがきより引用

テスト駆動開発で目標にしてるのは「動作するきれいなコード」をゴールとしてる。
これは Ron Jeffries 氏 の言葉で 和田卓人さんの有名な『テスト駆動開発』の書籍のまえがきにも書かれているとても大切にされてる言葉のようです。(詳しくは、「テスト駆動開発」の書籍読んでみてください)

要約になりますが、テスト駆動開発とは

普通のプログラマーが自信を持って着実に「動作するきれいなコード」に向かって行けるテクニックのことをテスト駆動開発と呼ぶ

と仰っています。
だけどいきなり「動作するきれいなコード」は書けないので、

  • 動作するコード
  • きれいなコード

の2つの要素に分けて片方ずつ進めて行くことで、ゴールとなる「動作するきれいなコード」を目指します。

TDDのサイクル

上記でテスト駆動開発については目指しているもの目標にしてることの概要はわかりました。
次に実際の手法(サイクル)はこのように説明されています。

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

この「Red」→「Green」→「Refactor」のサイクルを回して行くのがTDDのサイクルとなります。

1. 次の目標を考える

まずは、TDDの最初に行うのは 1番の目標(簡単な設計)を考える。
アジャイル開発やTDDでよくある誤解の一つに「設計をしない」ということを仰っています。
正確には「常に設計し続ける」が正しい認識のようです。
ここでの手法として、「まずは今考えていることを何かにダンプする」という方法が挙げられています。
ダンプするのは何でも良さそう。(「紙」「エディタ」なんでもいい)
とにかく、「今考えていることを書き出す」これが重要のようです。
これは、TDDのサイクルを回して行くと、徐々に実装に考えが偏ってしまい視野が狭くなっていく傾向があるため、目的(目標)を見失わないようにするためのものだそうです。

そしてTDDではこの目標を「箇条書き(TODOリストのような形式)」で共有というのが一般的のようです。
これから実現するためには何をしていかければいけないのか?というのをリスト化していく感じで、粒度に関してはひとまずバラバラでもよくて、まずはその考えているものをダンプ(箇条書き)していくのが大事ということです。

ただし、ここでリスト化したものをすべてテスト書いていくのはTDDではなくて、
TDDは「動作するきれいなコード」を実現するために2つの要素「動作するコード」「きれいなコード」を交互繰り返します。
そのため、徹底的に問題を小さく分解して一つ一つ解決していくのがTDDのやり方です。
なのでまずはリスト化した次の行動はリストの中から「最初に解決するものを1つ選ぶ」必要があります。
この選び方として、大きく2つの方法が挙げられています。

  • 1つ目の方法は「一番大事そうなものを一番最初にテストしていく」重要度を重視した方法。
  • もう一つの方法は「一番簡単そうなものからテストしていく」テスト容易性が高いものを重視した方法。

どっちがオススメなのか?という話ですが、最初は後者の「一番簡単そうなものからテストしていく」方法がオススメのようです。
理由としては、TDDのサイクルの最初は0ベースから開始していく都合上準備などやることが多いのでサイクルを回すまでに時間がかかるからです。
「重要なテスト」と「簡単なテスト」というのは

  • 重要なテストほど「テスト容易性が低い」
  • 簡単なテストほど「テストの重要性が低い」

と思われがちだけどTDDに慣れてくると「テスト容易性が高く・重要度も高い」ものと「テスト容易性が低く・重要度も低い」ものに寄せていくことが可能になってくるみたいです。

2. その目標を示すテストを書く

1番でピックアップしたテストに対して「出来上がったらこのように動作してほしい」という感じで利用者の視点からまずテストを書いて行きます。
そのため実装する前にテストを書いていくのでTDDには「Test First」が組み込まれていると言われているようです。

3. そのテストを実行して失敗させる(Red)

2番でテストを書いたら実行させます。
すると実装がまだ無いので、テストが「必ず失敗」します。
これによりTDDの最初の「Red」が達成されます。

4. 目的のコードを書く

3番でテストが失敗することが確認できたら、ここからテストが成功するように実装コードを書いて行きます。
ここでの実装コードは「テストを成功させるための必要最低限の最短時間で実装コードを書いていく」というのを意識して書くのが目標となります。

5. 2で書いたテストを成功させる(Green)

4番でテストを成功させる実装コードを書いたので、テスト実行することで「テストが成功する(Green)」ことをこのフェーズで確認します。
※これが「動作するきれいなコード」を2つに分けた「動作するコード」となると私は解釈してます。

6. テストが通るまでリファクタリングを行う(Refactor)

TDD での Refactor の定義は

成功しているテストが成功しているまま「コードをきれい」にしていくこと

となります。
※これが「動作するきれいなコード」を2つに分けた「きれいなコード」となると私は解釈してます。

ここで気になったのは「コード」は「プロダクトコード」も「テストコード」も両方含まれるというものでした。
つまりテストコードが成功(Green)を維持し続けているということはテストされているプロダクトコード(成果物)も正常動作が保証されているという解釈にでいいのかな?と思いました。(認識間違ってないだろうか?)

とにかくこれで、

  • テストが失敗すれば「Red」に戻り
  • テストを成功(Green)させ
  • リファクタリング(Refactor)をして「動作するきれいなコード」にしていく

というTDDのサイクルが 安全に迅速にかつ細かい単位で何度もリファクタリングをすることが可能になります。

ここでの注意点として、リファクタリングをすると長い時間繰り返してしまう傾向があるので、「辞め時」が必要になってきます。
この辞め時ですが、動画では案1に「時間」で設定する方法が紹介されています。
大体5分〜10分リファクタリングして、次のリストに進むそうです。
なぜかというと、リファクタリングはTDDのサイクルを回していくと何度でもする機会が訪れるため、一度のリファクタリングでは深追いしなくてもまた次でという感じにすることができます。

案2は「数を数える」という方法です。
具体的には「1」という数を目指す方法です。(ここだけ見るとなんのことかさっぱりかもです。)
失敗(Red) → 成功(Green)の過程でコードには「重複」が発生する可能性が高いです。
つまり重複するということは「1」が「2」になるのでこれをリファクタリングで「1」にして次のリストに進むという「重複を除去する」を行って次のTDDのサイクルに進むのが案2の方法です。

7. 1〜6を繰り返す

6つの工程を一つのサイクルとして行うのがTDDの方法ということがわかりました。
一つのサイクルを回すごとにフィードバックを得ることができます、例えば

  • リスト化の内容を細分化する
  • この仕様もっと簡略化してもよさそう
  • このリストまだテストするには早いかも
  • そもそもこのリスト不要(つまりテスト不要)
  • etc …

こんな感じですね。
なので、サイクルを回す毎にリストを改訂しつつ次のリストをテストして Red → Green → Refactor をするサイクルを回して行くことが可能になるということです。

デモ:FizzBuzz 問題 やってみた

ここまでがTDDについての解説となります。
ここからはデモでFizzBuzz 問題を題材にTDDの実践がありましたので、実践も兼ねてJest で書いてみようと思います。(動画では Java でライブコーディングしていました。)
※動画では時間の都合上最後まで実演されてないので、後半は私ならこう書くかな?っていう感じで補完してますのでご了承ください。
※Jestの環境はシンプルに公式の導入を参考にホスト環境を導入しました。

FizzBuzz問題の定義

1から100までの数をプリントするプログラムを書け。
ただし3の倍数のときは数の代わりに「Fizz」と、5の倍数のときは「Buzz」とプリントし、
3と5の倍数の場合には「FizzBuzz」とプリントすること。

ではまずTDDのサイクルの手順通りに目標を考えるために上記の定義を元にリスト化(テスト設計)していきます。
まず初めにリスト化するとこんな感じになります。

テスト容易性:高 重要度:高
- [] 数を文字列に変換する
- [] 3の倍数のときは数の代わりに「Fizz」に変換する
- [] 5の倍数のときは数の代わりに「Buzz」に変換する
- [] 3と5の倍数のときは数の代わりに「FizzBuzz」に変換する

テスト容易性:低 重要度:低
- [] 1からnまで
- [] 1から100まで
- [] プリントする

※リスト化までの経緯は省きます。(詳しくは動画を視聴してもらったほうがわかりやすいと思うので)

最初のテスト項目をピックアップする

ピックアップ方法は上記で説明した通り最初のテストなんで「一番簡単そうなテストから」ということで「数を文字列に変換する」あたりからテスト書いていきます。

と、その前にまずは Jest が正常に動作するかの確認をしよう

動画でもまず最初に JUnit がそもそも正しく動作するかを確認しています。(ここではテスト失敗するメッセージが正しく出力されるかの確認をしてる。)

理由として、そもそも設定や別のエラーが原因で正しく動作していない可能性つまり「テスト開始するためのスタートラインに立てていない可能性がある」のでまずはちゃんと動いているか?という確認を一番最初に行うことで、余計な実装やテストがないクリーンな状態で正常に動けばそれは「スタートラインに立てている状態である」ということが言えます。

逆にこの状態で想定外のエラーが出力されているということは「スタートラインに立てていない」つまり、テストが正常に動作しないので原因として設定がおかしい?もしくはインストールに失敗してる?などに特定できるというメリットがあります。

ということで、動作確認用のコードをざっと用意してみました。

// fizzBuzz.spec.ts
import { describe, expect, it } from "@jest/globals";

describe("FizzBuzzテスト", () => {
  it("必ず失敗するテスト", () => {
    expect(1).toBe(2);
  });

  it("必ず成功するテスト", () => {
    expect(1).toBe(1);
  });
});

一応失敗と成功両方用意してみました。
テスト結果は正常に失敗と成功が帰ってきたのでこの時点で「スタートラインに立てている」という証明が出来たということになりますので、テストコード書いて行きます。

FAIL  src/__test__/fizzBuzz.spec.ts
  FizzBuzzテスト
    ✕ 必ず失敗するテスト (2 ms)
    ✓ 必ず成功するテスト

  ● FizzBuzzテスト › 必ず失敗するテスト

    expect(received).toBe(expected) // Object.is equality

    Expected: 2
    Received: 1

      3 | describe("FizzBuzzテスト", () => {
      4 |   it("必ず失敗するテスト", () => {
    > 5 |     expect(1).toBe(2);
        |               ^
      6 |   });
      7 |
      8 |   it("必ず成功するテスト", () => {

      at Object.<anonymous> (src/__test__/fizzBuzz.spec.ts:5:15)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        0.774 s, estimated 1 s

では、テストを書いて行こう

動画を視聴しながら自分なりに Jest で書いてみた結果最終的にこうなりました。

実装コード

// fizzBuzz.ts
export class FizzBuzz {
  constructor() {}

  public convert(num: number): string {
    if (num % 15 === 0) return "FizzBuzz";
    if (num % 3 === 0) return "Fizz";
    if (num % 5 === 0) return "Buzz";
    return num.toString();
  }
}

テストコード

// fizzBuzz.spec.ts
import { FizzBuzz } from "./../fizzBuzz";
import { afterEach, beforeEach, describe, expect, it } from "@jest/globals";

describe("Fizz Buzz 数列と変換規則を扱う FizzBuzz クラス", () => {
  let fizzbuzz: FizzBuzz | null;

  // 前準備
  beforeEach(() => {
    fizzbuzz = new FizzBuzz();
  });

  // 後片付け
  afterEach(() => {
    fizzbuzz = null;
  });

  describe("convertメソッドは数を文字列に変換する", () => {
    describe("3の倍数のときは数の代わりに「Fizz」に変換する", () => {
      it("3を渡すと文字列Fizzを返す", () => {
        expect(fizzbuzz!.convert(3)).toBe("Fizz");
      });
    });

    describe("5の倍数のときは数の代わりに「Buzz」に変換する", () => {
      it("5を渡すと文字列Buzzを返す", () => {
        expect(fizzbuzz!.convert(5)).toBe("Buzz");
      });
    });

    describe("3と5の倍数の時つまり15の倍数のときは数の代わりに「FizzBuzz」に変換する", () => {
      it("15を渡すと文字列FizzBuzzを返す", () => {
        expect(fizzbuzz!.convert(15)).toBe("FizzBuzz");
      });
    });

    describe("その他の数のときはそのまま文字列に変換する", () => {
      it("1を渡すと文字列1を返す", () => {
        expect(fizzbuzz!.convert(1)).toBe("1");
      });
    });

    describe("1からnまでの値を渡すと正しく動作する", () => {
      it("1から30までの値を渡すと3の倍数は「Fizz」を5の倍数は「Buzz」を15の倍数は「FizzBuzz」をその他の数値はそのままの値を文字列に変換されて出力される", () => {
        const expected = ["1","2","Fizz","4","Buzz","Fizz","7","8","Fizz","Buzz","11","Fizz","13","14","FizzBuzz","16","17","Fizz","19","Buzz","Fizz","22","23","Fizz","Buzz","26","Fizz","28","29","FizzBuzz"];
        const actual: string[] = [...Array(30)].map((val, index) => fizzbuzz!.convert(index + 1));
        expect(actual).toEqual(expected);
      });
    });
  });
});

テスト結果

PASS  src/__test__/fizzBuzz.spec.ts
  Fizz Buzz 数列と変換規則を扱う FizzBuzz クラス
    convertメソッドは数を文字列に変換する
      3の倍数のときは数の代わりに「Fizz」に変換する
        ✓ 3を渡すと文字列Fizzを返す (1 ms)
      5の倍数のときは数の代わりに「Buzz」に変換する
        ✓ 5を渡すと文字列Buzzを返す (1 ms)
      3と5の倍数の時つまり15の倍数のときは数の代わりに「FizzBuzz」に変換する
        ✓ 15を渡すと文字列FizzBuzzを返す
      その他の数のときはそのまま文字列に変換する
        ✓ 1を渡すと文字列1を返す
      1からnまでの値を渡すと正しく動作する
        ✓ 1から30までの値を渡すと3の倍数は「Fizz」を5の倍数は「Buzz」を15の倍数は「FizzBuzz」をその他の数値はそのままの値を文字列に変換されて出力される

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        0.68 s, estimated 1 s

やることリスト(最終改訂版)

テスト容易性:高 重要度:高
- [x] その他の数のときはそのまま文字列に変換する -> TDD 1サイクル目
  - [x] 1を渡すと文字列"1"を返す -> 仮実装
  - [x] 2を渡すと文字列"2"を返す -> 三角測量 -> 冗長なのでテスト削除

- [x] 3の倍数のときは数の代わりに「Fizz」に変換する
  - [x] 3を渡すと文字列"Fizz"を返す -> 仮実装 -> 実装

- [x] 5の倍数のときは数の代わりに「Buzz」に変換する
  - [x] 5を渡すと文字列"Buzz"を返す -> 明白な実装

- [x] 3と5の倍数のときは数の代わりに「FizzBuzz」に変換する
  - [x] 15を渡すと文字列"FizzBuzz"を返す -> 明白な実装 

テスト容易性:低 重要度:低
- [x] 1からnまで
  - [x] 1から30まで -> 100 まで作るの大変だったので30まで期待値を作成にとどめた
- [] プリントする -> このテスト不要

デモをやってみた感想

まとめでTDDのスキルとして必要なのものはこれらと説明がありました。

  • 問題を「小さく分割」する
  • 歩幅を調整する
    • テスト → 仮実装三角測量 → 実装 (最初は慎重に)
    • テスト → 仮実装 → 実装 (慣れてきたら)
    • テスト → 明白な実装 (自信がついたら)
  • テストの構造化とリファクタリング

上記のスキルを元にTDDを進めていき慣れて来たら実装までのステップを省略して自信を持って実装していくことを体験出来ました。

感想としてTDDの進め方自体自分に合っててよかったです。(自分は石橋を叩きながら進めて行く傾向があるので、相性が良かったと思います。)

今回やってみて良かった点として、「テストの構造化とリファクタリング」についてがありました。
これは「テストのメンテナンス」についての項目で、
テストを書いていく過程でスキルにあるその時の不安や自信によって様々な粒度のテストを歩幅を調節しながら書いていくので粒度がバラバラなテストコードになりがちです。
このままだとテストコードの旨味が少なく資産価値としても低いので、テストコードを「仕様のドキュメント」として後世に残す所までが「テストの構造化とリファクタリング」というお話でした。

これはまさに経緯に書いた

  • テスト書かれてるけどどういうテストなのかわからない
  • スキップされてるけどこれはどういう理由があってスキップされてるのかわからない
  • テストの粒度が統一されてない
  • テスト書かれてたり書かれてなかったりしてる
  • etc …

つまり、「テストの構造化とリファクタリング」が出来ておらずテストコードとしての資産価値が低い状態だってことの証明だなと納得出来ました。

さいごに

いかがでしたでしょうか?
正直いい感じに記事としてまとめれてないので、読みにくかったらすいません。
個人的にはテスト駆動開発についての目標や手法に進め方など知るいい機会となりました。
これを業務に組み込んで行くにはまだまだ慣れが必要となってくると思うので、いきなりうまくはいけないですが自分の中で一つの方法として引き出しが増えました。
私と同じくテストについて困ってる人の助力になれば幸いです。
長くなりましたが今回のブログは以上となります。
最後まで見ていただいてありがとうございました。