📝

フロントエンド(React Testing Library)で TDD(テスト駆動開発)をする

2020/11/19に公開

私はフロントエンドエンジニアとして働いてはいるのですが、巡り合わせが悪いのでしょうか?まともなテストを書いたことがないんですよね。まあ、それもでテストくらい書けないとなぁ。なんて思ってはちょいちょい調べたりする日々を過ごしていました。

そんなある日、たまたま TDD(テスト駆動開発) についての動画を視聴してみました。
TDD 自体は知ってはいて、なんとなく知っているくらいの知識ではありましたが、分かりやすい説明とその思想が好きで、のめり込むように見てしまいました。

その後も何度か視聴して、フロントエンドでも TDD したいなと考え始めました。
普段テストすら書いていないのにいきなり TDD とも思わないこともなかったですが、実際に普段自分がさわっているようなコードに落とし込んで書いていくと、テストする本当の意味というものが、より正確に理解できてきたような気がします。

そんなテスト初心者の僕が、TDD を啓蒙すべく書いた拙い記事にはなりますが、温かい目で見てやってくれると幸いです。

テストの種類

色々と考え方などがあるのですが、以下の記事が個人的に分かりやすかったですので、そちらをみてもらえればと思います。

https://qiita.com/naoking99/items/3fd211deb8711fae8204

上記の記事を踏まえた上で、今ならフロントエンドでは以下の構成でよいかなと考えています。
まったく自信はないですが。

種類 説明 ツール
E2E(End to End) システム全体が正しく動作するかのテスト Cypress
Integration コンポーネントごとの UI のテスト React Testing Library
Unit 使い回す関数などのテスト Jest
Static 型や Lint などの静的なテスト TypeScript, ESLint

TDD(テスト駆動開発)とは

僕が説明するよりも、さきほど紹介した以下の動画をみてもらえれば、TDD とはなにか、TDD をする意義とはなにか、どのように TDD をするのか、というのがわかるかと思います。
TDD Boot Camp 2020 Online #1 基調講演/ライブコーディング

一応、TDD について簡単に説明してみます。

動画内の言葉を借りると、

動作する仕様書

をつくることであり、実装前にテストコードを書くテストファーストな開発手法です。

また、Red(失敗)Green(成功)Refactor(リファクタリング) のサイクルを繰り返すことで、動作するきれいなコードを目指します。

ベースの環境を用意する

React Testing Library が動く環境さえ用意できればなんでもよいのですが、環境構築については説明しませんので、とりあえず試したい方は以下のリポジトリからクローンしてきてください。

https://github.com/syuji-higa/demo-fizzbuzz-nextjs-tdd/tree/blank

以下コマンドの実行で、モジュールのインストールもしておいてください。

$ yarn

TDD(テスト駆動開発)をはじめる

1. 要件を仕様に落とし込む

今回は TDD の動画と同じく Fizz Buzz 問題をベースにしたいと思います。
ただし、フロントエンドでの UI をともなうテストをしたいので、Fizz Buzz 問題を UI をともなう形にしてみたいと思います。

入力と出力をともなう UI

こんな感じですね。
1〜100 までを入力してその結果を表示するのではなくて、入力エリアした値を出力エリアに結果として表示する。といった要件とします。まとめると以下になります。

入力エリアに数字を入力したら、出力エリアにその数字を表示する。
ただし、3 の倍数のときは fizz と表示して、5 の倍数のときは buzz と表示する。
さらに、3 と 5 の倍数のときは fizzbuzz と表示する。

この要件を仕様レベルに落とし込んでいきます。

  • 入力エリアに 3 の倍数を入力したら、出力エリアに "fizz" を表示する
  • 入力エリアに 5 の倍数を入力したら、出力エリアに "buzz" を表示する
  • ただし、入力エリアに 3 と 5 の倍数を入力したら、出力エリアに "fizzbuzz" を表示する
  • それ以外の数字を入力したら、そのまま出力エリアに表示する
