1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

SwiftでAPIクライアントのユニットテストを記述する

Posted at

概要

APIクライアントのテストを書きます。
APIにはQiitaのAPIを使用します。
環境にはPlaygroundを使用しました。

環境

Xcode 13.1

手順

  1. APIクライアントを準備する
  2. テスト書く

APIクライアントを準備する

今回の記事の目的から逸れるため、詳しくは解説しません。
コードは以下です。

Playground
import Foundation

enum SessionError: Error {
    case noData
    case noResponse
    case unAcceptableStatusCode(Int)
}

enum HttpMethod: String {
    case `get` = "GET"
    case post = "POST"
}

enum Result<T> {
    case success(T)
    case failure(Error)
}

protocol Requestable {
    associatedtype Response: Decodable
    var baseURL: URL { get }
    var path: String { get }
    var httpMethod: HttpMethod { get }
}

final class Session {
    func send<T: Requestable>(_ request: T, completion: @escaping (Result<T.Response>) -> ()) {
        let url = request.baseURL.appendingPathComponent(request.path)
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = request.httpMethod.rawValue
        
        let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            
            guard let response = response as? HTTPURLResponse else {
                completion(.failure(SessionError.noResponse))
                return
            }
            
            guard 200..<300 ~= response.statusCode else {
                completion(.failure(SessionError.unAcceptableStatusCode(response.statusCode)))
                return
            }
            
            guard let data = data else {
                completion(.failure(SessionError.noData))
                return
            }
            
            do {
                let objects = try JSONDecoder().decode(T.Response.self, from: data)
                completion(.success(objects))
            } catch {
                completion(.failure(error))
            }
        }
        
        task.resume()
    }
}

struct Article: Codable {
    let title: String
}

struct ArticlesRequest: Requestable {
    typealias Response = [Article]
    var baseURL: URL = URL(string: "https://qiita.com")!
    var path: String = "/api/v2/items"
    var httpMethod: HttpMethod = .get
}

let session = Session()
let request = ArticlesRequest()

session.send(request) { result in
    switch result {
    case .success(let articles):
        print("[debug] \(articles.count)") // => [debug] 20
    case .failure(let error):
        print("[debug] \(error.localizedDescription)")
    }
}

ユニットテストを記述する

コードは以下です。

Playground
import XCTest
...
final class PlaygroundTests: XCTestCase {
    func testGetArticles() {
        let session = Session()
        let request = ArticlesRequest()
        let expectation = expectation(description: "")
        
        session.send(request) { result in
            switch result {
            case .success(let articles):
                XCTAssertNotNil(articles.first)
                expectation.fulfill()
            case .failure(let error):
                print(error)
            }
        }
        
        wait(for: [expectation], timeout: 5)
    }
}
PlaygroundTests.defaultTestSuite.run()

出力は以下のようになると思います。

Test Case '-[__lldb_expr_13.PlaygroundTests testGetArticles]' started.
Test Case '-[__lldb_expr_13.PlaygroundTests testGetArticles]' passed (1.482 seconds).
Test Suite 'PlaygroundTests' passed at 2021-12-07 07:03:11.673.
	 Executed 1 test, with 0 failures (0 unexpected) in 1.482 (1.485) seconds

非同期テストの注意点

非同期処理のテストを書くときに注目すべきは expectation です。
expectationを使わずにテストを書いた場合、処理が完了する前にテストコードが終わってしまいます。
そのため、失敗して欲しいテストが成功してしまいます。

もう一度、テストコードを確認しましょう。

コードの最初にexpectationを生成しています。
testGetArticles()の最後でexpectationが完了するまでwaitしています。(timeoutを5秒に設定しています。)

XCTAssertNotNil(articles.first)でレスポンスが取得できたことを確認できてから、
expectationfulfillします。
これでexpectationが完了したことを通知します。

Playground
final class PlaygroundTests: XCTestCase {
    func testGetArticles() {
        let session = Session()
        let request = ArticlesRequest()
        let expectation = expectation(description: "")
        
        session.send(request) { result in
            switch result {
            case .success(let articles):
                XCTAssertNotNil(articles.first)
                expectation.fulfill()
            case .failure(let error):
                print(error)
            }
        }
        
        wait(for: [expectation], timeout: 5)
    }
}
PlaygroundTests.defaultTestSuite.run()

全体のコードです。

Playground
import Foundation
import XCTest

enum SessionError: Error {
    case noData
    case noResponse
    case unAcceptableStatusCode(Int)
}

enum HttpMethod: String {
    case `get` = "GET"
    case post = "POST"
}

enum Result<T> {
    case success(T)
    case failure(Error)
}

protocol Requestable {
    associatedtype Response: Decodable
    var baseURL: URL { get }
    var path: String { get }
    var httpMethod: HttpMethod { get }
}

final class Session {
    func send<T: Requestable>(_ request: T, completion: @escaping (Result<T.Response>) -> ()) {
        let url = request.baseURL.appendingPathComponent(request.path)
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = request.httpMethod.rawValue
        
        let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            
            guard let response = response as? HTTPURLResponse else {
                completion(.failure(SessionError.noResponse))
                return
            }
            
            guard 200..<300 ~= response.statusCode else {
                completion(.failure(SessionError.unAcceptableStatusCode(response.statusCode)))
                return
            }
            
            guard let data = data else {
                completion(.failure(SessionError.noData))
                return
            }
            
            do {
                let objects = try JSONDecoder().decode(T.Response.self, from: data)
                completion(.success(objects))
            } catch {
                completion(.failure(error))
            }
        }
        
        task.resume()
    }
}

struct Article: Codable {
    let title: String
}

struct ArticlesRequest: Requestable {
    typealias Response = [Article]
    var baseURL: URL = URL(string: "https://qiita.com")!
    var path: String = "/api/v2/items"
    var httpMethod: HttpMethod = .get
}

final class PlaygroundTests: XCTestCase {
    func testGetArticles() {
        let session = Session()
        let request = ArticlesRequest()
        let expectation = expectation(description: "")
        
        session.send(request) { result in
            switch result {
            case .success(let articles):
                XCTAssertNotNil(articles.first)
                expectation.fulfill()
            case .failure(let error):
                print(error)
            }
        }
        
        wait(for: [expectation], timeout: 5)
    }
}
PlaygroundTests.defaultTestSuite.run()

参考リンク

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?