PHPでAWS SDKのテストをMockする

mock-aws-sdk-in-php
  • URLをコピーしました!

こんにちは。バックエンドエンジニアの筒井(@tsuttsun_wind)です。今回は、PHPでAWS SDKのテストをMockする方法について紹介します。

目次

背景

現在、PR TIMESでアップロードされる画像はAWS S3(以降S3)に格納されています。

これまでに実装されたテストコードでは、開発環境のS3に直接接続しているため、不必要なデータ書き込みやコストが発生するという問題を抱えています。

AWSなどの外部環境に直接接続してテストすることは上記の問題で好ましくないことや、外部サービスがメンテナンスなどで停止している場合にテストが落ちてしまうため避ける必要があります。

この問題は、Mockを利用することにより解決が可能です。

Mockとは

Mockは、実際の外部サービスの代わりに動作を模倣するオブジェクトです。テスト対象のコードから外部依存性を切り離すことで、テストの独立性・高速化・安定性を実現します。

Mockの役割

1. 依存関係の分離

  • テスト対象のコードが外部サービスに依存せず、独立してテストできるようにするため、外部サービスの振る舞いを模倣することが可能

2. テスト高速化

  • 実際の通信を省略することで、テスト実行時間を短縮可能

3. 安定性と再現性の確保

  • 外部サービスのレスポンスやエラーなどを自分でコントロールできるため、特定の状況下の挙動をシミュレートすることでテスト結果の再現性を高めることが可能

PHPでは、Mockeryというライブラリを利用することで、簡単にMockを生成してAWS SDKの動作を模倣可能です。このライブラリは非常に機能が豊富で柔軟性が高く、多様なテストシナリオにフィットします。

もちろん、PHPUnitでもMockは可能ですが、Mockeryのほうが機能が豊富で使いやすいため、今回はこちらを採用しました。

AWS SDKのS3ClientをMockしてテストする

ここでは、S3Clientを対象にMockを利用したテストの手法について説明します。

このテストコードはS3ClientのMockを利用するため、 S3と通信せずにテストを実行可能です。

今回は、S3にファイルをアップロードする処理を実装したコードであるS3Functionsクラスを例に、テスト方法を紹介します。

このクラスはS3ClientをWrapしたライブラリで、実行結果に応じて成功または失敗のレスポンスを返しています。

具体的には、S3InterfaceでS3への操作を定義し、S3ResultInterfaceで結果を抽象化しています。成功時にはS3ResultSuccess、失敗時にはcatchしたAwsExceptionからエラーメッセージや例外オブジェクトを保持するS3ResultErrorを返します。

S3Functionsクラスでは、内部で S3Client を利用してファイルのアップロードを行い、アップロード処理が正常に完了すればS3ResultSuccessを、例外が発生した場合はS3ResultErrorを返す実装となっています。

<?php
declare(strict_types=1);

namespace ApplicationName\ApplicationFunc\ManageFile\Util\Aws\S3;

use Aws\Exception\AwsException;
use Aws\S3\S3Client;

// S3ClientのInterface
interface S3Interface
{
    public function put(string $bucket, string $fileKey, string $contentType): S3ResultInterface;
}

// S3の実行結果を返すInterface
interface S3ResultInterface
{
}

// S3で実行に成功した際に返すクラス
class S3ResultSuccess implements S3ResultInterface
{
}

// S3で実行に失敗した際に返すクラス
class S3ResultError implements S3ResultInterface
{
    private string $message;
    private Exception $exception;

    public function __construct(string $message, Exception $exception)
    {
        $this->message = $message;
        $this->exception = $exception;
    }

    public function getMessage(): string
    {
        return $this->message;
    }

    public function getException(): Exception
    {
        return $this->exception;
    }
}

class S3Functions implements S3Interface
{
    private S3Client $s3Client;

    public function __construct(S3Client $s3Client)
    {
        $this->s3Client = $s3Client;
    }

    /**
     * @param string $bucket
     * @param string $fileKey
     * @param string $sourcePath
     * @param array $options
     * @return S3ResultInterface
     */
    public function put(string $bucket, string $fileKey, string $contentType, array $options = []): S3ResultInterface
    {
        try {
		    // S3にファイルを配置
            $this->s3Client->putObject(array_merge([
                'Bucket' => $bucket,
                'Key' => $fileKey,
                'ContentType' => $contentType
            ], $options));
        } catch (AwsException $e) {
		    // 失敗として返却する
            return new S3ResultError($e->getMessage(), $e);
        }
        // 成功として返却する
        return new S3ResultSuccess();
    }
}

テストクラスの作成

S3Functionsのテストコードを準備します。

<?php
/** @noinspection NonAsciiCharacters */
declare(strict_types=1);

namespace ApplicationName\ApplicationFunc\Tests\ManageFile\Aws\S3FunctionsTest;