要件から見えない仕様について

通常の Fizz Buzz 問題であれば 1〜100 と入力が決まっていますが、今回の場合は入力をユーザに任せる形になります。となると要件から直接は見えてこない想定外の入力値や操作などがありえます。

実際に仕様に落とし込んでいく際には、そういった入力値の場合にどうするかといった仕様も考える必要があります。

今回のお話は TDD が中心ですので、複雑にならないようにそういった仕様は考えないで進めます。

2. テストが動くかのテストをする

まずテストが動くかの確認をしましょう。
何もテストがないとエラーになるので、サンプルの環境ではとりあえず以下が書かれています。

src/__tests__/FizzBuzz.test.tsx
it.todo('blank')

それでは、テストを実行します。

$ yarn test

問題がなければ以下のようになります。

これでテストが動くという安心を得ました。

3. 要件と仕様をテストに書く

さっそくテストに要件と仕様を書いていきます。
describe に説明を書けるので、そこに要件を書きます。ただ、仕様も書くのでザックリと一言にまとめてよいかと思います。
it.todo または test.todo に ToDo(やること)を書けるので、そこに仕様を書きます。

はじめに書いてあった ToDo は不要なので削除して、以下を記述します。

src/__tests__/FizzBuzz.test.tsx
// 要件
describe('Fizz Buzz 問題の答えを表示する', () => {
  // 仕様
  it.todo('入力エリアに 3 の倍数を入力したら、出力エリアに "fizz" を表示する')
  it.todo('入力エリアに 5 の倍数を入力したら、出力エリアに "buzz" を表示する')
  it.todo('ただし、入力エリアに 3 と 5 の倍数を入力したら、出力エリアに "fizzbuzz" を表示する')
  it.todo('それ以外の数字を入力したら、そのまま出力エリアに表示する')
})

この時点で、テストを実行してみます。

$ yarn test

以下のように、要件に仕様がインデントされて表示されます。

4. テストの ToDo を書く

テストを書く順番はテスト容易性が高くて、重要度も高いものから書いていきます。
では今回は「それ以外の数字を入力したら、そのまま出力エリアに表示する」についてからテストを書いていきます。

今回の仕様は入力をともないますので、テストする値を考える必要があります。
例外条件に該当する 「3」と「5」の倍数以外の数字を入れれば良いですね。
となると「1」から順番に考えて、そのままはじめの「1」が当てはまるので、「1」を入力値にします。

次は「1」を入力値とした場合の出力値を考えます。
そのまま出力するので、「1」が出力値となります。

ということで、「入力エリアに "1" を入力したら、出力エリアに "1" を表示する」 をテストする内容にします。これをテストに書くと以下のようになります。

src/__tests__/FizzBuzz.test.tsx
// 仕様
describe('それ以外の数字を入力したら、そのまま出力エリアに表示する', () => {
  // テスト内容
  it.todo('入力エリアに "1" を入力したら、出力エリアに "1" を表示する')
})

ちょっと分かりづらいのですが、もともと it.todo に書いてあった仕様を describe にして、その中にテストする内容を ToDo として書きます。

テストを実行してみます。

$ yarn test

以下のように、仕様にテスト内容の Todo がインデントされて表示されます。

5. テストの検証を書く

テストの実行は、準備(arrange)実行(act)検証(assert) の順番に行われます。
ただし、テストを書く順番としては逆から書いていくと分かりやすいです。
ですので、検証から書いていきます。

expect に実測値を、toBe に期待値 を入れる形になります。

src/__tests__/FizzBuzz.test.tsx
// テスト内容
it('入力エリアに "1" を入力したら、出力エリアに "1" を表示する', () => {
  // 検証(assert)
  expect(expected).toBe('1')  // expected は期待値
})

6. テストの実行を書く

次にテストの実行を書きますが、UI をともなうテストを書いていくことになります。

それでは実行とは何でしょうか?
ユニットテストで考えた場合はシンプルに関数の実行になります。
その場合、関数の実行の返り値実測時になります。

