始めに
テスト駆動開発を読んで知ったことや、実践して分かったこの手法のメリットとデメリットについて記載
テスト駆動開発とは
先にテストコードを作成し、テストをパスするように実装を行う手法
■目的
動作するきれいなコードを作成する
■実装順序
Red・Green・Refactorの3ステップで実装する
# | 名前 | 説明 |
---|---|---|
1 | Red | 通らないテストを書く |
2 | Green | テストが通るコードを書く |
3 | Refactor | Greenで作成したコードを綺麗にする |
当初のイメージと完読後のイメージ
■当初のイメージ
「テスト駆動開発」「テストコードを先に書く」と知った際、「綿密な設計が必要でそれに沿ってテストを作成する」というイメージだった
■完読後のイメージ
完読後は基本設計さえあれば、以降はテストコードやプロダクトコードを作る中で詳細な設計することができると思っている
実施した内容
テスト駆動開発を試すにあたり、Reactのチュートリアルを題材にした
https://ja.react.dev/learn/tutorial-tic-tac-toe
内容は〇×ゲームの作成であり、Reactの基本的なことを学ぶことができるようになっている
これを題材に選んだ理由は、特にない
当時の私がフロントエンドの単体テスト方法に関心があり、かつ丁度Reactのチュートリアルを終わらせたタイミングだったためである。
そのため、これからテスト駆動開発を試すという方は既に単体テストの実行方法を知っている実装方法で試してほしい(理由後述)
実施した内容の抜粋
Reactチュートリアルを全てテスト駆動開発で実装したが、全て記載すると冗長になるため一部のみ記載する
今回実装する機能はマス目を押すとOまたはXが入力される
実装すべき機能は以下の3つ
- すべて空欄
- 1回目がO
- 2回目はX
おさらいになるが実装手順は3手順。この手順に従う
※今回はコードがシンプルなため、3. Refactorは省略
# | 名前 | 説明 |
---|---|---|
1 | Red | 通らないテストを書く |
2 | Green | テストが通るコードを書く |
3 | Refactor | Greenで作成したコードを綺麗にする |
また、プロダクトコードはApp.tsx
、テストコードをApp.test.tsx
に記載する
1. Red:テストコードの作成
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import '@testing-library/jest-dom';
import 'jest';
import App from '../src/scripts/App';
import React from 'react';
describe('App.tsのテスト', () => {
test('初期値が空欄であること', () => {
render(<App />);
const buttons = screen.getAllByRole('button');
buttons.forEach((button) => {
expect(button).toHaveTextContent('');
});
});
test('1回クリックするとOが入力されること', async () => {
render(<App />);
const user = userEvent.setup();
const button = screen.getAllByRole('button')[0];
await user.click(button);
expect(button).toHaveTextContent('O');
});
test('2回目のクリックはXが入力されること', async () => {
render(<App />);
const user = userEvent.setup();
const buttons = screen.getAllByRole('button');
await user.click(buttons[0]);
await user.click(buttons[1]);
expect(buttons[1]).toHaveTextContent('X');
});
});
この時点ではまだテストコードは通らない
App.tsxに何も記述していないため実行すらできない
$ npm test
> frontend@1.0.0 test
> jest
FAIL tests/App.test.tsx
● Test suite failed to run
tests/App.test.tsx:5:17 - error TS2306: File '/home/dev1/src/frontEnd/src/scripts/App.tsx' is not a module.
5 import App from '../src/scripts/App';
~~~~~~~~~~~~~~~~~~~~
2. Green:テストコードをパスする実装
まずはコードを記述するApp.tsxのひな型を作る
import React from 'react';
export default function App() {
return <></>;
}
この時点でコンパイルが通るためテストが実行される
FAIL tests/App.test.tsx (19.243 s)
App.tsのテスト
✕ 初期値が空欄であること (66 ms)
✕ 1回クリックするとOが入力されること (6 ms)
✕ 2回目のクリックはXが入力されること (6 ms)
次にマス目を作成する
import React from 'react';
import '../templates/App.css';
export default function App() {
return (
<>
<div className="board-row">
<Square />
<Square />
<Square />
</div>
<div className="board-row">
<Square />
<Square />
<Square />
</div>
<div className="board-row">
<Square />
<Square />
<Square />
</div>
</>
);
}
function Square() {
return <button className="square"></button>;
}
これで1つ目のテストがパスした
FAIL tests/App.test.tsx (16.537 s)
App.tsのテスト
✓ 初期値が空欄であること (102 ms)
✕ 1回クリックするとOが入力されること (56 ms)
✕ 2回目のクリックはXが入力されること (51 ms)
現時点でこのようなレンダーになる
クリックしても何も起きない
最期にクリックした際にOまたはXが入力される実装を行う
import React, { useState } from 'react';
import '../templates/App.css';
export default function App() {
const [squareValues, setSquareValues] = useState(Array(9).fill(undefined));
const [currentTurn, setCurrentTurn] = useState(true);
const onClicksSetValues = (id: number) => {
const nextValues = squareValues.slice();
nextValues[id] = currentTurn ? 'O' : 'X';
setSquareValues(nextValues);
setCurrentTurn(!currentTurn);
};
return (
<>
<div className="board-row">
<Square
squareValue={squareValues[0]}
click={() => onClicksSetValues(0)}
/>
<Square
squareValue={squareValues[1]}
click={() => onClicksSetValues(1)}
/>
<Square
squareValue={squareValues[2]}
click={() => onClicksSetValues(2)}
/>
</div>
<div className="board-row">
<Square
squareValue={squareValues[3]}
click={() => onClicksSetValues(3)}
/>
<Square
squareValue={squareValues[4]}
click={() => onClicksSetValues(4)}
/>
<Square
squareValue={squareValues[5]}
click={() => onClicksSetValues(5)}
/>
</div>
<div className="board-row">
<Square
squareValue={squareValues[6]}
click={() => onClicksSetValues(6)}
/>
<Square
squareValue={squareValues[7]}
click={() => onClicksSetValues(7)}
/>
<Square
squareValue={squareValues[8]}
click={() => onClicksSetValues(8)}
/>
</div>
</>
);
}
function Square({
squareValue,
click,
}: {
squareValue: string;
click: any;
}) {
return (
<button className="square" onClick={click}>
{squareValue}
</button>
);
}
これで最初に作成したテストは全て通る
PASS tests/App.test.tsx (18.66 s)
App.tsのテスト
✓ 初期値が空欄であること (129 ms)
✓ 1回クリックするとOが入力されること (76 ms)
✓ 2回目のクリックはXが入力されること (63 ms)
メリットとデメリット
メリット
1. 単体テストの役割を果たすことができる
単体テストの役割は「アプリケーションの振る舞いをテストする」ことであり、テスト駆動開発ではそれが意識せずに可能になっていた。
例えば、テスト名が「初期値が空欄であること」や「1回クリックするとOが入力されること」となっており、内部の実装を意識させない名前になっている。普段の私なら「初期値は全てnull」や「1回目はOが登録される」など実装を意識したテスト名になっている。
2. コードが綺麗になる
テストコードがあるため、リファクタリング後に直ちに退行の有無を確認できる。実装した機能が失われていないことがすぐに確認できるため、リファクタリングを実施するハードルが非常に低くなり、コードが綺麗になる
3. コミットから現新比較が容易になる
本開発手法がRed->Green->Refactorの手順を踏むため、コミット履歴もそのようになる。その結果、コミット履歴から新しい機能を実装する前と実装した後のコードやテスト結果の比較が容易になった。
私は以前、プロダクトコードの修正でテストコードを追加した際に「追加したテストは修正前ではパスしない?」と聞かれたことがある。そのときは一度修正箇所を消し、追加したテストをパスしないことを見せたが、このコミット履歴があればスムーズに解答できたのにと少し悔しくなった
デメリット
テスト駆動開発は動作する綺麗なコードを作るのに非常に有用な開発手法と思った。しかし、以下の2つの理由で定着しないのだろうと感じた
1. 実践までのコストが大きい
通常の開発フローであれば実装に必要な最低限の知識は開発言語と実装で使用するモジュールの2つである。しかし、テスト駆動開発ではそれに加えてテストライブラリとテスト方法を知っている必要がある。
私は当初ReactもReactに対する単体テストの方法も知らなかったため、両方を調べる必要があった。新しいことを同時に複数学ぶ必要がある開発手法は実践のハードルが高いと感じた。
2. 要件が完璧に決まっていないと出戻りが多くなる
開発時に追加要件が発生すると戻りが多くなる。
特に入力と出力が変化するような追加要件が発生した場合、テストコードとプロダクトコードの両方を書き換える必要があるため、非常に手間がかかる。
あとがき
テスト駆動開発は動作する綺麗なコードをかくために非常に有用な手法だと感じた。しかし、実践に必要なコストや現場で発生する可能性のある出戻りを考慮したとき、チーム全体でこの手法で開発することは難しく、それがこの手法が定着しない理由だと考えた
私としてはコードの動作を担保しながら実装するこの手法が非常に肌に合っている。そのため、個人の開発ではこの手法を続けたいと思った