Notice
Recent Posts
Recent Comments
Link
«   2025/03   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
Archives
Today
Total
관리 메뉴

찹모찌의 기록일지

Mock 활용해서 환경 의존도 없애기(with. URLSession, UserDefaults) 본문

iOS

Mock 활용해서 환경 의존도 없애기(with. URLSession, UserDefaults)

찹모찌 2024. 4. 11. 00:32

iOS Client의 역할을 맡아 개발을 진행하다 보면 개발 환경에 의존할 때가 있다. Server의 상태라던가, 아니면 기기의 DB라던가.

개발을 하면서 이 환경이 완벽하게 구성이 되기는 힘들 것이다. Network가 연결이 안 될 수도 있고, Server 측의 API가 아직 준비가 안 됐을 수도 있고, 기기 DB의 용량이 부족하다던가, 아니면 테스트할 데이터의 수가 부족하다던가와 같은 경우들이 있을 것이다.

이럴 때 환경에 의존하지 않고 Business Logic을 테스트하기 용이하도록 Mock을 활용할 수 있다.

URLSession과 UserDefaults를 Mock을 활용해 기능을 구현하거나 테스트를 하는 법을 알아보자.

 

기본적인 원리

Mock을 사용하는 원리

하나의 기본 동작이 되는 Protocol을 만든다. 그리고 그 Protocol에 따르는 실제 환경과 상호작용하는 객체, Mock Data를 활용하는 객체를 만든다. 이를 활용해 환경에 의존하지 않고, 환경에 의존하는 객체를 분리하여 기능을 구현하고 테스트할 수 있다.

실제 예시를 보자.

 

Mock NetworkManager

현재 주식 시세 조회 앱을 구현 중인데, 주식 API을 사용하는데 제한이 있어 Mock을 사용하고자 한다. 먼저 실제 환경과 상호작용하는 NetworkManager를 먼저 보자.

// NetworkSessionManager Protocol request 메서드를 채택해놓은 상태.
protocol NetworkSessionManager {
    typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void

    func request(_ request: URLRequest, completion: @escaping CompletionHandler) -> NetworkCancellable
}

final class DefaultNetworkSessionManager: NetworkSessionManager {
    func request(_ request: URLRequest, completion: @escaping CompletionHandler) -> NetworkCancellable {
        // URLSession.shared.dataTask는 실제 네트워크를 필요로 한다.
        let task = URLSession.shared.dataTask(with: request, completionHandler: completion)
        task.resume()
        return task
    }
}

request라는 Method를 가진 NetworkSessionManager를 DefaultNetworkSessionManager 채택해 사용 중이다.

여기서 request부분을 보면 되는데, request의 task는 URLSession.shared의 dataTask를 이용해 Network의 데이터를 받아온다.

이때, 이 request를 가진 Mock Manager를 이용하면 실제 Network환경 없이도 기능 구현에 활용할 수 있다.

// NetworkSessionManager Protocol을 채택
final class MockNetworkSessionManager: NetworkSessionManager {
    var response: HTTPURLResponse?
    let data: Data?
    let error: Error?

    init(response: HTTPURLResponse?, data: Data?, error: Error?) {
        self.response = response
        self.data = data
        self.error = error
    }
	
    // request method를 구현하는데 URLSession을 사용하는 것이 아닌 로컬의 json data를 가져와 사용
    func request(_ request: URLRequest,
                 completion: @escaping CompletionHandler) -> NetworkCancellable {
        response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)
        if let url = Bundle.main.url(forResource: "MockStock", withExtension: "json") {
            do {
                let jsonData = try Data(contentsOf: url)
                completion(jsonData, response, error)
            } catch {
                completion(nil, response, error)
            }
        } else {
            completion(nil, response, NSError(domain: "MockNetworkSessionManager", code: 404))
        }
        return MockURLSessionDataTask()
    }
}

// cancel method도 따로 구현해줄수도 있다.
final class MockURLSessionDataTask: NetworkCancellable {
    func cancel() {
        print("DataTask cancelled")
    }
}

이런 식으로 실제 Network를 사용하는 대신 Mock data를 활용하여 NetworkManager의 request method를 사용할 수 있다.

