Swift

[iOS] Moya가 테스트에 용이한 이유 (with. Stub)

chemi_ 2023. 8. 22. 17:40

노션에 먼저 작성한 후 블로그로 옮기기 때문에 노션이 보기에 더 편합니다

https://mingwan.notion.site/Moya-Unit-Test-5745af80d0414f3bbc8f785dd3775efd?pvs=4 


들어가면서

Moya의 장점은 뭐가 있을까??

장점으로 언급되는 곳에는 항상 테스트에 용이하다 라는 말이 항상 존재한다.

얼마나 용이하길래 항상 장점으로 꼽히는 것일까?

한번 알아보자.

(Moya를 사용하면서 만들어봤던 영화 검색 프로젝트를 예시로 사용하겠다.)

 

간단히보는 Moya 사용법

본격적으로 알아보기 전에 아주 간단히 Moya의 사용법을 한번 더 짚고 넘어가자.

 

다들 알다시피 Moya를 사용하기 위해선 TargetType 이라는 protocol을 채택해야만 한다.

import Foundation

/// The protocol used to define the specifications necessary for a `MoyaProvider`.
public protocol TargetType {

    /// The target's base `URL`.
    var baseURL: URL { get }

    /// The path to be appended to `baseURL` to form the full `URL`.
    var path: String { get }

    /// The HTTP method used in the request.
    var method: Moya.Method { get }

    /// Provides stub data for use in testing. Default is `Data()`.
    var sampleData: Data { get }

    /// The type of HTTP task to be performed.
    var task: Task { get }

    /// The type of validation to perform on the request. Default is `.none`.
    var validationType: ValidationType { get }

    /// The headers to be used in the request.
    var headers: [String: String]? { get }
}

public extension TargetType {

    /// The type of validation to perform on the request. Default is `.none`.
    var validationType: ValidationType { .none }

    /// Provides stub data for use in testing. Default is `Data()`.
    var sampleData: Data { Data() }
}

Moya를 사용하기 위해선 해당 프로토콜을 채택하는 열거형을 하나 만들어야한다.

 

enum MovieSearchEndPoint {
    case nowPlaying
    case searchMovie(query: String)
}
extension MovieSearchEndPoint: TargetType {
    var baseURL: URL {
        return URL(string: "https://api.themoviedb.org/3")!
    }
    
    var path: String {
        switch self {
        case .nowPlaying:
            return "/movie/now_playing"
        case .searchMovie:
            return "/search/movie"
        }
    }
    
    var method: Moya.Method {
        switch self {
        case .nowPlaying:
            return .get
        case .searchMovie:
            return .get
        }
    }
    
    var task: Moya.Task {
        switch self {
        case .nowPlaying:
            return .requestParameters(parameters: ["language": "ko",
                                                   "region": "KR"],
                                      encoding: URLEncoding.queryString)
        case .searchMovie(let query):
            return .requestParameters(parameters: ["language": "ko",
                                                   "region": "KR",
                                                   "query": query],
                                      encoding: URLEncoding.queryString)
        }
    }
    
    var headers: [String : String]? {
        return [
            "Content-type": "application/json",
            "authorization": "Bearer \(Bundle.main.apiKey)"
        ]
    }
}

위와 같이 요구 조건대로 정의하면 손쉽게 Moya를 사용 가능하다.

 

func fetchData(completion: @escaping (Result<[Movie], NetworkError>) -> Void) {
    self.movieSearchProvider.request(.nowPlaying) { result in
        switch result {
        case .success(let response):
            guard let movies = try? response.map(MovieSearchDTO.self) else { return }
            completion(.success(movies.toMovieList()))
        case .failure:
            completion(.failure(.serverError))
        }
    }
}

위와 같이 간단하게 네트워크 요청을 보낼 수 있다.

 

Protocol을 활용한 추상화

위 코드를 테스트에 용이하게 만드는 방법은 무엇이 있을까?? 바로 protocol을 사용해서 추상화 하는 것이다.

 

내가 만들어야하는 기능들이 있는 MovieSearchService 란 프로토콜을 만들고 내부에 구현해야하는 함수를 정의한다.

protocol MovieSearchService {
    func fetchNowPlayingMovies() async throws -> Result<[Movie], NetworkError>
    func fetchSearchMovie(query title: String) async throws -> Result<[Movie], NetworkError>
}

난 두가지 네트워크 통신이 필요하다

  1. 현재 상영중인 영화 목록 가져오기
  2. 영화 제목 검색을 통해 영화 목록 가져오기

 

해당 프로토콜을 채택하는 클래스를 만들고 함수를 구현해주면 된다.

final class DefaultMovieSearchService: MovieSearchService {
    private let movieSearchProvider: MoyaProvider<MovieSearchEndPoint>
    