では UI をともなうテストの場合ではどうなるのでしょうか?
実行とは入力エリアに数字を入力することになります。
また、出力エリアに表示される文字列実測値になります。

上記の考えをコードに落とし込んでみます。

src/__tests__/FizzBuzz.test.tsx
it('入力エリアに "1" を入力したら、出力エリアに "1" を表示する', () => {
  // 実行(act)
  fireEvent.change(inputElement, { target: { value: '1' } })  // inputElement は入力エリアの要素
  // 検証(assert)
  expect(outputElement.textContent).toBe('1')  // outputElement は出力エリアの要素
})

この段階で合わせて、検証の expected を出力エリアのテキストに差し替えています。

7. テストの準備を書く

テストの実行を遡ってきて、あとは準備になります。
準備とは何になるかですが、単純に検証と実行をするのに足りない処理です。

UI をともなうテストですので、要素の取得が必要になります。
つまり、このテストの場合ですと入力エリアと出力エリアの要素の取得が足りない処理であり、このテストの準備となります。

src/__tests__/FizzBuzz.test.tsx
it('入力エリアに "1" を入力したら、出力エリアに "1" を表示する', () => {
  // 準備(arrange)
  const { getByTestId } = render(<Index />)
  const inputElement = getByTestId('input')  // `data-testid="input"` の要素を取得
  const outputElement = getByTestId('output')  // `data-testid="output"` の要素を取得
  // 実行(act)
  fireEvent.change(inputElement, { target: { value: '1' } })
  // 検証(assert)
  expect(outputElement.textContent).toBe('1')
})
要素の取得方法について

React Testing Library での要素の取得方法は色々とありますが、今回は getByTestId を使い、要素側に data-testid 属性を使っています。

考え方は色々とあるかと思いますが、変更に強いという点で選択しています。
今回はこの点については深く追求することはしませんので、ご自身で調べてみて最適な方法を模索するとよいかと思います。

追記(2022/8/30):現時点においては React Testing Library の思想に則り、getByRole などのユーザにとってアクセシブルなクエリを優先するべきだと考えています。

ではここで久しぶりに、テストを実行してみます。

$ yarn test

以下のように、失敗してしまいます。

これは想定したとおりの失敗であり、ステータスは Red です。
取得する要素自体がありませんので、失敗するのは当たり前ですね。

テストの自体の信頼性とは別に、テストを書いていく上での自信というものがあるかと思います。
自信を持ってテストを書いていくには、あえて正しく失敗することを確認する過程を踏むことも、大事な工程のひとつです。

8. テストの仮実装を書く

やっと実装を書くところまできました。
しかし、まだこれから書いていくのは仮実装です。

仮実装とは最短でテストが成功する実装 のことです。
言ってしまえば意味のない実装ではありますが、重要なのはまずテストを成功させることで、小さな一歩を踏み出すことです。

今回ので実装で、最短でテストを成功さえる為に必要なことは以下の 3 点です。

  • 入力エリアの要素を用意する
  • 出力エリアの要素を用意する
  • 出力エリアに「1」を表示する

実装すると以下のようになります。

src/pages/index.tsx
<div className={styles.container}>
   <p>
    入力:
    <input type="number" data-testid="input" />
  </p>
  <p>
    出力:<span data-testid="output">1</span>
  </p>
</div>

では、テストを実行してみます。

$ yarn test

以下のように、成功します。

これは想定した通りの成功であり、ステータスは Green です。
つまり、小さな一歩を確実に踏み出したといえます。

9. 三角測量のテストを書く

さて、テストは無事に成功しましたが、これは最短でテストを成功させることを優先し、仮実装をしたことによる偽りの成功です。

ですので、同じ仕様に対してもうひとつのテストを追加します。
これを三角測量といいます。