물론 request를 대체하는 대신, URLSession.shared의 dataTask를 Mock으로 대체하는 방법도 있다. 이 부분은 좀 더 상위 부분을 Mock으로 대체한 것이다.

그러나 이런 방식으로 하게 되면, MockNetworkSessionManager의 request는 정해진 하나의 데이터 밖에 주지 못하기 때문에 불편한 점이 있다.

MockURLProtocol을 사용해 개선해 보자.

// URLProtocol을 채택시킨 MockURLProtocol
final class MockURLProtocol: URLProtocol {
	// requestHandler를 이용해 request시 return 값을 내 맘대로 지정할 수 있다.
    static var requestHandler: ((URLRequest) -> (HTTPURLResponse?, Data?, Error?))?

    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }

    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    override func startLoading() {
        guard let handler = MockURLProtocol.requestHandler else { return }
        let (response, data, error) = handler(request)

        if let error = error {
          client?.urlProtocol(self, didFailWithError: error)
        }

        if let response = response {
          client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        }

        if let data = data {
          client?.urlProtocol(self, didLoad: data)
        }

        client?.urlProtocolDidFinishLoading(self)
    }

    override func stopLoading() { }
}

URLProtocol을 채택하여 requestHandler를 활용해 내가 원하는 대로 request의 return값을 변경할 수 있다.

extension URLSession {
	// Mock session으로 초기화
    static func initMockSession(configuration: URLSessionConfiguration = .ephemeral) -> URLSession {
        let configuration = URLSessionConfiguration.default
        configuration.protocolClasses = [MockURLProtocol.self]
        let urlSession = URLSession.init(configuration: configuration)
        return urlSession
    }
}

실제 통신에서는 URLSession.shared.dataTask를 사용하지만 Mock URLSession에서는 shared가 아닌 Mock Session을 사용하여 내가 원하는 데이터를 전달시킬 것이다.

final class MockCheckStockRepository {
    private let dataTransferService: DataTransfer
    private let backgroundQueue: DataTransferDispatchQueue = DispatchQueue.global(qos: .userInitiated)
    private var currentIndex = -1
    private var mockDatas: [Data] = []

    init(dataTransferService: DataTransfer = APIDataTransfer(apiProvider: APIProvider(sessionManager: DefaultNetworkSessionManager(session: .initMockSession()))) ) {
        self.dataTransferService = dataTransferService
    }
}

extension MockCheckStockRepository: CheckStockRepository {
    func fetchStockTodayPrices(stockName: String) -> Observable<StockInformation?> {
        var mockName: String
        switch stockName {
        case "SK하이닉스":
            mockName = "MockStock1"
        case "삼성":
            mockName = "MockStock2"
        case "네이버":
            mockName = "MockStock3"
        default:
            mockName = "MockStock1"
        }
        
        guard let mockFileLocation = Bundle.main.url(forResource: mockName, withExtension: "json"),
              let mockData = try? Data(contentsOf: mockFileLocation) else {
            return Observable.just(nil)
        }
        // 원하는 데이터로 변경
        mockDatas.append(mockData)
        // requestHandler는 URLSession의 request가 실행될 때 안의 클로저가 실행되므로 순서를 맞출 필요가 있을 때가 있다.
        MockURLProtocol.requestHandler = { request in
            let response = HTTPURLResponse(url: request.url!,
                                           statusCode: 200,
                                           httpVersion: nil,
                                           headerFields: nil)
            self.currentIndex += 1
            let currentIndex = min(self.currentIndex, self.mockDatas.indices.last ?? 0)
            return (response, self.mockDatas[currentIndex], nil)
        }

        return Observable.create { observer in
            let task = RepositoryTask()
            let endPoint: EndPoint<StockDayPriceDTO> = EndPoint<StockDayPriceDTO>(path: "/uapi/domestic-stock/v1/quotations/inquire-price", method: .GET)
            task.networkTask = self.dataTransferService.request(
                with: endPoint,
                on: self.backgroundQueue,
                completion: { result in
                    switch result {
                    case .success(let responseDTO):
                        observer.onNext(responseDTO.toDomain(korName: stockName, engName: stockName))
                        observer.onCompleted()
                    case .failure(let error):
                        observer.onError(error)
                    }
                })
            return Disposables.create {
                task.cancel()
            }
        }
    }
}