use \PHPUnit\Framework\TestCase;

class S3FunctionsTest extends TestCase 
{
	public function test_S3FunctionsのPutテスト(){
		
	}
}

S3ClientのMockを作成する

Mockeryの Mockery::mock を利用して、S3ClientのMockオブジェクトを作成します。

このMockオブジェクトをS3Functionsのconstructorに入れることで、AWSに依存しない、S3Clientを模倣したテストを行う準備が可能になります。

...
use Aws\S3\S3Client;
use Mockery;
use ApplicationName\ApplicationFunc\ManageFile\Util\Aws\S3\S3Functions;

class S3FunctionsTest extends TestCase 
{
	public function test_S3FunctionsのPutテスト(){
	    // S3ClientをMockして、Mockした変数を渡す
		$awsSdkS3ClientMock = Mockery::mock(S3Client::class);
		$s3FunctionsMock = new S3Functions($awsSdkS3ClientMock);
	}
}

Mockしたい関数と期待される返却値を設定する

S3のMockオブジェクトの作成後、 Mockオブジェクトの挙動をあらかじめ指定する必要があります。

そのため、shouldReceiveでテストしたい具体的な関数を設定し、andReturnで関数の返り値を設定します。

例外のAwsExceptionが発生した場合は、andReturnではなくandThrowを設定する必要があります。

このとき、AwsExceptionは第2引数にAws\CommandInterfaceを指定する必要があるため、CommandInterfaceも合わせてMockする必要があります。

...
use Aws\CommandInterface;
use Aws\Exception\AwsException;
use ApplicationName\ApplicationFunc\ManageFile\Util\Aws\S3\S3Functions;
use ApplicationName\ApplicationFunc\ManageFile\Util\Aws\S3\S3ResultError;
use ApplicationName\ApplicationFunc\ManageFile\Util\Aws\S3\S3ResultSuccess;

class S3FunctionsTest extends TestCase 
{
	public function test_S3FunctionsのPutテスト(){
	  ...
	  // 正常系
	  // $awsSdkS3ClientMock->shouldReceive('putObject')->andReturn(new S3ResultSuccess());
	  
	  
	  // 異常系
	  $awsSdkS3ClientMock->shouldReceive('putObject')->andReturn(new S3ResultError());
	  
	  // AwsExceptionはthrowにCommandInterfaceが必要なため、これもMockする必要がある
	  $awsS3CommandInterfaceMock = Mockery::mock(CommandInterface::class);
      $awsS3CommandInterfaceMock->shouldReceive('getName')->andReturn('putObject');
      $awsSdkS3ClientMock->shouldReceive('putObject')->andThrow(new AwsException('putObject failed', $s3CommandInterfaceMock));
	}
}

S3ClientをWrapしたS3FunctionsというライブラリではInterfaceを利用しているため、S3の処理結果のInterfaceを返り値で指定していますが、S3Clientは基本的にAws\ResultというObject型で返却されます。

あわせて読みたい

そのレスポンスを再現するためにResult型でテストしたい場合は以下のように書く必要があります。

...
use Aws\Result;
use ApplicationName\ApplicationFunc\ManageFile\Util\Aws\S3\S3Functions;

class S3FunctionsTest extends TestCase 
{
	public function test_S3FunctionsのPutテスト(){
	  ...
	  // 正常系
	  $awsSdkS3ClientMock->shouldReceive('putObject')->andReturn(
	      new Result([
			  'ETag' => '"xxxxxxxx"', 
		  ])
	  );
	}
}

S3の関数に引数をセットする

テストでは、S3Functionsput メソッドを呼び出すために必要な引数を用意します。

引数には、putObject に必要な3つ(Bucket, Key, ContentType)を指定することを前提としています。

public function test_S3FunctionsのPutテスト(){
	  ...
	  $putResult = $s3FunctionsMock->put($bucket, $fileKey, $contentType);
}

次に、動作させた結果が正常系であるか確認します。

ベースとなるコードでは、処理結果としてS3ResultInterfaceを返す設計になっています。

そのため、返却値が正常である場合はS3ResultSuccessのインスタンスが返されることを確認します。

public function test_S3FunctionsのPutテスト(){
	  ...
	  // 正常系
	  $this->assertInstanceOf(S3ResultSuccess::class, $putResult);
}

もし、Result型の結果をテストしたい場合は以下のように書くことでテスト可能です。

public function test_S3FunctionsのPutテスト(){
	  ...
	  // 正常系
	  $this->assertInstanceOf(Result::class, $putResult);
	  $this->assertEquals('"xxxxxxxx"', $result['ETag']);
}

テストサンプル

以下は、完成したテストコードのサンプルです。

<?php
/** @noinspection NonAsciiCharacters */
declare(strict_types=1);