内容としては動的な入力値に対するテストになればよいですので、「1」の次の値から考えてみます。
「2」はどうでしょうか?
入力値「2」 から得られる出力値「2」 も、「それ以外の数字を入力したら、そのまま出力エリアに表示する」という仕様の期待値になります。

ということで、「2」を期待値とした 「入力エリアに "2" を入力したら、出力エリアに "2" を表示する」 という内容のテストを書きます。

src/__tests__/FizzBuzz.test.tsx
// テスト内容
it('入力エリアに "2" を入力したら、出力エリアに "2" を表示する(三角測量)', () => {
  // 準備(arrange)
  const { getByTestId } = render(<Index />)
  const inputElement = getByTestId('input')
  const outputElement = getByTestId('output')
  // 実行(act)
  fireEvent.change(inputElement, { target: { value: '2' } })
  // 検証(assert)
  expect(outputElement.textContent).toBe('2')
})

では、テストを実行してみます。

$ yarn test

新しく追加したテストが失敗しています。

これも想定どおりの失敗で、ステータスは Red です。
仮実装のままですので、何を入力しても出力エリアに表示されるのは「1」 だけです。
これもまた、小さな一歩です。

10. 正しく動作する実装を書く

それではここからが本格的な実装です。
仮実装とは違い、入力値に対して正しい答えになるようにします。
ただし、この後に待ち受けるリファクタリングのフェーズがありますので、正しい実装であればキレイな実装である必要はないです。

src/pages/index.tsx
const [num, setNum] = useState('')

const convertFizzbuzz = (e: React.ChangeEvent<HTMLInputElement>) => {
  setNum(e.target.value)
}

return (
  <div className={styles.container}>
    <p>
      入力:
      <input type="text" data-testid="input" onChange={convertFizzbuzz} />
    </p>
    <p>
      出力:<span data-testid="output">{num}</span>
    </p>
  </div>
)

実装が終わりましたので、テストを実行します。

$ yarn test

2 つのテストに成功しました。

三角測量により正しく動作することを確認でき、ステータスは Green です。
つまり、さらなる一歩を踏み出すことができたということです。これにより、次なる一歩への自信がついたかと思います。

11. 実装のリファクタリングをする

一般的にリファクタリングというと、外部からみた動作を変えずに内部の構造を整理することです。
しかし、TDD におけるリファクタリングでは、成功しているテストが成功するままで、コードを書き換えることを指します。

また、ステータスが Green の状態でリファクタリングすることが大切です。
リファクタリングにはメリットともにリスクがともないます。それは、現在動作しているものが動かなくなる可能性です。
ただし、テストが成功し続けているうちは、その動作は保証されているということです。
これにより安全性を得ることができ、大胆かつ積極的にリファクタリングしやすくなります。

では実際にリファクタリングをしてみます。
入力エリアの input は type="text" でしたが、数字の入力を想定されているので type="number" にしてみます。

src/pages/index.tsx
<p>
  入力:
  <input type="number" data-testid="input" onChange={convertFizzbuzz} />
</p>

実装のリファクタリングをしましたので、テストを実行します。

$ yarn test

かわらずテストに成功しています。

ステータスは Green のままですので、問題なくリファクタリングができたということです。

このときの気持はどうでしょうか?
自信を持ってリファクタリングできたと言えるかなと思います。

この自信が TDD におけるメリットであり、さらに詳細に考えると、仕様書どおりの実装を保ったまま改善することができる自信です。

12. テストのリファクタリングをする

実装と同様にテストのリファクタリングもしていきます。
テストのコードを見てみると、以下の準備の箇所が重複していますね。

src/__tests__/FizzBuzz.test.tsx
// 準備(arrange)
const { getByTestId } = render(<Index />)
const inputElement = getByTestId('input')
const outputElement = getByTestId('output')

こちらを beforeEach によって前準備することができます。

src/__tests__/FizzBuzz.test.tsx
// 前準備(arrange)
let inputElement: HTMLElement, outputElement: HTMLElement
beforeEach(() => {
  const { getByTestId } = render(<Index />)
  inputElement = getByTestId('input')
  outputElement = getByTestId('output')
})