    init(movieSearchProvider: MoyaProvider<MovieSearchEndPoint> = MoyaProvider<MovieSearchEndPoint>()) {
        self.movieSearchProvider = movieSearchProvider
    }
    
    func fetchNowPlayingMovies() async throws -> Result<[Movie], NetworkError> {
        return try await withCheckedThrowingContinuation { continuation in
            self.movieSearchProvider.request(.nowPlaying) { result in
                switch result {
                case .success(let response):
                    guard let movies = try? response.map(MoviePlayingResponseDTO.self) else { return continuation.resume(throwing: NetworkError.decodingError)}
                    continuation.resume(returning: .success(movies.toMovieList()))
                case .failure:
                    continuation.resume(throwing: NetworkError.serverError)
                }
            }
        }
    }
    
    func fetchSearchMovie(query title: String) async throws -> Result<[Movie], NetworkError> {
        return try await withCheckedThrowingContinuation { continuation in
            self.movieSearchProvider.request(.searchMovie(query: title)) { result in
                switch result {
                case .success(let response):
                    guard let movies = try? response.map(MovieSearchDTO.self) else { return continuation.resume(throwing: NetworkError.decodingError)}
                    continuation.resume(returning: .success(movies.toMovieList()))
                case .failure:
                    continuation.resume(throwing: NetworkError.serverError)
                }
            }
        }
    }
}

DefaultMovieSearchService 클래스는 실제 네트워크 통신이 이루어지는 클래스이다.

 

그럼 테스트할때는 어떻게 해야할까??

 

MovieSearchService 프로토콜을 채택하는 Mock 클래스를 만들어주면 된다.

import Foundation

import Moya
@testable import SearchMovieApp

final class MockMovieSearchService: MovieSearchService {
    func fetchNowPlayingMovies() async throws -> Result<[Movie], NetworkError> {
        // 
    }
    
    func fetchSearchMovie(query title: String) async throws -> Result<[Movie], NetworkError> {
        //
    }
}

(난 Mock 클래스의 위치를 Test코드쪽에 만들었다.)

 

이제 각각의 fetchNowPlayingMovies()  fetchSearchMovie(query title: String) 함수의 내부를 채우면 된다.

 

Moya Stub

Moya Github의 문서를 참고하자.

https://github.com/Moya/Moya/blob/master/docs/Testing.md

 

위의 TargetType 을 채택한 열거형에서 정의하지 않은 부분이 있다.

public extension TargetType {

    /// The type of validation to perform on the request. Default is `.none`.
    var validationType: ValidationType { .none }

    /// Provides stub data for use in testing. Default is `Data()`.
    var sampleData: Data { Data() }
}

해당 값들은 default값이 있어서 선언하지 않아도 괜찮기 때문에 에러가 발생하지 않았다.

When creating your TargetType you are required to provide sampleData for your targets. All you need to do there is to provide Data that represents a sample response from every particular target. This can be used later for tests or just for providing offline support while developing.

Moya 문서를 보면 sampleData를 테스트용으로 사용할 수 있다고 나와있다.

 

위의 열거형에 sampleData를 추가하자. 여기엔 네트워크 통신을 하지 않고 예시로 받을 데이터 형식을 넣으면 된다. 쉽게 말해 내가 원래 받을 Response 형식대로 dummy 데이터를 만들어서 넣어주면 된다.

 

extension MovieSearchEndPoint: TargetType {
    
    // 생략...
    
    var sampleData: Data {
        switch self {
        case .nowPlaying:
            return .loadMockNowPlayingMovieLsit()
        case .searchMovie:
            return .loadMockMovieList()
        }
    }
}

(여기에 사용되는 각각의 loadMockNowPlayingMovieLsit() loadMockMovieList() 함수는 dummy 데이터를 JSON 형식으로 프로젝트에 넣어두고 사용하는 함수다.)

 

Dummy Data 불러오는 코드

import Foundation

extension Data {
    static func loadMockMovieList() -> Data {
        guard let path = Bundle.main.path(forResource: "MockMovieList", ofType: "json") else { return Data() }
        guard let jsonString = try? String(contentsOfFile: path) else { return Data() }
        
        guard let encodeData = jsonString.data(using: String.Encoding.utf8) else { return Data() }
        return encodeData
    }
    
    static func loadMockNowPlayingMovieLsit() -> Data {
        guard let path = Bundle.main.path(forResource: "MockNowPlayingMovieList", ofType: "json") else { return Data() }
        guard let jsonString = try? String(contentsOfFile: path) else { return Data() }
        
        guard let encodeData = jsonString.data(using: String.Encoding.utf8) else { return Data() }
        return encodeData
    }
}

위에서 언급했던 MovieSearchService 의 내부는 어떻게 채우면 될까?? Moya의 문서를 보면 가이드라인이 나와있다.

 

