찹모찌의 기록일지
Mock 활용해서 환경 의존도 없애기(with. URLSession, UserDefaults) 본문
iOS Client의 역할을 맡아 개발을 진행하다 보면 개발 환경에 의존할 때가 있다. Server의 상태라던가, 아니면 기기의 DB라던가.
개발을 하면서 이 환경이 완벽하게 구성이 되기는 힘들 것이다. Network가 연결이 안 될 수도 있고, Server 측의 API가 아직 준비가 안 됐을 수도 있고, 기기 DB의 용량이 부족하다던가, 아니면 테스트할 데이터의 수가 부족하다던가와 같은 경우들이 있을 것이다.
이럴 때 환경에 의존하지 않고 Business Logic을 테스트하기 용이하도록 Mock을 활용할 수 있다.
URLSession과 UserDefaults를 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
'iOS' 카테고리의 다른 글
RxSwift로 코드 개선하기(with. Clean Architecture) (0) | 2024.04.13 |
---|---|
MVVM + RxSwift 직접 써보기 // 주어진 API에서 최선을 다히기 (0) | 2024.04.13 |
내 앨범이 너에게 닿기를: 서버 없이 앨범 전달받기 (0) | 2024.03.15 |
설명 뷰가 늘어나면서 올라왔으면 좋겠습니다(애니메이션 구현기). (0) | 2024.03.09 |
숏폼 플랫폼 재생 화면 정보를 불러올 때는 어떻게 불러오는게 좋을까? (1) | 2024.03.08 |