배경 및 원리
오늘은 Network 환경에서 테스트 코드를 작성하는 방법을 알아보려고 함. iOS 환경에서 가장 기본적인 Networking 기술은 URLSession이고, 나 또한 지금 URLSession을 사용하기 때문에 URLSession 환경에서의 테스트 코드를 작성해보려고 함.
여기에 필요한 건 Test Double 개념이고, 그중에서도 Stub 또는 Mock 개념이 필요함. (*현업에서는 상태와 행위를 동시에 검증하는 경우가 많아서 ‘Mock’으로 퉁쳐서 사용한다고 함) 이게 뭐임?
Unit Test로 URLSession의 통신 자체를 테스트할 수도 있지만(status code가 200인지), 통신으로 받은 데이터를 잘 가공하는지 테스트해야 함. (통신이 잘 된 후 그 다음 과정을 검증하는 것임) 그러기 위해선 ‘Network(여기선 URLSession)가 통신 잘 했다고 가정할게요~’라고 전제 조건을 깔아야 함.
근데 왜 저런 전제 조건이 필요함? 저 부분을 테스트한답시고 매번 API 호출을 하고 URLSession을 통해 HTTP Request를 보내면 테스트 코드 작성의 FIRST 원칙에 위배됨 (Fast, Repeatable) 왜냐하면, 속도가 느리고 테스트에 외부 조건이 개입되어서 원만한 테스트가 진행되지 않을 수도 있기 때문임. 내가 테스트하려는 대상에 영향을 미칠 수도 있는 불순물 같은 존재랄까.
여기에 필요한 것이 임의의 URLSession인 URLSessionStub(또는 URLSessionMock)임. 이 불순물 같은 존재를 없애고, 전제 조건을 까는 것임. ‘URLSession으로 HTTP 통신이 성공했다’고 가정하고 다음 과정을 테스트하는 것에 집중할 것임. 그럴려면 HTTP 통신으로 받은 응답이 다음 테스트에 필요하니까 내가 응답 또한 임의로 만들어서 주입할 것임. 이걸 이용해서 그 다음 단계를 테스트하는 것임. 그리고 이 테스트는 저번 포스팅에서 Unit Test의 3가지 범주 중 Fake Test에 해당됨. (저번 링크 참고) (여기서 말하는 ‘3가지 범주’라는 개념은 스스로의 이해를 돕기 위해 굉장히 주관적으로 네이밍한 개념입니다!)
마치 축구 선수가 프리킥 연습을 하기 위해 매번 사람을 불러서 세우는 게 아니라, ‘사람을 불러서 세우고 내가 프리킥을 찰 때 점프한다’라는 것을 임의로 구현한 프리킥 벽을 세우는 것과 같달까. 그 다음 단계인 프리킥을 찬 공이 수비벽을 넘어서 골대에 잘 들어가냐를 테스트하기 위해. Mock을 촬영장의 스턴트맨으로 비유하기도 함.
결국 기존의 진짜 URLSession을 내가 임의로 만들어놓은 URLSessionStub으로 바꿔치기해서 테스트를 진행한다는 것임.
그리고 URLSessionStub으로 ‘용이하게’ 바꿔칠 수 있는 이유는 URLSession 코드에 의존 관계 역전 원칙을 적용해서 테스트에 용이하도록 구조를 짜놨기 때문임 (저번 링크 참고)
자 이제 우리가 어떤 걸 할 것이고, 왜 할 것인지 감이 잡혔음?! 이걸 예제 상황과 코드와 함께 살펴봅세
적용
순서
- URLSessionMock, URLSessionDataTaskMock 만들기
- 테스트 코드 작성
일단 URLSessionMock 클래스부터 만들어보겠습니다.
class URLSessionMock: URLSessionProtocol {
}
여기에 어떤 내용이 와야할까요? 우리는 URLSession의 dataTask가 필요한 것이니 구현해보겠습니다.
class URLSessionMock: URLSessionProtocol {
func dataTask(with: String, completionHandler: @escaping DataTaskCompletionHandler) -> URLSessionDataTask {
}
}
dataTask 함수 내부를 구현해보겠습니다. 반환값으로 URLSessionDataTask 타입이 필요합니다.
URLSessionDataTask가 resume()을 호출하면 api call을 때려서 네트워크와 호출하게 됩니다. 우리는 이 부분을 바꿔치기 해야합니다.
그래서 호출해서 데이터 받아왔다는 임의의 URLSessionDataTaskMock을 만들어야 합니다.
class URLSessionMock: URLSessionProtocol {
func dataTask(with: String, completionHandler: @escaping DataTaskCompletionHandler) -> URLSessionDataTask {
}
}
class URLSessionDataTaskMock: URLSessionDataTask {
}
URLSessionDataTask를 상속받는 URLSessionDataTaskMock을 만들어줍니다. 여기에는 어떤 코드가 필요할까요?
resume()을 바꿔치기 해주어야 합니다. 왜냐하면 진짜 resume()을 호출해버리면 api에 call을 때려버리기 때문이죠. 그렇게 되면 우리가 하고 있는 이 일의 목적이 무산됩니다. 이때 바꿔치기 하는 수단으로 클로져를 사용해줍니다.
class URLSessionMock: URLSessionProtocol {
func dataTask(with: String, completionHandler: @escaping DataTaskCompletionHandler) -> URLSessionDataTask {
}
}
class URLSessionDataTaskMock: URLSessionDataTask {
let resumeHandler: () -> Void
init(resumeHandler: @escaping () -> Void) {
self.resumeHandler = resumeHandler
}
override func resume() {
resumeHandler()
}
이 코드의 의미를 해석해보자면,
URLSessionDataTaskMock의 resume()을 호출한다면, URLSessionDataTaskMock의 인스턴스를 처음 생성하면서 이니셜라이저로 설정한 클로저가 실행됩니다. 그리고 이 클로저를 resumeHandler라고 명명한 것입니다. 인자도 없고, 반환값도 없습니다. 나중에 이 클로저에 “호출해서 데이터 받아왔어”의 전제 조건에서 데이터를 설정해줄 것입니다.
URLSessionDataTaskMock을 만들어주었으니, dataTask 함수를 마저 작성해보도록 합시다.
dataTask 함수에서 반환값으로 URLSessionDataTask 타입이 필요하다고 했죠? 우리가 생성한 URLSessionDataTaskMock을 반환값으로 넣어줍시다.
class URLSessionMock: URLSessionProtocol {
func dataTask(with: String, completionHandler: @escaping DataTaskCompletionHandler) -> URLSessionDataTask {
return URLSessionDataTaskMock(resumeHandler: {
// code
}
}
}
class URLSessionDataTaskMock: URLSessionDataTask {
let resumeHandler: () -> Void
init(resumeHandler: @escaping () -> Void) {
self.resumeHandler = resumeHandler
}
override func resume() {
resumeHandler()
}
앗, URLSessionDataTaskMock의 인스턴스를 선언할 때 resumeHandler를 설정해주어야 하고, 저희는 여기에 받아왔다고 가정한 데이터를 설정해주기로 했습니다. 근데, 데이터 프로퍼티 정보가 없네요. 넣어주겠습니다.
그 전에, 데이터는 dataTask에서 data, response, error와 함께 오죠? 이 3가지를 Response라는 것으로 묶고 프로퍼티 정보부터 먼저 설정해주겠습니다.
class URLSessionMock: URLSessionProtocol {
typealias Response = (data: Data?, response: URLResponse?, error: Error?)
let response: Response?
init(response: Response) {
self.response = response
}
func dataTask(with: String, completionHandler: @escaping DataTaskCompletionHandler) -> URLSessionDataTask {
return URLSessionDataTaskMock(resumeHandler: {
// code
}
}
}
class URLSessionDataTaskMock: URLSessionDataTask {
let resumeHandler: () -> Void
init(resumeHandler: @escaping () -> Void) {
self.resumeHandler = resumeHandler
}
override func resume() {
resumeHandler()
}
자, 이제 dataTask의 함수를 마저 작성해볼까요?
class URLSessionMock: URLSessionProtocol {
typealias Response = (data: Data?, response: URLResponse?, error: Error?)
let response: Response?
init(response: Response?) {
self.response = response
}
func dataTask(with: String, completionHandler: @escaping DataTaskCompletionHandler) -> URLSessionDataTask {
return URLSessionDataTaskMock(resumeHandler: {
completionHandler(
self.response?.data,
self.response?.response,
self.response?.error)
})
}
}
class URLSessionDataTaskMock: URLSessionDataTask {
let resumeHandler: () -> Void
init(resumeHandler: @escaping () -> Void) {
self.resumeHandler = resumeHandler
}
override func resume() {
resumeHandler()
}
‘Handler’라는 단어가 두번이나 나와서 헷갈릴 수도 있습니다. 저도 이것때문에 엄청 헷갈렸습니다. 천천히 살펴보자구요!
구조부터 보겠습니다. resumeHandler는 completionHandler를 가지고 있고, completionHandler는 data, response, error를 갖고 있습니다.
resumeHandler는 URLSessionDataTaskMock의 이니셜라이저의 전달인자 입니다. resume이 호출되면 resumeHandler가 갖고 있는 놈을 클로저로 수행한다는 뜻이고, 그게 completionHandler입니다.
근데 completionHandler는 dataTask 함수의 클로저입니다. resume을 호출하고 나서 가지고 있는 data, response, error를 가져왔으니 예정된 동작을 수행하면 됩니다.
즉, resume()을 호출하면 다른 거 안하고, 그냥 response의 data, response, error를 URLSessionMock의 dataTask의 후행 클로저에 던져주는 것일 뿐입니다.
그럼 저희는 URLSessionMock을 생성하면서 Response를 잘 할당해주면 되겠죠?!
Mocking한 코드들을 사용하러 가봅시다!
class BullsEyeGame {
...
var targetValue = 50
var urlSession: URLSessionProtocol = URLSession.shared
init() {
startNewGame()
}
func startNewGame() {
round = 1
scoreTotal = 0
}
...
func startNewRound(completion: @escaping () -> Void) {
round += 1
scoreRound = 0
getRandomNumber { newTarget in
self.targetValue = newTarget
DispatchQueue.main.async {
completion()
}
}
}
...
func getRandomNumber(completion: @escaping (Int) -> Void) {
guard let url = URL(string: "<http://www.randomnumberapi.com/api/v1.0/random?min=0&max=100&count=1>") else {
return
}
let task = urlSession.dataTask(with: url) { data, _, error in
do {
guard
let data = data,
error == nil,
let newTarget = try JSONDecoder().decode([Int].self, from: data).first
else {
return
}
completion(newTarget)
} catch {
print("Decoding of random numbers failed.")
}
}
task.resume()
}
}
var sut: BullsEyeGame!
func testURLSesionTest() throws {
// given
sut.urlSession = URLSessionMock(response: Response)
let promise = expectaion("mock test")
}
우선 urlSession을 URLSessionMock으로 바꿔줍니다. 하지만, 생성 인자로 Response 타입의 인스턴스가 필요합니다.
func testURLSesionTest() throws {
// given
let response: Response = {
let data = Data("[9]".utf8)
return (data, nil, nil)
}()
sut.urlSession = URLSessionMock(data)
let promise = expectaion("mock test")
}
저희는 가볍게 data만 확인해봅시다. 만약 response, error에 따른 테스트를 진행할 경우 response 인스턴스에 같이 생성하면 됩니다.
여기서 response의 data, response, error가 바로 “통신 잘 했다고 치고 받은 임의의 데이터”들입니다.
우리가 테스트할 것은 통신해서 받은 내용을 우리의 함수가 잘 다루는지 테스트해봐야 합니다.
func testURLSesionTest() throws {
// given
let response: Response = {
let data = Data("[9]".utf8)
return (data, nil, nil)
}()
sut.urlSession = URLSessionMock(data)
let promise = expectaion("mock test")
// when
sut.startNewRound() {
// then
XCTAssert(sut.targetValue, 9)
promise.fulfill()
}
wait(for: [promise], timeout: 5)
}
데이터로 받은 “[9]”로부터 targetValue 변수에 잘 실리는지 확인하는 테스트입니다.
임의의 데이터를 언제, 어떻게 넣는지를 포인트로 공부해봤습니다.
'iOS > Swift' 카테고리의 다른 글
[iOS - Swift] 의존 관계 역전 원칙 (DIP, Dependency Inversion Principle) (0) | 2023.03.15 |
---|---|
[iOS - Swift] TDD를 위한 Unit Test 코드 작성하기 (Feat. XCTest) (0) | 2023.03.08 |
[iOS - Swift] XCTest FAQ (XCTest를 공부하다 생긴 궁금증들) (0) | 2023.03.07 |
[iOS - Swift] static func vs func (0) | 2023.03.06 |
[Swift] Struct, Initializer (구조체와 이니셜라이저) (0) | 2022.05.10 |