이 코드는 실제 프로젝트의 한 부분이다. 주식 목록의 Mock을 가져온다. 이때 stockName마다 주식 내용을 다르게 하기 위하여 MockURLProtocol의 requestHandler의 data를 배열에서 꺼내 사용한다.

protocol NetworkSessionManager {
    typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void

    func request(_ request: URLRequest, completion: @escaping CompletionHandler) -> NetworkCancellable
}

final class DefaultNetworkSessionManager: NetworkSessionManager {
    private let session: URLSession
	// 초기화시 URLSession을 shared로 할 지, Mock으로 할 지 골라 사용하면 된다.
    init(session: URLSession) {
        self.session = session
    }

    func request(_ request: URLRequest, completion: @escaping CompletionHandler) -> NetworkCancellable {
        let task = session.dataTask(with: request, completionHandler: completion)
        task.resume()
        return task
    }
}

이 방법의 장점은 기존 NetworkSessionManager를 그대로 사용할 수 있는 것이다. Mock을 사용하는 부분의 상위 모듈에서 requestHandler만 변경시켜 사용해 줘도 되기 때문에 구조상 Mock을 사용하기 용이하다.

Mock UserDefaults

URLSession뿐만 아니라 다른 곳에서도 충분히 적용가능하다. UserDefaults 또한 UserDefaults의 standard를 Mock으로 대체할 수 있다.

// set과 array 메서드가 있는 UserDefaultsProtocol
protocol UserDefaultsProtocol {
    func set(_ value: Any?, forKey defaultName: String)
    func array(forKey: String) -> [Any]?
}

// 실제 UserDefaults에도 채택!
extension UserDefaults: UserDefaultsProtocol {

}

// MockUserDefaults에도 채택!
final class MockUserDefaultsStandard: UserDefaultsProtocol {
	// Mock 데이터를 사용한다.
    var stocksList: [String] = ["카카오", "삼성", "네이버"]

    func set(_ value: Any?, forKey defaultName: String) {
        guard let stock: String = value as? String else { return }
        stocksList.append(stock)
    }
    
    func array(forKey: String) -> [Any]? {
        stocksList
    }
}

// 실제 UserDefaults를 사용할 때는 userDefaults 파라미터를 갈아끼우면서 사용하면된다.
final class UserDefaultsManager {
    private let userDefaults: UserDefaultsProtocol
    let userDefaultsKey: String = "stocks"
	
    // Mock을 사용하고 싶으면 초기화시 Mock생성자를 이용해 넣어주면 된다.
    init(userDefaults: UserDefaultsProtocol = UserDefaults.standard) {
        self.userDefaults = userDefaults
    }

    func save(stock: String) {
        var stockList: [String] = userDefaults.array(forKey: userDefaultsKey) as? [String] ?? []
        stockList.append(stock)
        userDefaults.set(stockList, forKey: userDefaultsKey)
    }

    func load() -> [String] {
        userDefaults.array(forKey: userDefaultsKey) as? [String] ?? []
    }
}

 

이러한 방식으로 Mock을 사용해 Network, UserDefaults를 실제 환경에 구애받지 않고 원하는 데이터를 넣어가며 사용할 수 있다.


Mock을 사용하게 되면, 실제 서버 API가 구현되어있지 않거나, 테스트 환경이 필요하다고 데이터를 일일이 직접 집어넣고 이런 식으로 테스트하지 않아도 되고, 간단한 Mock 데이터를 활용하여 원하는 환경을 조성할 수 있다.

 

현재 진행중인 프로젝트의 코드가 궁금하다면

https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev

 

GitHub - App-in-App-le/Or-rock-Nari-lock: 주식 정보 iOS 애플리케이션

주식 정보 iOS 애플리케이션. Contribute to App-in-App-le/Or-rock-Nari-lock development by creating an account on GitHub.

github.com