TDD 入門 ~E2E テストを添えて~

#あらすじ
TDD に入門してみました。
リグレッション対策となる E2E テストも書きました。


#更新履歴
[2021/1/23] 
validateInput メソッドで throw した例外を使っていなかったので、checkFizzBuzz メソッドを try-catch に書き換えました。
[2021/1/24]
TDD の 参考 URL を追加しました。


#インデックス
- [TDD とは](#tdd-とは)
- [TDD やってみる](#tdd-やってみる)
- [Tips](#tips)


#TDD とは
TDD = テスト駆動開発 については、下記の本がお勧めです。

- [テスト駆動開発](https://www.amazon.co.jp/%E3%83%86%E3%82%B9%E3%83%88%E9%A7%86%E5%8B%95%E9%96%8B%E7%99%BA-Kent-Beck/dp/4274217884) Kent Beck (著), 和田 卓人 (翻訳)

以下の動画も非常に参考になりました。

- [50 分でわかるテスト駆動開発](https://channel9.msdn.com/Events/de-code/2017/DO03)

TDD は、

**まず、失敗するテストを書く（レッド）
→ とりあえずテストを通すコードを書く（グリーン） 
→ 意味のあるコードにする（リファクタリング）　
→ 失敗するテストを書く（レッド）
→ ...**

というプロセスを繰り返す開発手法です。

個人的な解釈ですが、TDD のポイントは以下の 2 点です。

1. レッド → グリーン → リファクタリングのプロセスを繰り返すこと
2. テストが将来のメンバーのためのロゼッタストーンになること

1 について、TDD のポイントの 1 つにリファクタリングがプロセスの中に含まれていることがあります。
機能を細分化して小さいステップで開発を行うことで、サイクルの中でリファクタリングを行うことができます。

2 について、テストは**「何をしたいか」**を表すメッセージになります。
テストコードを読むことで、そのコードが何をしたいコードなのか推測できます。
「将来、案件を引き継ぐ仲間のことを思ってテストを書くと良い」と感じました。


#TDD やってみる
Vuetify + TypeScript のプロジェクトの 1 機能を TDD でやってみることで理解を深めたいと思います。
ついでに、 E2E テストも書きます。

###作るもの
FizzBuzz っぽい Web 画面を作ります。

**動作**
①. 数字を入力してボタンを押します。
②. 3 の倍数であれば Fizz を、5 の倍数であれば Buzz を、3 と 5 の倍数であれば FizzBuzz を画面上に出力します。

**完成イメージ**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/557096/5572deb1-7a12-261e-e2d4-df344c97335e.png)

###下準備
#####Jest + Cypress の環境を作成する
以下の記事を参考に、 Vuetify + TypeScript + Jest + Cypress の環境を整えます。

- https://qiita.com/GabD380/items/02d3ac6c31ddd2f086c5
- https://qiita.com/GabD380/items/1a1c3267521fd995f983

###設計する
先ほどのイメージをどうやって実現するか、何となく考えます。

- 画面は Vue で作る
- FizzBuzz の判定を行うクラスを作る
- ボタンを押すと、FizzBuzz を判定するメソッドが呼ばれる

###画面を作る
FizzBuzz クラスをどう作るかを想像しつつ、書きます。

```vue:FizzBuzzComponent.vue
<template>
  <div class="fizzbuzzcomponent">
    <v-card width="400" class="mx-auto mt-5">
      <v-card-text>
        <v-form ref="form">
          <v-text-field
            v-model.number="inputNumber"
            type="number"
            label="Number"
            data-cy="inputNumber"
            :rules="inputRules"
          />
        </v-form>
      </v-card-text>

      <v-card-actions class="pb-5 px-5">
        <v-row>
          <v-col cols="12">
            <v-btn
              block
              depressed
              color="success"
              data-cy="checkButton"
              @click="clickFizzBuzzButton(inputNumber)"
            >
              FizzBuzz チェック
            </v-btn>
          </v-col>
        </v-row>
      </v-card-actions>
    </v-card>
    <h1 v-if="showText.showNumber" data-cy="Number">{{ inputNumber }}</h1>
    <h1 v-if="showText.showFizz" class="Fizz" data-cy="Fizz">Fizz</h1>
    <h1 v-if="showText.showBuzz" class="Buzz" data-cy="Buzz">Buzz</h1>
    <h1 v-if="showText.showFizzBuzz" class="FizzBuzz" data-cy="FizzBuzz">
      FizzBuzz
    </h1>
  </div>
</template>

<script lang="ts">
/*eslint @typescript-eslint/no-explicit-any: "off"*/
import { Component, Emit, Vue } from 'vue-property-decorator';
import { TCFizzBuzz } from '@/class/TCFizzBuzz';

@Component
export default class FizzBuzzComponent extends Vue {
  showText: object = {
    showNumber: false,
    showFizz: false,
    showBuzz: false,
    showFizzBuzz: false,
  };

  name = 'FizzBuzzComponent';
  inputNumber = '';
  inputFizzBuzz: TCFizzBuzz = new TCFizzBuzz(0);

  @Emit('click-fizzbuzz-button')
  clickFizzBuzzButton(n: number): void {
    this.showText = this.inputFizzBuzz.checkFizzBuzz(n);
  }

  private inputRules: any = [
    (value: number) => !!value || '1~100 までの数字を入力してください。',
    (value: number) => value >= 1 || '1 より大きい数字を入力してください。',
    (value: number) => value <= 100 || '100 より小さい数字を入力してください。',
  ];
}
</script>

<style>
.Fizz {
  color: red;
}
.Buzz {
  color: blue;
}
.FizzBuzz {
  color: green;
}
</style>
```


###Unit テストを書く
早速、FizzBuzz の判定を行うクラスを作ります。
（ぶっちゃけ関数だけ実装すれば良いのですが、練習がてらクラスでかっちり作ります）

TDD 実践していきます。
まずは FizzBuzz クラスにして欲しいことを洗い出します。 

- 入力が 3 の倍数の時、Fizz を返す
- 入力が 5 の倍数の時、Buzz を返す
- 入力が 15 の倍数の時、FizzBuzz を返す
- 入力が 3, 5, 15 の倍数ではない時、その数値を返す
- 入力が 1~100 ではない場合、エラーを返す
- 入力が数字ではない場合、エラーを返す

これを元にテスト書いていきます。
例として、「入力が 3 の倍数の時、Fizz を返す」のテストを書きます。

実現したいことをテストに書きましょう（後で見る人がわかりやすいテストにしましょう！）。
「入力が 3 の時、TCFizzBuzz クラスの checkFizzBuzz は showFizz = true（画面上に Fizz を出力する）を返す。」というテストを書きました。

```typescript:TCFizzBuzz.test.ts
import { TCFizzBuzz } from '@/class/TCFizzBuzz';

describe('Check the input.', () => {
  let fb: TCFizzBuzz;
  beforeAll(() => {
    fb = new TCFizzBuzz(0);
  });

  it('When the input is 3, checkFizzBuzz() returns {showNumber: false, showFizz: true, showBuzz: false, showFizzBuzz: false}.', () => {
    expect(fb.checkFizzBuzz(3)).toEqual({
      showNumber: false,
      showFizz: true,
      showBuzz: false,
      showFizzBuzz: false,
    });
  });
});
```

テストを動かします。当然失敗します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/557096/97028fcb-a64b-aa5d-fff3-16b22b1269fe.png)

レッドでは気持ちが落ち着かないので、早くグリーンにしましょう。
テストに合わせて TCFizzBuzz クラスを作ります。

```typescript:TCFizzBuzz.ts
export class TCFizzBuzz {
  private n: number;

  /**
   * コンストラクター
   * @param n 入力された数字
   */
  constructor(n: number) {
    this.n = n;
  }

  /**
   * 入力された数字が 3 または 5 または 15 の倍数か確認する。
   * 3 の倍数の場合 Fizz を、
   * 5 の倍数の場合 Buzz を、
   * 15 の倍数の場合 FizzBuzz を、
   * それ以外の場合、入力された数字を返却する。
   * @param input 入力
   */
  public checkFizzBuzz(input: number): any {

    if (input == 3) {
      console.log('Fizz');
      return {
        showNumber: false,
        showFizz: true,
        showBuzz: false,
        showFizzBuzz: false,
      };
    }
  }
}
```
※ 一部抜粋。

またテストを動かします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/557096/bf7013ed-2022-af05-c524-2c5d0308742c.png)
やった！⛳️になりました。

本当に 3 の倍数の時 Fizz となっているか心配になったので、テストを追加します。

```typescript:TCFizzBuzz.test.ts
import { TCFizzBuzz } from '@/class/TCFizzBuzz';

describe('Check the input.', () => {
  let fb: TCFizzBuzz;
  beforeAll(() => {
    fb = new TCFizzBuzz(0);
  });

  it('When the input is 3, checkFizzBuzz() returns {showNumber: false, showFizz: true, showBuzz: false, showFizzBuzz: false}.', () => {
    expect(fb.checkFizzBuzz(3)).toEqual({
      showNumber: false,
      showFizz: true,
      showBuzz: false,
      showFizzBuzz: false,
    });
  });

  it('When the input is multiples of 3, checkFizzBuzz() returns {showNumber: false, showFizz: true, showBuzz: false, showFizzBuzz: false}.', () => {
    expect(fb.checkFizzBuzz(99)).toEqual({
      showNumber: false,
      showFizz: true,
      showBuzz: false,
      showFizzBuzz: false,
    });
  });
});
```
あれ、失敗してしまいました。
テストをパスするコードに書き直します。

```typescript:TCFizzBuzz.ts
export class TCFizzBuzz {

  /**
   * 入力された数字が 3 または 5 または 15 の倍数か確認する。
   * 3 の倍数の場合 Fizz を、
   * 5 の倍数の場合 Buzz を、
   * 15 の倍数の場合 FizzBuzz を、
   * それ以外の場合、入力された数字を返却する。
   * @param input 入力
   */
  public checkFizzBuzz(input: number): any {
    if (input % 3 == 0) {
      return {
        showNumber: false,
        showFizz: true,
        showBuzz: false,
        showFizzBuzz: false,
      };
    }
  }
}
```
※ 一部抜粋。

⛳️になりました。
と、TDD はこんな感じで「とりあえずテストをパスするコードを書く」という考え方で進みます。
「そんな丁寧にやらなくても... 」と感じるかもしれませんが、「まずテストコードにバグがないこと」を確かめるために if == 3 のコードを書いています。
仮に明らかに成功するはずなのにレッドになってしまった場合、テストコードの書き方を疑う必要があります。
まずは明らかに成功するコードを書きグリーンになることを確認することで、テストコードにバグがないことを確認しています（和田先生の動画参考）。

最終的に、Unit テストはこんな感じになりました。
TDD で大切なことは**「テストコードのリファクタリングも行う」**ことです。
テストの対称性を保つため、「入力が 99 の場合」のテストは削除しました。
後でテストコードを見た人が「何で 3 の倍数の場合だけ、 2つテストあるんだ？」ってならないためのリファクタリングです。

```typescript:TCFizzBuzz.test.ts
import { TCFizzBuzz } from '@/class/TCFizzBuzz';
import { TCNotTypeOfNumberError } from '@/class/TCNotTypeOfNumberError';

describe('Validate the input', () => {
  let fb: TCFizzBuzz;
  beforeAll(() => {
    fb = new TCFizzBuzz(0);
  });
  it('When the input is less than 1, validateInput() returns false.', () => {
    expect(fb.validateInput(0)).toBe(false);
  });
  it('When the input is more than 100, validateInput() returns false.', () => {
    expect(fb.validateInput(101)).toBe(false);
  });
  it('When the input is not number, validateInput() throws TCNotTypeOfNumberError.', () => {
    expect(() => fb.validateInput('test')).toThrowError(TCNotTypeOfNumberError);
  });
});

describe('Check the input.', () => {
  let fb: TCFizzBuzz;
  beforeAll(() => {
    fb = new TCFizzBuzz(0);
  });
  it('When the input is not multiples of 3 or 5 or 15, checkFizzBuzz() returns {showNumber: true, showFizz: false, showBuzz: false, showFizzBuzz: false}.', () => {
    expect(fb.checkFizzBuzz(1)).toEqual({
      showNumber: true,
      showFizz: false,
      showBuzz: false,
      showFizzBuzz: false,
    });
  });

  it('When the input is 3, checkFizzBuzz() returns {showNumber: false, showFizz: true, showBuzz: false, showFizzBuzz: false}.', () => {
    expect(fb.checkFizzBuzz(3)).toEqual({
      showNumber: false,
      showFizz: true,
      showBuzz: false,
      showFizzBuzz: false,
    });
  });

  it('When the input is multiples of 5, checkFizzBuzz() returns {showNumber: false, showFizz: false, showBuzz: true, showFizzBuzz: false}.', () => {
    expect(fb.checkFizzBuzz(5)).toEqual({
      showNumber: false,
      showFizz: false,
      showBuzz: true,
      showFizzBuzz: false,
    });
  });

  it('When the input is multiples of 15, checkFizzBuzz() returns {showNumber: false, showFizz: false, showBuzz: false, showFizzBuzz: true}.', () => {
    expect(fb.checkFizzBuzz(15)).toEqual({
      showNumber: false,
      showFizz: false,
      showBuzz: false,
      showFizzBuzz: true,
    });
  });
});
```

TCFizzBuzz クラスはこんな感じ。

```typescript:TCFizzBuzz.ts
export class TCFizzBuzz {
  private n: number;

  /**
   * コンストラクター
   * @param n 入力された数字
   */
  constructor(n: number) {
    this.n = n;
  }

  /**
   * 入力が数字かどうか判定する。
   * 数字であれば true を、それ以外であれば false を返す。
   * @param input 入力
   */
  public checkType(input: any): boolean {
    if (typeof input === 'number') {
      return true;
    }
    return false;
  }

  /**
   * 入力された数字の validation を行う。
   * @param input 入力
   */
  public validateInput(input: any): boolean {
    if (!this.checkType(input)) throw new Error('Not type of number.');

    if (input < 1) {
      console.log('Less than 1');
      return false;
    } else if (input > 100) {
      console.log('More than 100');
      return false;
    }
    console.log('Passed validations');
    return true;
  }

  /**
   * 入力された数字が 3 または 5 または 15 の倍数か確認する。
   * 3 の倍数の場合 Fizz を、
   * 5 の倍数の場合 Buzz を、
   * 15 の倍数の場合 FizzBuzz を、
   * それ以外の場合、入力された数字を返却する。
   * @param input 入力
   */
  public checkFizzBuzz(input: number): any {

    try {
      //入力のバリデーションを行う。
      if (!this.validateInput(input)) {
        return {
          showNumber: false,
          showFizz: false,
          showBuzz: false,
          showFizzBuzz: false,
        };
      }
      //FizzBuzz のチェックを行う。
      if (input % 15 == 0) {
        console.log('FizzBuzz');
        return {
          showNumber: false,
          showFizz: false,
          showBuzz: false,
          showFizzBuzz: true,
        };
      } else if (input % 3 == 0) {
        console.log('Fizz');
        return {
          showNumber: false,
          showFizz: true,
          showBuzz: false,
          showFizzBuzz: false,
        };
      } else if (input % 5 == 0) {
        console.log('Buzz');
        return {
          showNumber: false,
          showFizz: false,
          showBuzz: true,
          showFizzBuzz: false,
        };
      }

      //number → stringに変換
      // return `${input}`;
      return {
        showNumber: true,
        showFizz: false,
        showBuzz: false,
        showFizzBuzz: false,
      };
    } catch (e) {
      console.error(e);
      return {
        showNumber: false,
        showFizz: false,
        showBuzz: false,
        showFizzBuzz: false,
      };
    }
  }
}
```

Jest は Coverage を出力してくれるので便利です。
（「Coverage 100[%] だから問題なし！」ってわけではないので、あくまで参考程度ですね。）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/557096/85e96afe-13da-402e-ec64-54addff347d3.png)

###E2E テストを書く
リグレッション対策として E2E テストも書きます。
inputRules のテストも行ってます。

```typescript:FizzBuzz.spec.ts
describe('FizzBuzz Test', () => {
  beforeEach(() => {
    cy.visit('http://localhost:8080/FizzBuzz');
    cy.url().should('include', '/FizzBuzz');
  });

  it('When the input is multiples of 3, show Fizz.', () => {
    cy.get('[data-cy=inputNumber]').type('99').should('have.value', '99');
    cy.get('[data-cy=checkButton]').click();
    cy.get('[data-cy=Fizz]').contains('Fizz');
  });

  it('When the input is multiples of 5, show Buzz.', () => {
    cy.get('[data-cy=inputNumber]').type('100').should('have.value', '100');
    cy.get('[data-cy=checkButton]').click();
    cy.get('[data-cy=Buzz]').contains('Buzz');
  });

  it('When the input is multiples of 15, show FizzBuzz.', () => {
    cy.get('[data-cy=inputNumber]').type('90').should('have.value', '90');
    cy.get('[data-cy=checkButton]').click();
    cy.get('[data-cy=FizzBuzz]').contains('FizzBuzz');
  });

  it('When the input is not multiples of 3 or 5 or 15, show Fizz.', () => {
    cy.get('[data-cy=inputNumber]').type('98').should('have.value', '98');
    cy.get('[data-cy=checkButton]').click();
    cy.get('[data-cy=Number]').contains('98');
  });

  it('When the input is less than 1, show "1~100 までの数字を入力してください。".', () => {
    cy.get('[data-cy=inputNumber]').type('test');
    cy.get('[data-cy=checkButton]').click();
    cy.get('[data-cy=Number]', { timeout: 0 }).should('not.exist');
    cy.get('.v-messages__message').contains(
      '1~100 までの数字を入力してください。'
    );
  });

  it('When the input is less than 1, show "1 より大きい数字を入力してください。".', () => {
    cy.get('[data-cy=inputNumber]').type('-1').should('have.value', '-1');
    cy.get('[data-cy=checkButton]').click();
    cy.get('[data-cy=Number]', { timeout: 0 }).should('not.exist');
    cy.get('.v-messages__message').contains(
      '1 より大きい数字を入力してください。'
    );
  });

  it('When the input is less than 1, show "100 より小さい数字を入力してください。".', () => {
    cy.get('[data-cy=inputNumber]').type('101').should('have.value', '101');
    cy.get('[data-cy=checkButton]').click();
    cy.get('[data-cy=Number]', { timeout: 0 }).should('not.exist');
    cy.get('.v-messages__message').contains(
      '100 より小さい数字を入力してください。'
    );
  });
});
```

Coverage を確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/557096/c6b71f1a-1d6c-6cfb-0daa-07afcfb5f5b7.png)
All ⛳️ですね！


