Laravelのテストコードを書いたらDIが少し見えてきた話
IT技術
はじめに
こんにちは!
今年の10月にライトコードに入社したあらいと申します。
皆さん、DIについてしっかり理解していますでしょうか。
私個人的なDIの使用経験としては、過去にJava(Spring Boot)を使用していた現場でのものです。そこでは、お作法のようにDIをコンストラクタに記述したり、テスト時にモックを用いたりしていたため、DIの深いメリットや、それを使用しないことのデメリットを深く考えることなく利用していました。また、DIではないコードによる具体的な困難を経験したことがなかったため、DIに対する理解は曖昧なものでした。
ライトコードに入社してからは、初めてLaravelを用いた開発に携わる機会があり、特にテストが不十分な機能のテストコードの実装に取り組む機会がありました。
その際に「あれ、これテスト書きにくいな...」と思った事があり、そこでDIを使ってみたところ、テストが格段にやりやすくなることに気づきました。
この経験を通じて、DIのメリットが少しずつ見えてきました。
本記事では、DIのメリットを実感するまでの過程や、その経験についてお話ししていきたいと思います。
DIとは?
今回の対応にあたり、DI(依存性注入)の理解を深めるために勉強しました。そこで得た知識を、簡単に解説できればと思います。
DIを一言で表すなら「疎結合を目的としたデザインパターン」のことです。
疎結合とは、コードの各部分が互いに独立していて、他の部分への依存が少ない状態を指します。DIでは、オブジェクトの依存関係をコードの外部から供給することで、この疎結合を実現しています。
「依存性の注入」という用語は、一見抽象的で理解しにくいかもしれませんが、実際には「依存しているオブジェクトを外部から供給する」と考えていただければ少しわかりやすいかと思います。
DIの概要について触れたところで、次に今回対応を行うことになった機能の現状のサンプルコードを見ていきます。
現状のコード
具体的な業務コードを示すことはできませんが、代わりにGoogle Cloud Storage(GCS)へのアップロード処理を例に挙げます。この処理では、データベースからデータを取得し、それを加工した後にGCSにアップロードするといったものです。
1<?php
2
3namespace App\Console\Commands;
4
5use Illuminate\Console\Command;
6use Google\Cloud\Storage\StorageClient;
7
8class XxxxFileCommand extends Command
9{
10 protected $signature = 'file:xxxx';
11 protected $description = 'Process and upload a file to Google Cloud Storage';
12
13 public function __construct()
14 {
15 parent::__construct();
16 }
17
18 public function handle()
19 {
20 // GCSのStorageClientをインスタンス化
21 $storageClient = new StorageClient();
22
23 // バケットを取得
24 $bucket = $storageClient->bucket('your-bucket-name');
25
26 // DBからデータを取得する処理
27 // 省略...
28
29 // アップロード対象のファイルの加工処理
30 $processedFile = 'processedFile' // 仮
31
32 // ファイルをGCSにアップロード
33 $bucket->upload(
34 fopen($processedFile, 'r'),
35 [
36 'name' => 'output_file_name'
37 ]
38 );
39
40 $this->info('File has been processed and uploaded to Google Cloud Storage.');
41 }
42}
このコードのままテストを実装しようとしたときの課題
テストコードの実装にあたり、私は特に一つの大きな問題に直面しました。それは、テスト実行時にGoogle Cloud Storage(GCS)への実際のアップロードを防ぎたいということです。
現在のコードでは、テストを実行する度に、StorageClient
がインスタンス化され、ファイルがGCSにアップロードされるリスクがあります。これは、環境変数を使ってアップロードを制御しているものの、誤った設定やその他のエラーにより、テストが失敗する可能性があります。
さらに、アップロードするファイルの内容を確認するテストをどのように書けば良いかという課題もありました。
テストで重要なのは、ファイルが正しく加工され、適切な内容でアップロードされているかを確認することです。しかし、現状のコードでは、アップロード処理が密結合しており、これを分離してテストしやすくする方法を模索する必要がありました。
このような問題点を踏まえ、テストコードを書きやすくするためのリファクタリングが必要となりました。次のセクションでは、どのようにコードを修正し、テストの容易性を高めたかについて詳しく説明します。
コードをリファクタリング
問題の解決に向けて、私はまずコードのリファクタリングに着手しました。
目標は、テストの際に外部サービスへの実際のアップロードを避けることと、アップロードプロセスのテストを容易にすることです。以下にそのプロセスを詳しく説明します。
GCP関連クラスのコンストラクタインジェクション
まず、StorageClient
のインスタンス化をコンストラクタ内で行わず、DIの形となるように変更しました。これにより、テスト時にStorageClient
をモックに置き換えることができるようになります。
モックとは、実際のオブジェクトやサービスを模倣したオブジェクトを作成し、テスト中に使用する手法のことです。
1public function __construct(StorageClient $storageClient)
2{
3 $this->storageClient = $storageClient;
4 parent::__construct();
5}
アップロード処理の部品化
次に、ファイルアップロードの処理を別のクラスに分離しました。この変更により、アップロードロジックを独立させ、テストがより簡単になります。新しいクラスUploadService
は、アップロードに必要なすべての機能を担います。
この新しいクラスは、ファイルのアップロードのを責務に持ち、必要なデータを引数として受け取るようになっています。
1class UploadService
2{
3
4 public function upload($file, $bucket)
5 {
6 // アップロード処理...
7 }
8}
コマンドクラスの修正
最後に、XxxxFileCommand
クラスを修正して新しいUploadService
クラスを使用しました。これにより、コマンドクラスの責務を縮小し、テストのしやすさを大幅に向上させました。
1class XxxxFileCommand extends Command
2{
3 // 省略...
4
5 private $storageClient;
6 private $uploadService;
7
8 public function __construct(
9 StorageClient $storageClient,
10 UploadService $uploadService
11 ) {
12 $this->storageClient = $storageClient;
13 $this->uploadService = $uploadService;
14 parent::__construct();
15 }
16
17 public function handle()
18 {
19 // バケットを取得
20 $bucket = $this->storageClient->bucket('your-bucket-name');
21
22 // 省略...
23
24 $this->uploadService->upload($processedFile, $bucket);
25
26 // 省略...
27 }
28}
このリファクタリングによって、テストコードの作成が格段に容易になりました。次のセクションでは、リファクタリング後のコードでテストコードをどのように書いたかについて説明します。
リファクタリング後のテストコードの作成
リファクタリングの完了後、私はテストコードの作成に取り掛かりました。リファクタリングのおかげで、テスト作成の際のいくつかの課題が解決され、テストコードを書く過程が格段にスムーズになりました。
モックオブジェクトの準備と設定
テストのゴールとしては、外部サービスへの実際のアップロードを回避しつつ、コードの挙動を確認することです。
これを実現するために以下のように、Bucket
オブジェクトのモックを返すように設定したStorageClient
と、UploadService
のモックオブジェクトを作成して、Laravelのサービスコンテナにインスタンスを登録しました。
各コードについては、後ほど記載します。
1class XxxxFileCommandTest extends TestCase
2{
3 private MockInterface $uploadMock;
4 private XxxxFileCommand $xxxxFileCommand;
5
6 protected function setUp(): void
7 {
8 // Mock作成
9 $storageMock = Mockery::mock(StorageClient::class);
10 $bucketMock = Mockery::mock(Bucket::class);
11 $bucketMock->shouldReceive('upload');
12 $storageMock->shouldReceive('bucket')->andReturn($bucketMock);
13 $this->uploadMock = Mockery::mock(UploadService::class);
14
15 // Laravel設定
16 parent::setUp();
17 $this->app->instance(StorageClient::class, $storageMock);
18 $this->app->instance(UploadService::class, $this->uploadMock);
19 $this->xxxxFileCommand = $this->app->make(XxxxFileCommand::class);
20 }
21
22 // 省略...
23}
各コードの説明
Mockery::mock()
Mockery::mock()
メソッドは、指定されたクラスのモックオブジェクトを作成します。今回のテストでは、StorageClient
とBucket
クラスのモックオブジェクトを生成するために使用されています。これにより、実際のStorageClient
やBucket
インスタンスを使用せずに、これらのクラスの挙動を模倣することが可能になります。
shouldReceive()->andReturn()
shouldReceive()->andReturn()
は、モックオブジェクトが特定のメソッドを受け取った際の挙動を定義します。今回のテストでは、Bucket
クラスのモックに対してupload
メソッドが呼ばれることを期待し、StorageClient
のモックに対してbucket
メソッドが呼ばれた際にBucket
のモックを返すように設定されています。これは、外部への実際のアップロードを行わずに、アップロードメソッドの呼び出しをシミュレートするために重要です。
app->instance()
app->instance()
メソッドを使用することで、Laravelのサービスコンテナに特定のインスタンスを登録できます。今回のテストでは、StorageClient
とUploadService
のモックをサービスコンテナに登録しています。これにより、テスト中にこれらのクラスが要求される場合には、実際のインスタンスの代わりにモックが使用されるようになります。これはテストの際に外部依存を減らし、テストの制御を強化するために不可欠です。
app->make()
app->make()
メソッドは、Laravelのサービスコンテナから指定されたクラスのインスタンスを生成します。今回のテストでは、XxxxFileCommand
クラスのインスタンスを生成する際に使用されています。Laravelの設定をもとに、必要なモックオブジェクトがコンストラクタ経由でXxxxFileCommand
に注入され、実際のサービスへの影響を排除しつつ、コマンドの挙動をテストすることが可能になります。
テストメソッドの作成
以下のテストメソッドでは、アップロードするファイルの内容が正しく作成されているかを検証しています。
これは、モック化されたUploadService
のuploadメソッドに対して、正しい引数が渡されているかを検証することで、ファイルが正しくアップロードプロセスを通過していることを確認できます。このようにモックを活用することで、外部サービスへの依存なしに、アップロードロジックの整合性を効果的にテストすることができるのです。
1public function test_fileCommand(): void
2{
3 // 期待値の設定
4 $expected = /* ここに期待するファイルの内容を設定 */
5
6 // UploadServiceのモックが期待する挙動を設定
7 $this->uploadMock->shouldReceive('upload')
8 ->with($expected, $bucketMock)
9 ->once();
10
11 // コマンドを実行し、終了コードを検証
12 $this->artisan('file:xxxx')->assertExitCode(Command::SUCCESS);
13}
まとめ
DIになっていないコードからDIになるように修正してテストを作成するというプロセスを経験することができたことによって、DIのメリットを体感することができました。
今までなんとなく使用してきたDIですが、DIを使わないことによる困難を実際に経験することで、その理解は一気に深まりました。DIは、保守性や変更の容易性を高めるだけでなく、テストのしやすさという点でも大きな利益をもたらします。特に、モックオブジェクトを活用することで、外部サービスへの依存を減らし、テストをより簡単かつ効率的に行うことが可能になります。
ぜひ皆さんも、DIを取り入れて、コードの品質向上とメンテナンスの効率化を実現してみてください。
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