// 仕様
describe('それ以外の数字を入力したら、そのまま出力エリアに表示する', () => {
  // テスト内容
  it('入力エリアに "1" を入力したら、出力エリアに "1" を表示する', () => {
    // 実行(act)
    fireEvent.change(inputElement, { target: { value: '1' } })
    // 検証(assert)
    expect(outputElement.textContent).toBe('1')
  })

  // テスト内容
  it('入力エリアに "2" を入力したら、出力エリアに "2" を表示する(三角測量)', () => {
    // 実行(act)
    fireEvent.change(inputElement, { target: { value: '2' } })
    // 検証(assert)
    expect(outputElement.textContent).toBe('2')
  })
})

テストのリファクタリングをしましたので、テストを実行します。

$ yarn test

かわらずテストに成功しています。

今回もステータスは Green のままですので、問題なくリファクタリングができたということですね。

これで「それ以外の数字を入力したら、そのまま出力エリアに表示する」という仕様については、テストと実装を書くことができました。

13. 2 つ目の仕様のテストを書く

次は「入力エリアに 3 の倍数を入力したら、出力エリアに "fizz" を表示する」という仕様のテストを書いていきます。

既に 4〜6 でおこなったことと同じですので、一気にテストを書いてみます。

src/__tests__/FizzBuzz.test.tsx
// 仕様
describe('入力エリアに 3 の倍数を入力したら、出力エリアに "fizz" を表示する', () => {
  // テスト内容
  it('入力エリアに 3 を入力したら、出力エリアに "fizz" を表示する', () => {
    // 実行(act)
    fireEvent.change(inputElement, { target: { value: '3' } })
    // 検証(assert)
    expect(outputElement.textContent).toBe('fizz')
  })
})

テストを追加しましたので、テストを実行します。

$ yarn test

すると失敗します。

もうお分かりかと思いますが、これは約束された勝利の失敗で、ステータスは Red です。
もちろんこれは、まだ実装していない為で、そのまま入力した数字が表示されているからです。

14. 2 つ目の仕様の仮実装を書く

これも 8 でおこなったことと同じです。
最短でテストが成功する実装をしていきます。

src/pages/index.tsx
const convertFizzbuzz = (e: React.ChangeEvent<HTMLInputElement>) => {
  if(e.target.value === '3') {
    setNum('fizz')
    return
  }
  setNum(e.target.value)
}

仮実装をしましたので、テストを実行します。

$ yarn test

これで成功します。

問題なく仮実装ができており、ステータスは Green です。

15. 2 つ目の仕様が正しく動作する実装を書く

これは 10 でおこなったことと同じですが、9 でおこなった三角測量をせずに実装へと進みます。
これは既にテストに対する信頼があり、迷いなく実装を進める自信を得ているからです。
今までよりも大きな一歩を踏み出すことができる状態になっています。

src/pages/index.tsx
const convertFizzbuzz = (e: React.ChangeEvent<HTMLInputElement>) => {
  const num = Number(e.target.value)
  if(num % 3 === 0) {
    setNum('fizz')
    return
  }
  setNum(e.target.value)
}

実装ができたので、テストを実行します。

$ yarn test

かわらずに成功しています。

これは勝利すべき緑色の成功であり、ステータスはもちろん Green です。

1 つ目の仕様の時点でしっかりと設計をしてテストを書いていれば、2 つ目からはそれほど時間をかけずにテストと実装を書くことができます。

16. 3 つ目の仕様のテストを書く

もう 3 週目ですので慣れてきたかと思います。
次は「入力エリアに 5 の倍数を入力したら、出力エリアに "buzz" を表示する」という仕様のテストを書いていきます。