let customEndpointClosure = { (target: APIService) -> Endpoint in
    return Endpoint(url: URL(target: target).absoluteString,
                    sampleResponseClosure: { .networkResponse(401 , /* data relevant to the auth error */) },
                    method: target.method,
                    task: target.task,
                    httpHeaderFields: target.headers)
}

let stubbingProvider = MoyaProvider<GitHub>(endpointClosure: customEndpointClosure, stubClosure: MoyaProvider.immediatelyStub)

 

위 코드를 살펴보면, 내가 원하는 결과값을 반환해주는 EndpointClosure를 만들어서 MoyaProvider 의 endpointClosure에 넣어주고, stubClosure에 .immediatelyStub 를 사용해 즉시 response를 반환하도록 만들어 주면 된다.

 

이 예시를 가지고 MovieSearchService 내부를 채워보자.

final class MockMovieSearchService: MovieSearchService {
    func fetchNowPlayingMovies() async throws -> Result<[Movie], NetworkError> {
        let nowPlayingEndpointClosure = { (target: MovieSearchEndPoint) -> Endpoint in
            return Endpoint(url: URL(target: target).absoluteString,
                            sampleResponseClosure: { .networkResponse(200, target.sampleData) },
                            method: target.method,
                            task: target.task,
                            httpHeaderFields: target.headers)
        }
        let provider = MoyaProvider<MovieSearchEndPoint>(endpointClosure: nowPlayingEndpointClosure, stubClosure: MoyaProvider<MovieSearchEndPoint>.immediatelyStub)
        
        return try await withCheckedThrowingContinuation { continuation in
            provider.request(.nowPlaying) { result in
                switch result {
                case .success(let response):
                    guard let movieList = try? response.map(MoviePlayingResponseDTO.self) else { return continuation.resume(throwing: NetworkError.decodingError) }
                    continuation.resume(returning: .success(movieList.toMovieList()))
                case .failure:
                    continuation.resume(returning: .failure(.unknownError))
                }
            }
        }
    }

    func fetchSearchMovie(query title: String) async throws -> Result<[Movie], NetworkError> {
        let searchMovieEndpointClosure = { (target: MovieSearchEndPoint) -> Endpoint in
            return Endpoint(url: URL(target: target).absoluteString,
                            sampleResponseClosure: { .networkResponse(200, target.sampleData) },
                            method: target.method,
                            task: target.task,
                            httpHeaderFields: target.headers)
        }
        let provider = MoyaProvider<MovieSearchEndPoint>(endpointClosure: searchMovieEndpointClosure, stubClosure: MoyaProvider<MovieSearchEndPoint>.immediatelyStub)
        
        return try await withCheckedThrowingContinuation { continuation in
            provider.request(.searchMovie(query: "범죄도시")) { result in
                switch result {
                case .success(let response):
                    guard let movieList = try? response.map(MovieSearchDTO.self) else { return continuation.resume(throwing: NetworkError.decodingError) }
                    continuation.resume(returning: .success(movieList.toMovieList()))
                case .failure:
                    continuation.resume(returning: .failure(.unknownError))
                }
            }
        }
    }
}

각각의 함수를 보면 간

  1. EndpointClosure 를 만든다.
  2. provider를 만든다.
  3. 통신 코드를 실행한다.

주의깊게 볼 부분은 두가지가 있다.

  1. sampleResponseClosure: { .networkResponse(200, target.sampleData) }, 상태코드 200을 리턴하면서 sampleData로 넣어두었던 값을 사용한다는 의미다.
  2. withCheckedThrowingContinuation

 

withCheckedThrowingContinuation

눈치챘을진 모르겠지만, 맨 처음 Moya의 예시를 만들었을 땐 escaping closure를 사용해서 함수를 만들었었다. 하지만 내가 프로젝트를 만들때는 escaping closure를 사용하지 않고, 모두 async await를 사용해 동시성(concurrency) 처리를 했다. 하지만 Moya의 request 함수는 escaping closure로 구현되어 있다.

    /// Designated request-making method. Returns a `Cancellable` token to cancel the request later.
    @discardableResult
    open func request(_ target: Target,
                      callbackQueue: DispatchQueue? = .none,
                      progress: ProgressBlock? = .none,
                      completion: @escaping Completion) -> Cancellable {

        let callbackQueue = callbackQueue ?? self.callbackQueue
        return requestNormal(target, callbackQueue: callbackQueue, progress: progress, completion: completion)
    }

Moya의 구현부를 뜯어 고칠 순 없기에 해결책이 필요했다. 이때 사용할 수 있는 함수가 바로 withCheckedThrowingContinuation 이다.

 

