Xcode UnitTest ② - RxSwift + Moya!! 네트워크도 목업으로 테스트
목차
1: Xcode UnitTest ① - RxTest!! 테스트 스케줄러
2: Xcode UnitTest ② - RxSwift + Moya!! 네트워크도 목업으로 테스트
3: Xcode UnitTest ③ - RxSwift + Moya 네트워크 딜레이 테스팅!!
4: Xcode UnitTest ④ - 병렬 처리!! 퍼포먼스 테스팅(measure)
참고
iOS Networking and Testing(우아한 형제들 기술 블로그)
힐페TV - ViewModel을 무조건 믿을 수 있는 방법이 있다???(삐슝빠슝)(강남언니 기술 블로그)
우리 프로젝트는 Moya를 사용하고 있다. Alamofire를 한 번 더 추상화한 HTTP 통신 라이브러리로, 마음에 든다…
Moya 간단 소개
enum MyAPI {
case api1
case api2(id: Int)
}
요런 식으로 API들을 enum으로 정의하고
extension MyAPI: TargetType {
var baseURL: URL
var path: String
var method: Moya.Method
var sampleData: Data
var task: Task
var headers: [String : String]?
}
TargetType
을 conform하여 구현하면 된다.
baseURL
- 말 그대로 베이스 url. 개발 서버와 릴리즈 서버가 다른 경우 플래그에 따라 다르게 리턴해주는 등 하면 됨
path
- 해당 API의 패스. 베이스 url에 덧붙여지는 부분
method
- GET, POST, PATCH, DELETE 등 HTTP 메소드
sampleData
- 예측되는 리스폰스의 샘플 데이터
task
- HTTP 태스크. 요청 시 파라미터 같이 보내거나할 때 쓰는 부분
headers
- HTTP 요청 시 헤더 부분. Authorization 등 여기서 채우면 됨
let provider = MoyaProvider<MyAPI>()
provider.rx.request(.api1)
.subscribe(onSuccess: {})
.disposed(by: disposeBag)
요런 식으로 네트워크 요청을 만들어서 Single
을 받아볼 수 있음!!
Moya In Testing
그럼 모야를 어떻게 테스트할 수 있을까
딱 보면 저 provider가 메인인 걸 알 수 있다.
init(endpointClosure: @escaping EndpointClosure = MoyaProvider.defaultEndpointMapping,
requestClosure: @escaping RequestClosure = MoyaProvider<Target>.defaultRequestMapping,
stubClosure: @escaping StubClosure = MoyaProvider.neverStub,
callbackQueue: DispatchQueue? = nil,
session: Session = MoyaProvider<Target>.defaultAlamofireSession(),
plugins: [PluginType] = [],
trackInflights: Bool = false)
MoyaProvider
의 init 부분이다. 주로 사용할 부분만 보자면
endpointClosure
- 위에서 통신에 필요한 여러가지(헤더, 메소드 등)를 configure한
Target
을, 실제 리퀘스트에 사용 가능하게 콘크리트 엔드포인트를 만드는 클로저
- 위에서 통신에 필요한 여러가지(헤더, 메소드 등)를 configure한
stubClosure
- never, immediately, delayed가 있다. immediately를 사용할 경우 바로 반환, delayed를 사용할 경우 지연되게 받을 수 있음
plugins
- 여러 플러그인이 사용 가능하다. 주로 MoyaInterceptor 많이 쓰는 듯
그래서?
let customEndpointClosure = { (target: MyAPI) -> Endpoint in
return Endpoint(url: URL(target: target).absoluteString,
sampleResponseClosure: { .networkResponse(201, target.sampleData) },
method: target.method,
task: target.task,
httpHeaderFields: target.headers)
}
let testProvider = MoyaProvider<MyAPI>(endpointClosure: customEndpointClosure,
stubClosure: MoyaProvider.immediatelyStub)
이렇게 커스텀으로 목업된 엔드포인트를 만드는 endpointClosure
를 넘겨주고, stubClosure
를 immediatelyStub
로 하면 테스트용 프로바이더 생성 끝!!
let customEndpointClosure = { (target: MyAPI) -> Endpoint in
var statusCode: Int?
var data: Data?
switch target {
case .api2(let id):
switch id {
/// Wrong data
case -1: data = Data(MyTests.wrongJsonData.utf8)
/// Server error
case -500: statusCode = 500
/// Normal data
default:
data = Data(MyTests.normalData.utf8)
}
default:
break
}
return Endpoint(url: URL(target: target).absoluteString,
sampleResponseClosure: { .networkResponse(statusCode ?? 201, data ?? Data()) },
method: target.method,
task: target.task,
httpHeaderFields: target.headers)
}
나는 주로 테스팅할 때 이런 식으로 api의 파라미터로 구분해서 에러 데이터나 정상적인 데이터를 리턴하도록 하니까 편한 거 같음
이제 이 프로바이더는 테스트 가능함!!
근데
문제?
그냥 저 프로바이더만 테스팅할 건 아니잖음??
우리는 뷰모델을 테스트 해야 함
class MyRepository<MyAPI: TargetType> {
var shared = MyRepository()
var provider: MoyaProvider<MyAPI>
private init() {
self.provider = MoyaProvider<MyAPI>()
}
func getAPI1() -> Single<Model1> {
return provider.rx
.request(.api1)
.map(Model1.self)
}
}
기존에 요런 식으로 레포지토리를 만들고
struct MyVM {
let repository = MyRepository.shared
}
뷰모델에서 이런 식으로 사용하고 있었는데
여기서!! 문제점
바로바로 싱글톤과 DIP 위반
싱글톤이 왜 문제냐
- 장점
- 할당 비용 이득
- 같은 객체를 여러번 사용하는 경우 이걸 재활용하면 메모리 공간은 물론 할당에 드는 오버헤드도 줄일 수 있다.
- 데이터 공유
- 여러 클래스에서 같은 인스턴스를 접근하므로 데이터 공유가 가능함.
- 할당 비용 이득
싱글톤 처음 봤을 땐 오 간단한데 짱 좋아 보이는데? 했는데 진짜 큰 문제가 있다;;
- 단점
- 클라이언트가 콘크리트 클래스에 의존
- DIP 위반
- 테스트하기가 어려움
- 위에 이어서, 객체간 의존도가 높아질 수밖에 없음.
- 클라이언트가 콘크리트 클래스에 의존
테스트 코드를 좀 짜려 해도 진짜 그거 관련된 부분만 딱딱해져서 어떻게 손을 댈 수가 없다!!
그래서… 싱글톤은 없애기로 했다.
이유는 장점이 별로 안 장점이라서… 네트워크 레포지토리는 데이터 공유가 필요 없음 단점이 더 크다
FileManager.default 개념으로 빠르게 접근 가능하게 기존 static 인스턴스는 놔둬도 되긴 할 듯
바꾼 후
struct MyVM: ViewModel {
var disposeBag = DisposeBag()
let repository: MyRepository
init(_ provider: MyProvider<MyAPI>? = nil) {
repository = MyRepository(provider)
}
}
class MyRepository<MyAPI: TargetType> {
var provider: MoyaProvider<MyAPI>
init(_ provider: MoyaProvider<MyAPI>? = nil) {
self.provider = provider ?? MoyaProvider<MyAPI>()
}
func getAPI1() -> Single<Model1> {
return provider.rx
.request(.api1)
.map(Model1.self)
}
}
왜 DI DI 하는지!! 이유가 다 있다 이거 때문이지
여기다 DIP도 따르도록, 레포지토리도 콘크리트인 MyRepository
가 아니라 추상화된 프로토콜을 추가하고 그걸 따르도록 해야할까도 생각해봤는데
이미 MyRepository
자체가 목업과 실제가 표현이 가능한 애라서… 굳이 싶어서 안 함
테스트 코드
전제
class MyVM: ViewModel {
struct Input {
let id: Observable<Int>
}
struct Output {
let result: Observable<Model1>
}
func transform(input: Input) -> Output {
let result = input.id.flatMap(repository.getAPI1)
return Output(result: result.asObservable())
}
}
정말 간단간단하게 뷰모델이 그냥 이렇다 치면
0.
override func setUpWithError() throws {
let customEndpointClosure = { (target: MyAPI) -> Endpoint in
// ...
}
let testProvider = MoyaProvider<MyAPI>(endpointClosure: customEndpointClosure,
stubClosure: MoyaProvider.immediatelyStub)
vm = MyVM(testProvider)
scheduler = TestScheduler(initialClock: 0)
disposeBag = DisposeBag()
}
테스트 프로바이더로 세팅 해주고
1.
func testNetwork() throws {
let idEvents: [Recorded<Event<Int>>] = [
.next(0, 1),
.next(10, 2),
.next(20, 3)
]
let output = vm.transform(input: .init(id: scheduler.createHotObservable(idEvents).asObservable()))
let result = scheduler.createObserver(Model1.self)
output.result.bind(to: result).disposed(by: disposeBag)
scheduler.start()
XCTAssertEqual(result.events, [.next(0, Model1(value: EXPECTED_1)),
.next(10, Model1(value: EXPECTED_2)),
.next(20, Model1(value: EXPECTED_3))])
// ...
}
이전 포스트와 똑같이 이제 테스트하면 됨!!
굿
댓글남기기