#Tips
###babel.config.js の設定
Cypress で Coverage を測定するために `babel-plugin-istanbul` の plugin を読み込む必要があります。
ただ Jest にも instrument が組み込まれており、`babel-plugin-istanbul` とぶつかります。
今回は env を分けることで対応しました。
（[ちなみに Jest は初期設定で NODE_ENV = test で実行されます](https://jestjs.io/docs/ja/23.x/getting-started.html)）

```javascript:babel.config.js
const plugins = [];
if (process.env.NODE_ENV === 'Coverage') {
  plugins.push([
    'babel-plugin-istanbul',
    {
      extension: ['.js', '.ts', '.vue'],
    },
  ]);
}

module.exports = {
  presets: [
    ['@vue/cli-plugin-babel/preset'],
    ['@babel/preset-env', { targets: { node: 'current' }, modules: false }],
    '@babel/preset-typescript',
  ],
  env: {
    test: {
      presets: [
        ['@babel/preset-env', { targets: { node: 'current' }, modules: false }],
      ],
    },
  },
  plugins,
};
```

###Vue の input を number 型と認識させる方法
**参考**

- https://jp.vuejs.org/v2/guide/forms.html#number

今回のコードでは入力を number 型にしています。
が、HTML5 の input 要素は文字列を返してきます。
parse を挟んでも良いのですが、`v-model.number` で回避しました。

ちなみに入力が 0 のとき false と判定されてしまい、「1 より大きい数字を入力してください。」ではなく、「1~100 までの数字を入力してください。」が表示されます。
（やっぱり、別に作った方が良いかもしれない...）

###Cypress の Coverage が測定されない時の対処
たまに Cypress の Coverage が測定できない時がありました。 
理由は対象の .vue ファイルが instrument されていないからなのですが、原因を特定できませんでした。
以下に、instrument されなかったときに試した対処法を載せます。
（多分、babel が原因だとは思います）

- [babel.config.js の設定を見直しました。](https://vuejsdevelopers.com/2020/07/20/code-coverage-vue-cypress/)
- [cypress/plugins/index.ts の設定を見直しました。](https://docs.cypress.io/guides/tooling/code-coverage.html#E2E-and-unit-code-coverage)
- [package.json に nyc の設定を入れました。](https://github.com/istanbuljs/istanbuljs/issues/572#issuecomment-683403613)

あれ？と思ったら、[開発者ツールで `window.__coverage__` を確認する](https://github.com/cypress-io/code-coverage)のが良いと思います。


#まとめ
環境整備が一番大変でした。
かっちりコード書いてくのは楽しいのでオススメです。
Cypress の CodeCoverage はイマイチわからんので、公式の記事を読み直すべきかもしれない...