TDD入門してみた by Laravel
IT技術
今回はTDDについて学習したので、そのまとめになります。
学習といっても、TDDという開発手法は奥が深く、またそれを使いこなすのはそれ何に難しいということで、何となくの雰囲気を掴んでやってみることを目標にしています。
すごく単純化したケースを題材にしているので、とっかかりになれば幸いです。
TDDとは
Test-Driven Developmentの略で、日本語ではテスト駆動開発という開発手法。
すごく雑に表現すると、テストを先に書き、そのテストが通るように実装し、そして内容を修正して綺麗にしていくやり方になります。
TDDの基本的な考え方
2つの原則
- 自動化されたテストが失敗した場合だけ、新しいコードを書く。
- 重複を除去する。
テストが先行するので、(初回は)落ちる前提のテストを作って、それを通る実装をするのが基本的な流れになります。
重複を削除というのは、例えばテストを通すために書いたコードで、共通化できるところは共通化していく、そんなイメージです。
また、TDDでの開発サイクルは大きく「レッド、グリーン、リファクタリング」の3つで構成されています。
レッド
動作しない、おそらく最初のうちはコンパイルも通らないテストを1つ書く
KentBeck. テスト駆動開発 (Japanese Edition) (p.6). Kindle 版.
グリーン
そのテストを迅速に動作させる。このステップでは罪を犯してもよい。
KentBeck. テスト駆動開発 (Japanese Edition) (p.6). Kindle 版.
※ここでの「罪」とは、良い設計とされる様々な原則を無視したコードを書くこと。
「普通ならそう書かないでしょ...」なコードも、このステップでは一時的に許されます。というのも、次のステップでリファクタが待っているためです。
リファクタリング
テストを通すために発生した重複をすべて除去する
KentBeck. テスト駆動開発 (Japanese Edition) (p.7). Kindle 版.
TDDで機能を作ってみよう
概要を抑えたので、早速TDDを意識して機能を作っていきましょう。
ゴール
今回は、メモアプリのメモ登録機能APIを実装する所に焦点を当てます。
登録に成功した場合と失敗した場合、それぞれでJSON形式のレスポンスを返します。
1// 登録成功時
2{
3 'message' => '登録成功'
4}
5// 失敗時
6{
7 'message' => '登録失敗'
8}
準備
作業を行うにあたり、以下を事前に準備しておく必要があります。
- マイグレーションファイル作成
- モデル作成
- コントローラ作成
- ルーティング
- テストクラス作成
TDDで実装してみる
TDDでは、やるべきことをToDoリストに書き出しておきます。
その各ToDoをクリアしていくことで、気づいたら機能ができていたという流れになっています。
本当はもっと細かい粒度でやった方が良いのかもですが、今回は以下をToDoとして設定します。
- JSONレスポンスを返す
- DBへの登録処理ができる
1. JSONレスポンスを返す
ここでは、postリクエストを投げたあと、コントローラが最後に返すJSONレスポンスを実装します。
1. レッド:落ちるテストを書く
1class StoreActionTest extends TestCase
2{
3 /*
4 * A basic feature test example.
5 */
6 public function testStoreSuccess(): void
7 {
8 $requestData = [
9 'title' => 'タイトル',
10 'content' => 'メモ内容',
11 ];
12 $url = route('api.memo.store');
13 $this->post($url, $requestData)
14 ->assertJson(
15 [
16 'message' => '登録完了',
17 ]
18 )
19 ;
20 }
21
22 public function testStoreFailed(): void
23 {
24 // 失敗条件
25 $requestData = [
26 'title' => '', // とりあえずタイトルを空白に
27 'content' => 'メモ内容',
28 ];
29 $url = route('api.memo.store');
30 $this->post($url, $requestData)
31 ->assertJson(
32 [
33 'message' => '登録失敗',
34 ]
35 )
36 ;
37 }
38}
このテストを実行するとこうなります。
1App\Http\Controllers\Api\Memo\MemoController::store(): Return value must be of type Illuminate\Http\JsonResponse, none returned
2--- Expected
3+++ Actual
4@@ @@
5 array (
6- 'message' => '登録失敗',
7+ 'message' => 'App\\Http\\Controllers\\Api\\Memo\\MemoController::store(): Return value must be of type Illuminate\\Http\\JsonResponse, none returned',
8 'exception' => 'TypeError',
9 'file' => '/var/www/app/Http/Controllers/Api/Memo/MemoController.php',
10 'line' => 35,
JsonResponse型を返さないといけないのに、何も返ってないとい怒られました。
当然です。現時点ではコントローラには何も実装されていません。
1class MemoController extends Controller
2{
3 /*
4 * Handle the incoming request.
5 *
6 * @param \Illuminate\Http\Request $request
7 *
8 * @return \Illuminate\Http\JsonResponse;
9 */
10 public function store(Request $request): JsonResponse
11 {
12 // まだ何も実装していない
13 }
14}
1回目のレッドをここで行いました。
2. グリーン:登録成功or失敗時のレスポンスが返るように、仮実装する
1で出たエラーをもとに、コントローラを実装しましょう。
これが上で述べたグリーンの部分になります。
1class MemoController extends Controller
2{
3 /*
4 * Handle the incoming request.
5 *
6 * @param \Illuminate\Http\Request $request
7 *
8 * @return \Illuminate\Http\JsonResponse;
9 */
10 public function store(Request $request): JsonResponse
11 {
12 $title = $request->get('title');
13 $content = $request->get('content');
14
15 if ($title === null || $content === null) {
16 return response()->json([
17 'message' => '登録失敗',
18 ]);
19 }
20
21 // メモの作成処理を実装
22 // ここでは、まだDBに接続しないため、処理を省略しています
23
24 return response()->json([
25 'message' => '登録完了',
26 ]);
27 }
28}
色々言いたくなるコードですが、気にせずテストを実行してみます。
※↓テストはここでは修正していません。
1class StoreActionTest extends TestCase
2{
3 /*
4 * A basic feature test example.
5 */
6 public function testStoreSuccess(): void
7 {
8 $requestData = [
9 'title' => 'タイトル',
10 'content' => 'メモ内容',
11 ];
12
13 $url = route('api.memo.store');
14
15 $this->post($url, $requestData)
16 ->assertJson(
17 [
18 'message' => '登録完了',
19 ]
20 )
21 ;
22 }
23
24 public function testStoreFailed(): void
25 {
26 // 失敗条件
27 $requestData = [
28 'title' => '', // とりあえずタイトルを空白に
29 'content' => 'メモ内容',
30 ];
31
32 $url = route('api.memo.store');
33
34 $this->post($url, $requestData)
35 ->assertJson(
36 [
37 'message' => '登録失敗',
38 ]
39 )
40 ;
41 }
42}
2つともテストが通りました。
1OK (2 tests, 2 assertions)
3. リファクタ:FormRequestクラスにバリデーション処理の移譲
グリーンをクリアしたら次はリファクタです。
TDDにおけるリファクタは、「テストがグリーンである範囲での修正」になります。
まずフォームリクエストクラスを作成し、以下のように実装します。
※内容的に、FormRequestの単体テストでやることかもですが...
1<?php
2
3namespace App\Http\Requests;
4
5use Illuminate\Contracts\Validation\Validator;
6use Illuminate\Foundation\Http\FormRequest;
7use Illuminate\Http\Exceptions\HttpResponseException;
8
9class MemoRequest extends FormRequest
10{
11 /*
12 * Determine if the user is authorized to make this request.
13 *
14 * @return bool
15 */
16 public function authorize()
17 {
18 return true;
19 }
20
21 /**
22 * Get the validation rules that apply to the request.
23 *
24 * @return array<string, mixed>
25 */
26 public function rules()
27 {
28 return [
29 'title' => 'required',
30 'content' => 'required',
31 ];
32 }
33
34 public function failedValidation(Validator $validator)
35 {
36 $response = ['message' => '登録失敗'];
37
38 throw new HttpResponseException(response()->json($response, 422));
39 }
40}
合わせてコントローラも修正します。
1public function store(MemoRequest $request): JsonResponse
2 {
3 return response()->json([
4 'message' => '登録完了',
5 ]);
6 }
1// 修正前
2 public function store(Request $request): JsonResponse
3 {
4 $title = $request->get('title');
5 $content = $request->get('content');
6 if ($title === null || $content === null) {
7 return response()->json([
8 'message' => '登録失敗',
9 ]);
10 }
11 return response()->json([
12 'message' => '登録完了',
13 ]);
14 }
かなりスッキリしたと思います。
2. DBへの登録処理をテスト
同じ要領でメモの登録処理を実装していきます。
1. 落ちるテストを書く
1class StoreActionTest extends TestCase
2{
3 use RefreshDatabase;
4 /*
5 * A basic feature test example.
6 */
7 public function testStoreSuccess(): void
8 {
9 $requestData = [
10 'title' => 'タイトル',
11 'content' => 'メモ内容',
12 ];
13 $url = route('api.memo.store');
14 $response = $this->post($url, $requestData);
15 $response->assertJson(['message' => '登録完了']);
16 // DBへの登録ができている
17 $this->assertDatabaseHas('memos', [
18 'title' => 'タイトル',
19 'content' => 'メモ内容',
20 ]);
21 }
22
23 public function testStoreFailed(): void
24 {
25 // 失敗条件
26 $requestData = [
27 'title' => '', // とりあえずタイトルを空白に
28 'content' => 'メモ内容',
29 ];
30
31 $url = route('api.memo.store');
32 $response = $this->post($url, $requestData);
33 $response->assertJson(['message' => '登録失敗']);
34 }
35}
testStoreSuccess()にて、memosテーブルに登録されているデータを検証しています。
このテストを実行すると以下になります。
1There was 1 failure:
2
31) Tests\Feature\Api\Memo\StoreActionTest::testStoreSuccess
4Failed asserting that a row in the table [memos] matches the attributes {
5 "title": "タイトル",
6 "content": "メモ内容"
7}.
8
9FAILURES!
10Tests: 2, Assertions: 3, Failures: 1.
保存処理を実装していないので、当然の結果になります。
2. DB登録されるように、仮実装する
テストが通るようにコントローラを修正します。
1class MemoController extends Controller
2{
3 /*
4 * Handle the incoming request.
5 *
6 * @param \Illuminate\Http\Request $request
7 *
8 * @return \Illuminate\Http\JsonResponse;
9 */
10 public function store(Request $request): JsonResponse
11 {
12 $validated = $request->validated();
13 Memo::create([
14 'title' => $validated['title'],
15 'content' => $validated['content'],
16 ]);
17 return response()->json([ 'message' => '登録完了', ]);
18 }
テストを実行します。
1OK (2 tests, 3 assertions)
無事通りました。
リファクタですが、やるとするならモデルを外に出すとかですかね...。
ケースが単純ゆえコードもシンプルなので、今回はスキップします。
感想
今回実装したのは簡単な処理でしたが、TDDでやろうとすると結構時間が掛かりました。これはシンプルに流れに慣れていないことが主な要因だと思うので、練習しようと思います。
また、テストを意識して実装する(というか強制的にそうなる)のが面白かったです。
自分で問題作ってそれを解いている感覚になりました。
1つ気になったことは、単純な機能ならTDDでやるイメージが湧くけど、大きな機能、フロントも絡む実装になってくると大変そうだなということです。
実際にTDDでガッツリ開発しているプロジェクトのお話も聞いてみたくなりました。
テストは割りと好きなので、TDDで開発できるようになったら楽しそうだなと思いました。
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
2022年7月に入社しました。開発未経験で未知の領域だらけですが、楽しく学びつつ、早く戦力になれるようにがんばります!