SwiftでAmazon S3のモックを用意して自動テストを実装する

# 概要
AWS SDK for Swiftに[Testing and debugging](https://docs.aws.amazon.com/sdk-for-swift/latest/developer-guide/using-testing-debugging.html)というセクションがあり、モックを使用した自動テストの方法が記載されています。
また、上記のドキュメント内で紹介されているコードは[Github](https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/swift/example_code/swift-sdk/mocking)上に公開されているので、こちらを確認するとより理解が深まります。

上記のコンテンツを参考にしてモックを使ったテストを実装したのでその方法についてメモを兼ねて記述します。

# 詳細

モックを用意して自動テストを実装する際の基本的な流れは以下の通りです。

**1. Protocolを作成し、Protocol内でモックを作成したい関数を指定する
2. 1で作成したProtocolに準拠したAmazon S3へのアクセスを実装するクラスと入力パラメータに基づいてモックされた結果を返却するクラスを作成する
3. モックを作成した関数を使用しているクラスの関数の設定を変更する
4. XCTestで自動テストのテストコードを記述する**


上から順に説明していきます。

### 1\.Protocolを作成し、Protocol内でモックを作成したい関数を指定する

Protocolを定義し、Protocol内でモックで使用する関数の定義を記述します。
Protocolはいわゆるインターフェースで、このプロトコルを使用したクラスではプロトコル内で定義された関数を実装する必要があります。

今回は以下のようなProtocolを実装します。

```swift
protocol S3SessionProtocol {
    func listObjectsV2(input: ListObjectsV2Input) async throws -> ListObjectsV2Output
}
```

この`S3SessionProtocol`というプロトコルを使用します。
仮にこのプロトコルを使用したクラスを作成する場合、Protocol内で定義されている`listObjectsV2`の定義がない場合、エラーになります。

なお`listObjectsV2`というのは[こちらのリンク](https://docs.aws.amazon.com/ja_jp/code-library/latest/ug/swift_1_s3_code_examples.html)内に存在する"バケット内のオブジェクトの一覧表示"する処理を行うサンプルコード内で登場するメソッドです。

### 2\. 1のProtocolに準拠したAmazon S3へのアクセスを実装するクラスと入力パラメータに基づいてモックされた結果を返却するクラスを作成する

1.で定義したProtocolを使用して、
- Amazon S3へのリクエストを実装するクラス(S3Session)
- 入力パラメータに基づいてモックのレスポンスを返却するクラス(MockS3Session)

を実装します。

```swift:S3Session.swift
public class S3Session: S3SessionProtocol {
    let client: S3Client
    let region: String

    init(region: String = "ap-northeast-1") throws {
        self.region = region
        self.client = try S3Client(region: self.region)
    }

    public func listObjectsV2(input: ListObjectsV2Input) async throws -> ListObjectsV2Output {
        return try await self.client.listObjectsV2(input: input)
    }
}
```

```swift:MockS3Session.swift
public class MockS3Session: S3SessionProtocol {
    var mockFiles: [String] = []

    init() {
        self.mockFiles = [
            // json
            "MockTest/S3_2024-02-06 19-01-32.json",
            "MockTest/S3_2024-02-07 20-01-32.json",
            "MockTest/S3_2024-02-08 21-01-32.json",
            "MockTest/S3_2024-02-06 19-01-32.json",
            "MockTest/S3_2024-02-07 20-01-32.json",
            "MockTest/S3_2024-02-08 21-01-32.json",
            "MockTest/S3_2024-02-06 19-01-32.jpg",
            "MockTest/S3_2024-02-07 20-01-32.jpg",
            "MockTest/S3_2024-02-08 21-01-32.jpg",
            "MockTest/S3_2024-02-06 19-01-32.jpg",
            "MockTest/S3_2024-02-07 20-01-32.jpg",
            "MockTest/S3_2024-02-08 21-01-32.jpg",
            "MockTest/S3_2024-02-06 19-01-32.mp4",
            "MockTest/S3_2024-02-07 20-01-32.mp4",
            "MockTest/S3_2024-02-08 21-01-32.mp4",
            "MockTest/S3_2024-02-06 19-01-32.mp4",
            "MockTest/S3_2024-02-07 20-01-32.mp4",
            "MockTest/S3_2024-02-08 21-01-32.mp4"
        ]
    }
    
    
    // 与えられたprefixでmockFiles内にあるデータの前方一致検索を行った後、該当するデータを返却する
    public func listObjectsV2(input: ListObjectsV2Input) async throws -> ListObjectsV2Output {
        var filteredFiles: [String] = []
        
        if let prefix = input.prefix {
            filteredFiles = self.mockFiles.filter { $0.hasPrefix(prefix) }
        } else {
            filteredFiles = self.mockFiles
        }
        
        let output = ListObjectsV2Output(contents: filteredFiles.map { S3ClientTypes.Object(key: $0, lastModified: Date()) })
        return output
    }
```

それぞれのクラスが`S3SessionProtocol`を使用しているため、それぞれのクラス内で`listObjectsV2`が定義されていることが確認できます。

S3Sessionではそのまま引数をS3Clientで定義されている関数にそのまま引数を渡している(元の関数を使用した時と同様の結果を返却するようにしている)のに対し、モックテスト用のクラスではクラス内に定義されている配列内から結果を返すようになっています。




### 3. モックを作成した関数を使用しているクラスの関数の設定を変更する

モックを作成した関数内を使用しているクラスは以下のようなものとします。

```diff_swift:AWSManager.swift
enum DataType: String {
    case image = "jpg"
    case json = "json"
    case mp4 = "mp4"
}


class AWSManager{
    
    var session: S3SessionProtocol
    
    init(session: S3SessionProtocol) {
        self.session = session
    }
    
    /// 指定されたバケットに含まれるファイルのリストを文字列の配列として返します。
    ///
    /// - Parameter bucket: ファイルリストを取得するバケットの名前。
    /// - Returns: バケット内に含まれる各ファイルの名前を示す`String`オブジェクトの配列。
    func listBucketFiles(bucket: String, prefix: String? = nil, fileExtension: String? = nil)
    async throws -> [String]
    {
        let input = ListObjectsV2Input(
            bucket: bucket,
            prefix: prefix
        )
        
        let output = try await session.listObjectsV2(input: input)
        var fileNames: [String] = []
        
        guard let objList = output.contents else {
            return []
        }
        
        for obj in objList {
            if let objName = obj.key {
                // 拡張子が指定されている場合は、その拡張子を持つファイルのみを追加する
                if let fileExtension = fileExtension, objName.hasSuffix(".\(fileExtension)") {
                    fileNames.append(objName)
                }
                // 拡張子が指定されていない場合は、すべてのファイルを追加する
                else if fileExtension == nil {
                    fileNames.append(objName)
                }
            }
        }
        
        return fileNames
    }
}

```

このクラスで説明する箇所は以下の部分です。

```swift:AWSManager.swift
    var session: S3SessionProtocol
    
    init(session: S3SessionProtocol) {
        self.session = session
    }
```

この箇所ではクラスをインスタンス化するときに呼び出し元から`session`情報を渡すようにコードを記述しています。
このコード内ではS3にリクエストする場合には`S3Session`を、自動テストを行う場合には`MockS3Session`を呼び出し元から渡すことでその切り替えが行えるようにしています。



### 4. XCTestで自動テストのテストコードを記述する

ここまできたら後はXCTestでテストコードを記述するのみです。

```swift:AWSManagerTests.swift
import XCTest
import AWSS3
import Foundation
import ClientRuntime
@testable import "ProjectName"

final class AWSManagerTests: XCTestCase {
    
    
    var bucket:String = ""
    var prefix:String = ""
    var session: MockS3Session!
    var awsManager: AWSManager!
    
    override func setUp() {
        super.setUp()
        session = MockS3Session()
        self.awsManager = AWSManager(session: self.session)
        self.prefix = "MockTest"
    }
    
    override func tearDown() {
        awsManager = nil
        super.tearDown()
    }
    
    
    
    /// listBucketFilesでMockS3SessionのmockFiles内を検索した結果、データが全件の場合のテストをする
    func testFunctionalListBucketFilesSearchResultAll() async throws {
        // Arrange:MockS3SessionのmockFiles全てのデータをsearchResultに追加する
        let searchResult = [
            "MockTest/S3_2024-02-06 19-01-32.json",
            "MockTest/S3_2024-02-07 20-01-32.json",
            "MockTest/S3_2024-02-08 21-01-32.json",
            "MockTest/S3_2024-02-06 19-01-32.json",
            "MockTest/S3_2024-02-07 20-01-32.json",
            "MockTest/S3_2024-02-08 21-01-32.json",
            "MockTest/S3_2024-02-06 19-01-32.jpg",
            "MockTest/S3_2024-02-07 20-01-32.jpg",
            "MockTest/S3_2024-02-08 21-01-32.jpg",
            "MockTest/S3_2024-02-06 19-01-32.jpg",
            "MockTest/S3_2024-02-07 20-01-32.jpg",
            "MockTest/S3_2024-02-08 21-01-32.jpg",
            "MockTest/S3_2024-02-06 19-01-32.mp4",
            "MockTest/S3_2024-02-07 20-01-32.mp4",
            "MockTest/S3_2024-02-08 21-01-32.mp4",
            "MockTest/S3_2024-02-06 19-01-32.mp4",
            "MockTest/S3_2024-02-07 20-01-32.mp4",
            "MockTest/S3_2024-02-08 21-01-32.mp4"
        ]
        
        // Act
        await awsManager.listBucketFiles(bucket: bucket, prefix: prefix)
        
        // Assert
        XCTAssertEqual(awsManager.fileNameArray,searchResult)
    }
    
    /// listBucketFilesでMockS3SessionのmockFiles内を検索した結果、データが0件の場合のテストをする
    func testFunctionalListBucketFilesSearchResultZero() async throws {
        
        // Arrange:何も存在しない場合を想定した結果をsearchResultに代入する.部分一致
        self.prefix = "TestMock/S3"
        let searchResult:[String] = []
        
        // Act
        await awsManager.listBucketFiles(bucket: bucket, prefix: prefix)
        
        // Assert
        XCTAssertEqual(awsManager.fileNameArray,searchResult)
        
        
        // Arrange:何も存在しない場合を想定した結果をsearchResultに代入する.後方一致の場合のテストのため、.jsonより前の文字列に対して検索を行う
        self.prefix = "22-01-32"
        
        // Act
        await awsManager.listBucketFiles(bucket: bucket, prefix: prefix)
        
        // Assert
        XCTAssertEqual(awsManager.fileNameArray,searchResult)
    }
    
    
    /// listBucketFilesでMockS3Sessionに定義されているmockFilesの内、fileExtensionをそれぞれ指定した場合の返り値が正しく返却されることをテスト
    func testBoundaryListBucketFilesWithFileExtension() async throws {
        // Arrange:MockS3SessionのmockFilesの内、jsonの場合に該当するケースを定義する。
        var searchResult = [
            "MockTest/S3_2024-02-06 19-01-32.json",
            "MockTest/S3_2024-02-07 20-01-32.json",
            "MockTest/S3_2024-02-08 21-01-32.json",
            "MockTest/S3_2024-02-06 19-01-32.json",
            "MockTest/S3_2024-02-07 20-01-32.json",
            "MockTest/S3_2024-02-08 21-01-32.json"
        ]
        
        // Act
        await awsManager.listBucketFiles(bucket: bucket, prefix: prefix,fileExtension:DataType.json.rawValue)
        
        // Assert
        XCTAssertEqual(awsManager.fileNameArray,searchResult)
        
        
        
        // Arrange:MockS3SessionのmockFilesの内、拡張子がjpgの場合に該当するケースを定義する。
        mockFiles = [
            "MockTest/S3_2024-02-06 19-01-32.jpg",
            "MockTest/S3_2024-02-07 20-01-32.jpg",
            "MockTest/S3_2024-02-08 21-01-32.jpg",
            "MockTest/S3_2024-02-06 19-01-32.jpg",
            "MockTest/S3_2024-02-07 20-01-32.jpg",
            "MockTest/S3_2024-02-08 21-01-32.jpg"
        ]
        
        awsManager.fileNameArray = []
        
        // Act
        await awsManager.listBucketFiles(bucket: bucket, prefix: prefix,fileExtension:DataType.image.rawValue)
        
        // Assert
        XCTAssertEqual(awsManager.fileNameArray,searchResult)
        
        
        // Arrange:MockS3SessionのmockFilesの内、拡張子がmp4の場合に該当するケースを定義する。
        searchResult = [
            "MockTest/S3_2024-02-06 19-01-32.mp4",
            "MockTest/S3_2024-02-07 20-01-32.mp4",
            "MockTest/S3_2024-02-08 21-01-32.mp4",
            "MockTest/S3_2024-02-06 19-01-32.mp4",
            "MockTest/S3_2024-02-07 20-01-32.mp4",
            "MockTest/S3_2024-02-08 21-01-32.mp4"
        ]
        
        awsManager.fileNameArray = []
        
        // Act
        await awsManager.listBucketFiles(bucket: bucket, prefix: prefix,fileExtension:DataType.mp4.rawValue)
        
        // Assert
        XCTAssertEqual(awsManager.fileNameArray,searchResult)
    }    
```

このファイルではサンプルとして

- listBucketFilesでMockS3SessionのmockFiles内を検索した結果、データが全件の場合のテスト
- listBucketFilesでMockS3SessionのmockFiles内を検索した結果、データが0件の場合のテスト
- listBucketFilesでMockS3Sessionに定義されているmockFilesの内、fileExtensionをそれぞれ指定した場合のテスト

を記述しています。

今回、初めてSwiftでS3のモックテストを実装しましたが、ドキュメントが充実している分、実装が容易でした。
単純なモックテストに限らず、他にも使い道がありそうなので探してみようと思います。