namespace ApplicationName\\ApplicationFunc\\Tests\\ManageFile\\Aws\\S3FunctionsTest;

use Aws\CommandInterface;
use Aws\Exception\AwsException;
use Aws\S3\S3Client;
use ApplicationName\ApplicationFunc\ManageFile\Util\Aws\S3\S3Functions;
use ApplicationName\ApplicationFunc\ManageFile\Util\Aws\S3\S3ResultError;
use ApplicationName\ApplicationFunc\ManageFile\Util\Aws\S3\S3ResultSuccess;
use Mockery;
use \PHPUnit\Framework\TestCase;

class S3FunctionsTest extends TestCase
{
    public function data_S3Functionsのputテスト(): array {
        return [
            '成功のレスポンスが返る場合' => [
                'mock-test-bucket',       // バケット名
                'found_image.jpg',        // ファイル名(Key)
                'image/jpeg',             // ContentType
                S3ResultSuccess::class,   // 期待される結果のクラス
                true                      // ファイルアップロードの可否
            ],
            '失敗のレスポンスが返る場合' => [
                'mock-test-bucket',
                'not_found_i.jpg',
                'image/jpeg',
                S3ResultError::class,
                false
            ],
        ];
    }

    /**
     * @dataProvider data_S3Functionsのputテスト
     */
    public function test_S3Functionsのputテスト(
        string $bucket,
        string $srcKey,
        string $contentType,
        string $expectedResult,
        bool   $canPut
    ){
        // S3ClientのMockを生成
        $awsSdkS3ClientMock = Mockery::mock(S3Client::class);
        
        // ファイルがアップロード可能な場合とそうでない場合の作成するMockオブジェクトを分ける
        if($canPut) {
	        $awsSdkS3ClientMock->shouldReceive('putObject')->andReturn(new S3ResultSuccess());
        } else {
	        $s3CommandInterfaceMock = Mockery::mock(CommandInterface::class);
            $s3CommandInterfaceMock->shouldReceive('getName')->andReturn('putObject');

            $awsSdkS3ClientMock->shouldReceive('putObject')
	            ->andThrow(new AwsException('putObject failed', $s3CommandInterfaceMock));
        }
        
        // MockされたS3ClientをconstructorとしてS3Functionsに入れる
        $s3FunctionsMock = new S3Functions($awsSdkS3ClientMock);

        // テスト対象のS3Functionsのputメソッドを実行
        $putResult = $s3FunctionsMock->put($bucket, $srcKey, $contentType);

        // 戻り値が期待されたクラスのインスタンスであることを検証
        $this->assertInstanceOf($expectedResult, $putResult);
    }
}

例外発生のテスト

テストでは、Mockを使用している場合でも使用していない場合でも、想定した例外が正しくthrowされるかを確認したいというケースもあります。

例えば、S3へのファイルアップロード処理では、アップロード前にファイル検証を行い、問題がある場合にS3FileFormatExceptionなどの例外をthrowする実装とするケースが考えられます。

そのため、Mockを利用したコードでも、実際のコードでも、期待通りに例外が発生するかをテストすることが重要です。

PHPUnitでは、expectExceptionメソッドを用いることで、事前に特定の例外が発生することを指定し、その例外が実際にthrowされるかを検証できます。

これにより、Mockを使った場合も、そうでない場合も、一貫して例外処理が正しく実装されていることを確認できます。

...
use Aws\S3\S3Client;
use Mockery;
use ApplicationName\ApplicationFunc\ManageFile\Exception\S3FileFormatException;
use ApplicationName\ApplicationFunc\ManageFile\Util\Aws\S3\S3Functions;

class S3FunctionsServiceTest extends TestCase 
{
	public function test_S3FunctionsServiceのPutテスト_ファイル検証で例外が発生(){
	    // ファイルの検証に失敗した例外を想定
	    $this->expectedException(S3FileFormatException::class);
	  
	    // ファイル検証
	    FileService::formatUploadFile();
	    ...
		  
	    // S3にファイルを配置 ここはMockを利用している
	    $putResult = $s3FunctionsMock(
		    ...
	    );
	}
}

まとめ

今回は、PHPでAWS SDKのテストをMockする方法について紹介しました。

MockeryやS3Clientを使うのが初めてで、テスト作成にかなり時間をかけてしまいましたが、AWS SDKの使い方を含め、外部SDKを利用したテストコード実装の理解が深まりました。

Mockを採用したことにより、新規でS3の関数を利用する際のテストや、既存コードのMock置換が容易になり、効率的かつ安全にテストが行えるようになりました。

また、Interfaceも今回作成したものであり、クリーンな設計を考える機会になったため、今後とも活用していきたいと思っています。

  • URLをコピーしました!

この記事を書いた人

2024年入社のバックエンドエンジニアです。

目次