src/__tests__/FizzBuzz.test.tsx
// 仕様
describe('入力エリアに 5 の倍数を入力したら、出力エリアに "buzz" を表示する', () => {
  // テスト内容
  it('入力エリアに 5 を入力したら、出力エリアに "buzz" を表示する', () => {
    // 実行(act)
    fireEvent.change(inputElement, { target: { value: '5' } })
    // 検証(assert)
    expect(outputElement.textContent).toBe('buzz')
  })
})

テストを追加したので、テストを実行します。

$ yarn test

はい。失敗しますね。

完璧な予想通りで、ステータスは Red です。

17. 3 つ目の仕様が正しく動作する実装を書く

今度は仮実装をせずに、正しく動作する実装をしていきます。
ここまで予想通りにサイクルが回っているということは、もはやテストに不安はないと思います。
踏み出す脚に恐れはなく、もはや走り出している状態と言えるでしょう。そうです、我々は既に勝利しているのです。

このような実装を 「明白な実装」 と呼びます。

では実装をしていきます。

src/pages/index.tsx
const convertFizzbuzz = (e: React.ChangeEvent<HTMLInputElement>) => {
  const num = Number(e.target.value)
  if(num % 3 === 0) {
    setNum('fizz')
    return
  }
  if(num % 5 === 0) {
    setNum('buzz')
    return
  }
  setNum(e.target.value)
}

実装ができたので、テストを実行します。

$ yarn test

成功しました。

問題なく実装ができており、ステータスは Green です。

18. 残りのテストと実装を書く

あとは 3 つ目の仕様とやることが同じですので、特に説明はしません。
不明点があれば、既に対応済みのリポジトリがありますので、そちらを参照してください。

https://github.com/syuji-higa/demo-fizzbuzz-nextjs-tdd

テストの実行結果は以下になります。

見ての通り、仕様書として機能しているかと思います。

19. 三角測量のテストを消す

最後に三角測量につかったテストを消すのですが、なぜでしょうか?
最終的なテスト結果を再度確認してみます。

「それ以外の数字を入力したら、そのまま出力エリアに表示する」仕様のテストだけ 2 つあり、レベル感が他とあっていないですね。
このまま残してしまうと、初めて見た人がどのようなレベル感でテストをしていたのかが分からず困ってしまいます。ですので分かりやすいように消しておくことが大切です。

そしてこれが、三角測量のテストを消した最終的なテスト結果であり、「動作する仕様書」 です。

所感

自分で言うのもなんですが、相変わらずの長い記事となってしまいました。
それに、ほとんどの思想は TDD についての動画内で説明されていることですので、そちらを見た方が圧倒的に良いです。自信を持って言えます!

でもまあ、強いていうのであれば、最近のフロントエンド環境で TDD の実装方法を書いている記事はそんなにないだろうという点と、僕なりの考え方に落とし込んでいる部分があるので、それが分かりやすく思える方もいるかもしれない点ですかね。もし、お役に立てたのであれば幸いです。

ただ、はじめにも書いているようにテスト初心者ではありますので、間違っている点などがあればご指摘いただけると嬉しいです。

そして、みなさんが疑問に思っていることがあるかと思います。
TDD は実際にプロジェクトで使えるのか?というところです。
これに関しては僕は経験不足でして、残念ながらそれをもとに答えることはできません。
それでも、TDD を学ぶことに意味はあると思えました。

僕は TDD の熱狂的信者という訳ではありませんが、ただただ TDD の思想が好きです。
僕の中で同様のものとして Rust があります。まだあまり書けないのですが、これも設計思想が好きです。

思想が設計を生み出し、設計を元に実装されていく。それを考えると全ての根源は思想にあるかと思います。
そういった意味でも僕は思想というものを知りたいし、理解したいです。好きになるかは別の話ですが。

なんか話がそれた気がしますが、機会があれば是非 TDD をやってみようかと思っています。
あと本も読んでないから、より深く理解する為に読まないとですね。

それでは、みなさんも Red, Green, Refactor の TDD 生活を楽しんでみてくださいね。
サイクル、サイクルぅ♪

Discussion