본문 바로가기

iOS

[iOS] Mock 데이터 기반 TDD 적용하기 - OHHTTPStubs

반응형

현재 프로젝트는 API 프로토콜을 정의하고, 해당 구현체를 주입하는 방식으로 API를 사용하고 있습니다.

따라서 테스트를 위해 Mock API 구현체를 구현하고, 해당 객체를 주입하여 테스트를 할 수 있습니다.

하지만, 위와 같은 방식으로 하기에는 다소 까다로운 점들이 있습니다.

  1. 테스트하고자 하는 API 외에도 프로토콜 채택하기 위해 다른 API에 해당하는 function들도 구현해야 함

  2. Mock Object 주입을 위한 코드 수정이 불가피함

  3. 다양한 방식으로 테스트의 어려움 (ex) 비동기 테스트, 같은 API 성공/실패 혹은 다양한 값)

위와 같은 까다로움을 OHHTTPStubs를 통해 해소할 수 있다고 판단했습니다. OHHTTPStubs는 좀 더 Low level인 URLSessionConfiguration를 swizzling을 하여 테스트할 API를 호출하면 미리 설정한 응답 값

으로 내려오게 함으로써 테스트를 용이하게 할 수 있습니다.

[OHHTTPStubs - 적용]

stub

func stub(condition: @escaping HTTPStubsTestBlock, response: @escaping HTTPStubsResponseBlock) -> HTTPStubsDescriptor
view raw stub.swift hosted with ❤ by GitHub

이 함수만 이해한다면, OHHTTPStubs를 사용하는데 무리가 없다고 생각합니다. (주로 이것만 사용함)

  • condition: 테스트를 진행할 request 여부 판단

  • response: 위의 request 정보에 해당하는 Mock Response 지정

1. condition

typedef BOOL(^HTTPStubsTestBlock)(NSURLRequest* request);

HTTPStubsTestBlock은 bool을 반환하며, NSURLRequest를 인자로 가진 블록입니다.

현재 request 중인 정보가 테스트할 정보인지 판단합니다.

 

사용 예시)

stub(condition: { request -> Bool in
request.httpMethod == "POST" &&
request.url?.absoluteString == "https://..."
// 위 결과가 참인 경우만 실제 네트워크 요청을 하지 않고, Mock Response를 넘겨줌
}, response ...

위와 같이 요청 중인 정보를 확인할 수 있지만, OHHTTPStubs/Swift에서 제공하는 방식을 이용하여 간결하게 표현할 수 있습니다.

 

stub(
condition: isMethodPOST() && isAbsoluteURLString("https://..."),
response: ....
)
OHHTTPStubs는 Objective-C 기반의 라이브러리이며, OHHTTPStubs/Swift는 이를 Swift 스럽게 사용할 수 있도록 래핑 되어 있는 것입니다. stub 함수도 stubRequestsPassingTest 함수를 래핑 한 형태로 OHHTTPStubs/Swift를 통해 사용할 수 있는 함수입니다.

2. response

typedef HTTPStubsResponse* __nonnull (^HTTPStubsResponseBlock)( NSURLRequest* request);
view raw response.m hosted with ❤ by GitHub

HTTPStubsResponse를 반환하며, NSURLRequest을 인자로 가진 블록입니다.

condition에 해당하는 request일 경우 HTTPStubsResponse를 반환하여 해당 Response를 받을 수 있도록 합니다.

 

사용 예시)

let jsonObject: [String: Any] = ["data": 1]
stub(condition: /* same */ ) { _ in
return HTTPStubsResponse(jsonObject: jsonObject, statusCode: 200, headers: nil)
}

위와 같이 테스트하고자 하는 jsonObject를 만들고 Response에 주입하여 해당 값을 받을 수 있습니다.

추가적으로 request 요청 시작 시간, response 받아오는 시간을 지정하여 다양한 환경에서 테스트를 진행할 수 있습니다.

 

// HTTPStubsResponse's
func requestTime(_ requestTime: TimeInterval, responseTime: TimeInterval) -> Self

header 값으로 nil이 들어가도 정상적으로 jsonObject를 테스트할 수 있는 이유는, jsonObject로 초기화 당시, 헤더에 Content-Type 이 누락되어 있다면 application/json 을 추가해주기 때문입니다.

 

+ (instancetype)responseWithJSONObject:(id)jsonObject
statusCode:(int)statusCode
headers:(nullable NSDictionary *)httpHeaders
{
if (!httpHeaders[@"Content-Type"])
{
NSMutableDictionary* mutableHeaders = [NSMutableDictionary dictionaryWithDictionary:httpHeaders];
mutableHeaders[@"Content-Type"] = @"application/json";
httpHeaders = [NSDictionary dictionaryWithDictionary:mutableHeaders]; // make immutable again
}
return [self responseWithData:[NSJSONSerialization dataWithJSONObject:jsonObject options:0 error:nil]
statusCode:statusCode
headers:httpHeaders];
}

주의 사항

stub 함수를 이용하여 테스트를 진행하는 경우, 해당 stub을 HTTPStubs의 싱글톤 객체의 stubDescriptors에 추가하게 됩니다. 따라서 테스트 진행을 완료한 이후에는 HTTPStubs.removeAllStubs()을 통해 진행한 stub들을 제거하는 것이 안전합니다.

 

추가하면 좋을 것?

1. HTTPStubsResponse jsonString 생성자

extension String {
func toJSON() -> [String: Any] {
if let data = self.data(using: .utf8) {
do {
return try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] ?? [:]
} catch {
return [:]
}
}
return [:]
}
}
extension HTTPStubsResponse {
convenience init(jsonString: String, statusCode: Int32, headers: [String: Any]?) {
self.init(jsonObject: jsonString.toJSON(), statusCode: statusCode, headers: headers)
}
}

2. HTTPStubsResponseBlock 간편 생성 함수

public func mockJsonResponse(_ jsonString: String) -> HTTPStubsResponseBlock {
return { _ in HTTPStubsResponse(jsonString: jsonString, statusCode: 200, headers: nil) }
}
// ...
stub(
condition: isMethodPOST() && isAbsoluteURLString("..."),
response: mockJsonResponse(jsonString)
)

샘플 테스트 코드

func test_sample() {
let jsonString: "..."
stub(
condition: isMethodPOST() && isAbsoluteURLString("..."),
response: mockJsonResponse(jsonString)
)
// given
let expected = 1
let exp = expectation(description: "subscribe onSuccess called")
// when
let request = viewModel.requestSomething()
// then
request.subscribe(onSuccess: { responseModel, _ in
exp.fulfill()
XCTAssertEqual(expected, responseModel.value)
})
waitForExpectations(timeout: 5, handler: nil)
}

 

반응형