사용하는 부분을 보면

        return try await withCheckedThrowingContinuation { continuation in
            provider.request(.nowPlaying) { result in
                switch result {
                case .success(let response):
                    guard let movieList = try? response.map(MoviePlayingResponseDTO.self) else { return continuation.resume(throwing: NetworkError.decodingError) }
                    continuation.resume(returning: .success(movieList.toMovieList()))
                case .failure:
                    continuation.resume(returning: .failure(.unknownError))
                }
            }
        }

위와 같이 return 형식으로 사용할 수 있고, 에러를 처리할 수도 있다.

 

MovieSearchServiceTest

위에서 만들어둔 sampleData와, MockService를 활용해 네트워크 환경에 의존하지 않는 테스트 코드를 만들어보자.

import XCTest

@testable import SearchMovieApp

final class MovieSearchServiceTest: XCTestCase {
    
    var service: MovieSearchService!
    
    override func setUp() {
        self.service = MockMovieSearchService()
    }
    
    override func tearDown() {
        self.service = nil
    }

    func test_nowPlaying함수가_올바르게_디코딩되는가() async throws {
        do {
            let result = try await self.service.fetchNowPlayingMovies()
            switch result {
            case .success(let movieList):
                XCTAssertNotNil(movieList)
            case .failure(let error):
                switch error {
                case .unknownError:
                    XCTFail("unknown Error")
                case .decodingError:
                    XCTFail("decoding Error")
                default:
                    XCTFail("Error")
                }
            }
        } catch {
            XCTFail("Error")
        }
    }

    func test_searchMovie함수가_올바르게_디코딩되는가() async throws {
        do {
            let result = try await self.service.fetchSearchMovie(query: "범죄도시")
            switch result {
            case .success(let movieList):
                XCTAssertNotNil(movieList)
            case .failure(let error):
                switch error {
                case .unknownError:
                    XCTFail("unknown Error")
                case .decodingError:
                    XCTFail("decoding Error")
                default:
                    XCTFail("Error")
                }
            }
        } catch {
            XCTFail("Error")
        }
    }
}

코드를 하나씩 살펴보자.

 

    override func setUp() {
        self.service = MockMovieSearchService()
    }

알다시피 override func setUp() 함수는 각각의 테스트 코드들이 실행되기 전 실행되는 함수다.

MovieSearchService 프로토콜을 채택하는 serviceMockMovieSearchService로 초기화 된다.

(여기서 MockMovieSearchService 는 아까 만들어 두었던 sampleData와 200을 반환한다.)

 

구현해두었던 함수들은 에러를 반환할 수 있기 때문에 do-catch 문을 사용해서 에러를 처리해준다.

성공적으로 데이터가 잘 들어왔을 때만 테스트 성공을 해주고 나머지는 각각의 에러 메시지를 출력하면서 실패하게 된다.

 

마무리

여기까지 Moya를 사용한 테스트 코드를 만드는 방법을 알아보았다.

 

Moya에서 제공하는 sampleData와 각각의 상황을 만들 수 있는 endpointClosure 덕분에 테스트에 용이하다고 말하는 것 같다. 위의 예시에서는 성공적인 네트워크 통신 때 반환되는 상태코드 200에 대한 테스트만 만들었는데, 같은 방법으로 400에러나 다른 에러에 대한 대응도 충분히 할 수 있다. 400에러에 대해 프로젝트에는 구현해 뒀으니 구경하고 싶다면 아래에 해당 테스트 파일 링크를 남겨두겠다.

https://github.com/MMMIIIN/SearchMovieApp/blob/main/SearchMovieApp/SearchMovieAppTests/Data/Network/MovieSearchServiceTest.swift

 

 

전체적인 프로젝트를 구경하고 싶다면 아래 링크를 통해 확인하면 된다.

프로젝트 링크 : https://github.com/MMMIIIN/SearchMovieApp

 

GitHub - MMMIIIN/SearchMovieApp: TMDB API를 활용한 영화 검색 앱

TMDB API를 활용한 영화 검색 앱. Contribute to MMMIIIN/SearchMovieApp development by creating an account on GitHub.

github.com

 

 

참고자료

https://techblog.woowahan.com/2704/

 

iOS Networking and Testing | 우아한형제들 기술블로그

{{item.name}} Why Networking? Networking은 요즘 앱에서 거의 필수적인 요소입니다. 설치되어 있는 앱들 중에 네트워킹을 사용하지 않는 앱은 거의 없을 겁니다. API 추가가 쉽고 변경이 용이한 네트워킹

techblog.woowahan.com

https://github.com/Moya/Moya/blob/master/docs/Testing.md

 

https://medium.com/@ryanisnhp/mock-data-with-moya-no-need-to-wait-for-apis-e69933b43c02

 

Mock Data with Moya, no need to wait for APIs

Why we must wait for Backend developers to finish APIs?

medium.com