[Swift] Mock 을 이용한 Network Unit Test 하기
들어가기 전에
개-하! 테스트 코드를 공부하다 보면 “테스트 대상은 외부 환경에 의존하면 안됨!” 이라는 내용을 한번쯤은 봤을 거예요.
못봤다면..? 어쩔 수 없구여 뭐 ㅋㅎ.. 튼 여기에서 말하는 외부 환경의 대표적인 예로는 네트워크 통신을 들 수 있는데요!
만약 “네트워크 통신해서 받아온 data 를 제대로 디코딩하나?” 를 테스트 하기 위해 실제 네트워크 요청을 한다면 어떤 상황이 발생할 수 있을까요?
실제 api 콜을 때리기 때문에 느릴테고, 네트워크 환경에 따라 통신이 실패할 수도 있고, DB의 데이터가 변경되어서 테스트가 실패할 수도 있습니다.
심지어 테스트를 위해 보내는 요청이 post 라면? 테스트 코드가 실제 서버의 프로덕션 환경을 오염시킬 수 있습니다.
또 다른 예시로 “status code 가 400번대 이상이라면 failure 로 처리했는데, 이게 제대로 처리가 되나??” 를 테스트 하려면 어떻게 해야할까요?? 와이파이를 끄고 요청을 보내는 식으로 테스트를 진행해야할까요?
사실 우리가 테스트하고 싶은건 네트워크 요청을 통해 받아온 데이터를 기준으로, 우리 코드가 예상한대로 동작하는지 테스트하고 싶은건데 말이죠!! 👀
즉 응답이 “A” 와 “B” 라는 값을 담아 제대로 오는지를 테스트하고 싶은게 아니라 (이건 실제 api 테스트), “A” 와 “B” 라는 응답이 온 상태에서, 우리는 그 값을 의도한대로 잘 처리하는지를 테스트하고 싶은거져 (ex. 디코딩은 잘 하나? 이상한 데이터가 들어오면 에러 핸들링은 잘 하나?)
따라서 네트워크 요청을 하는 척~ 하지만!! 실제로는 네트워크 요청 없이 우리가 예상하는 데이터를 바로 돌려주는 기능을 만들어야합니다.
다 필요 없고 여기서 설명하는 코드만 보고 싶다면 바로 이곳으로 이동 하세여
Mock 객체 만들기
MockURLSession
Alamofire 나 Moya 같은 라이브러리를 사용하지 않는다는 가정 하에 우리는 보통 URLSession.shared
이라는 URLSession
객체를 얻는 것을 시작으로 본격적인 네트워킹 코드를 작성할거예요.
그리고 URLSession
의 dataTask 함수를 이용해 URLSessionDataTask
을 얻고, 이걸 resume
해서 실제 네트워킹을 시작하는디요!
코드를 보니 대충~~ 기억이 떠오르쉴지???
이제 저 코드로 유추해 볼때, 우리가 test 를 위해 원하는 결과를 돌려받기 위해서는 URLSessionDataTask
가 resume()
될 때, 바로 원하는 결과 값을 담아 dataTask
의 completionHandler
를 호출하면 됨이라는 생각을 할 수 있는데요!
ㅎㅎㅎ 사실 저런 생각은 바로 안나는게 정상 아닐까요..? ◠‿◠ 저도.. 뭐 구글링하고 온갖 자료 보면서 이제서야 조금 정리되는거지..ㅎㅎ 만약 바로 떠올리셨다면…! 역시 천재들은 재수 없다…!!!! 쉬익..
어차피 밑에서 계속 설명할거니까 언제나 그랬듯 지금 이해가 안가도 괜찮아여 ㅎㅎ 그럼 킵 고잉 ㄱㄱ
우리가 제일 먼저 시작할거는 URLSession
을 바꿔치기 하는 일이에요. 아까 말했져?? 우리는 보통 URLSession.shared
이라는 URLSession
객체를 얻는 것으로 네트워크 코드 작성을 시작한다고!
네트워킹을 시작하기 위해 dataTask 의 resume 을 호출한다든가, 이를 위해 먼저 URLSessionDataTask
객체를 얻는다든가..하는 모든 일들은 어차피 URLSession
을 얻는 것으로부터 시작됩니다.
따라서 이 URLSession
을 test 환경에서는 다른 걸 사용할 수 있게 init
에서 주입받을겁니다
다른건 다 똑같고 URLSessionProtocol
을 conform 하는 객체를 주입받아서 session 으로 사용하고 있어요. 디폴트는 URLSession.shared
로 설정해주었습니당
여기서 URLSessionProtocol
이 뭐냐하면 URLSession
의 instance method 인 dataTask(with:completionHandler:)
랑 동일한 시그니처를 정의해둔 protocol 이에요.
원래
URLSession.shared.dataTask(with: url) { ~~ }
처럼 작성하던 코드를 URLSessionProtocol
을 conform 하는 인스턴스를 주입받아서
session.dataTask(with:url) { ~~ }
처럼 똑같은 형식으로 사용할 수 있어야하니까요!
그리고 URLSessionProtocol
로 받는 인스턴스는 기본 URLSession
도 포함되어야하기 때문에, URLSession
도 URLSessionProtocol
을 conform 해야겠져? 얘는 extension 으로 정의해줬어욤
이제 테스트코드에서 URLSession.shared
대신 사용할 URLSession
의 Mock 만들건디용!
참고로 Mock 은 호출에 대해 예상하는 결과를 받을 수 있도록 미리 프로그램 된 오브젝트입니다.
흑흑.. Test Double 공부해야하는데.. 일단 링크만 냅다 놓고 미래의 나에게 맡겨두기.. Test Double / Stubbing, Mocking or Faking
튼 얘가 URLSessionProtocol
을 conform 하려고 보니 구현해야하는 dataTask(with:completionHandler:)
함수가 URLSessionDataTask
를 반환해야 하네요??!
우리가 원하는건
- 여기서 반환하는
URLSessionDataTask
의resume()
을 호출할 때, dataTask(with:completionHandler:)
에서 인자로 받은completionHandler
를 원하는 결과 값을 담아 호출
하는 거였죠?!!
즉 resume
호출이 실제 api call 을 때리는게 아니고, 때려서도 안되기 때문에!! URLSessionDataTask
에 대한 Mock 객체도 만들어주겠음요
MockURLSessionDataTask
얘는 URLSessionDataTask
를 상속 받고 resume
함수를 override 할거예요. 그리고 이 안에서는 resumeHandler
를 실행할겁니다. resumeHandler
는 resume
호출 시 같이 실행하기 위해 미리 넘겨 받은 클로저예욤
일초 전에 resumeHandler
는 resume
호출 시 같이 실행하기 위해 미리 넘겨 받은 클로저라고 했져??
따라서 resumeHandler
가 호출될 때 내부에서 실행될 코드로는!! 원하는 결과 값을 담아 dataTask(with:completionHandler:)
에서 인자로 받은 completionHandler
를 호출하는 코드를 넘겨주면 돼염
바로 바로 이렇게!
자 끝났음요!!!
방금 MockURLSessionDataTask
을 만들기 위해 URLSessionDataTask
를 상속받고 resume
함수를 override 했잖아여?
근데 얘도 별도의 protocol 을 선언하는 식으로 구현할 수 있어요! 아까 전에 MockURLSession
을 만들기 위해 URLSessionProtocol
을 따로 선언하고 이걸 conform 한 것 처럼요!
비슷하게 MockURLSession
을 만들기 위해 URLSessionProtocol
을 conform 하는 대신, 그냥 URLSession
을 상속 받고, dataTask
함수를 override 하는 식으로 만들 수도 있구요
단 subclass 를 통해 Mock 객체를 만드는 것은 Apple이 이러한 클래스를 변경했을 때 바로 영향을 받을 수 있다는 것을 의미합니다.
둘 다 subclass 로 구현한 것 / 저처럼 protocol, subclass 섞어서 구현한 것 / 둘 다 protocol 로 구현한 것에 대한 글은 Mocking Network Calls in Swift 을, 코드는 MockingURLSession 과 NetworkingUnitTest 를 참고하세용
제가 섞어서 한 이유는,, 이것도 해보고 저것도 해보고 싶어서?! ㅋㅎ
테스트 코드 작성
그럼 이제 테스트 코드 작성해봐야겠죠..???????
자자.. 이 fetchData
에서 뭘 검증하고 싶냐…
디코딩 후 원하는 객체에 값을 담아 반환하는지?
일단 dataType
을 넘기면, fetchData
함수 안에서 제대로 디코딩 후 원하는 객체에 값을 담아 반환하는지 확인 하고 싶네여??
우선 저는 샘플 json 데이터로 https://api.sampleapis.com/coffee/hot 에서 반환되는 값을 긁어와서 json 파일로 저장해 뒀구염
요 파일을 Data
type 으로 만들고, 응답되길 원하는 HTTPURLResponse
객체와 함께 mockResponse
를 생성해줬습니다.
요 값을 제가 그대로 받고 싶은거니까 MockURLSession
의 response 에 넣어서 미리 세팅해주고! 이렇게 생성된 MockURLSession
을 실제 NetworkManager
의 session 에 주입합니다.
마지막으로 fetchData
함수를 통해 받아온 결과 값이 json 파일 데이터를 바로 디코딩 한 값과 같나? 를 확인했습니다.
예상대로 잘 동작하는지 확인하기 위해서 count 값이 같은지.. first 에 할당 된 값이 같은지.. 정도를 비교했습니다.
XCTAssertEqual(result, expectation)
과 같은 코드를 작성하기 위해서는 Coffee
가 Equatable
을 conform 해야하는데.. test code 를 위해 굳이 거기까지..? 라는 생각이 들어서.. 말이죠..?!!
참고로 JsonLoader
는 제가 따로 만든 class 예염
전체 코드로 보면 이렇게 됩니당
잘못된 dataType 을 넘겼을때 원하는 에러를 반환하는지?
또 뭘 테스트해볼 수 있을까.. 생각해보니 디코딩 에러일때 제가 NetworkError.failToParse
에러를 반환하게 해놨거든여
그러니까 일부러 dataType
에 잘못된 타입을 넘기고 여기서 반환된 에러가 failToParse
인지를 확인해 볼 수도 있을거 같아요!
given 쪽은 똑같고 when 이랑 then 쪽만 약간 변경해서 [Coffee]
가 아닌 Coffee
로 데이터 타입을 넘겨봤어요!
statusCode 가 500 이면 에러를 반환하는지?
흠.. 또 statusCode 가 200...<400
사이 값이 아니면 fail 처리를 잘 하는지도 확인해 보면 좋을 것 같네여
이번에는 뭔 에러인지는 신경쓰지 않고 그냥 error 가 nil 이 아닌가만 체크해 봤어염
근데 쓰다보니 mockURLSession
생성을 위한 중복 코드가 너무 많아서 MockURLSession
의 static func 으로 해당 객체를 반환하는 함수를 만들어도 좋을 것 같아욤
그럼 사용할때는 요런식으로 좀 더 짧게 쓸 수 있겠져??
프로퍼티에 적합한값 세팅되나
NetworkManager
의 fetchData
함수 자체를 테스트하는 것 외에도, ViewController 에서 데이터를 가져온 후에 원하는대로 값을 잘 설정 하는가를 테스트해볼 수도 있을 것 같아요..?
예를 들어 coffees
를 가져온 후에 userGuideDescription
설정 해주는 코드가 ViewController 에서 있다고 할때
테스트 코드에서는 이런 식으로 프로퍼티에 원하는 형식의 값이 할당되는지 확인 할 수 있을거 같기두 하고..
근데 보다 보니까, 값을 테스트 하기 위해서 ViewController 를 로드하는 것도 맘에 안들구, ViewController 입장에서는 사실 private 으로 숨길 수 있는 getCoffees
나 userGuideDescription
가 테스트를 위해 internal 로 뚫려있는 것도 맘에 안들구하네요…?
흠.. 그래서 ViewModel 을 사용하는 걸까여?? 한번 ViewModel 로 빼내보겠음요 ㅇㅇ
ViewController 에서 있었던 코드랑 거의~ 같지만, internal 접근자로 설정되어있는게 이제는 거슬리진 않아여! 왜냐면 ViewController 에서 당연히 가져다가 써야하는 값이니까?!
테스트 코드에서도 이제 ViewModel 만 생성해서 테스트를 해보면 되겠져?
근데 또 쓰다 보니 네트워크 호출 후 userDescription
등 다른 값들이 잘 설정되는지를 이렇게 한번에 테스트하는게 맞나,,? 싶기도 해요.
제 의식의 흐름은 이렇습니다.
🙄 사실 네트워크 통신으로 값이 들어오는 것과, 이후에 그 값을 어딘가에 세팅해주는거는 다른 문제 아닌가? NetworkManager
에서 Data 를 잘 처리하는게 검증이 된 상태라면, userGuideDescription
에 데이터 세팅 등의 네트워크 이후에 호출되는 함수는 별도로 테스트 코드를 작성해야하는게 아닌가???? 흠.. 그럼 또 userGuideDescription
세팅하는 함수를 internal 로 풀어야하네?? 그냥 방금 쓴 예시처럼 ‘getCoffees
한번 호출 -> 네트워크 데이터 잘 처리 & 이후 값 원하는대로 세팅’ 을 한번에 검증하는게 맞는건가?????
혹시 읽으신 분 계신가요??? 어렵네여 어려워!!!!!!!!!!!! 저는 그냥 얼레벌레 테스트 코드를 공부하고 있는 사람 1 이기 때문에 훈 수 대 환 영 입니다.
끝나지 않은 이야기.. 🫠🫠🫠
설명도 다 끝난거 같은데 뭔 놈의 끝나지 않은 이야기냐구요??????? 제말이요 ㅋㅋㅋ..
ㅋㅋ 열어분.. 아까 MockURLSessionDataTask
만들때 떴던 노란색 warning 다들 기억하시는지? ㅋㅋ
저는 얘 처음에 떴을때 워닝?ㅋ 에러도 아니고ㅋ 나중에 해결해야지~ 흣냐흣냐흣냐~~ 하고 가볍게 무시한 뒤에 넘어갔어요
누가 알았을까요..? ◠‿◠ 이게 이렇게 큰 업보가 되어 돌아올지..
사실 얘는 전체적으로 보면 이런 에러인데요..ㅎ
'init()' was deprecated in iOS 13.0: Please use -[NSURLSession dataTaskWithRequest:] or other NSURLSession methods to create instances
얘를 이제 해결하려고 구글링 해보니까 ㅋㅋ.. 저와 같은 사람이 많았는지 URLSessionDataTask
상속 받아서 Mock 만드려고 하는데 이런 에러가 뜬다~어떻게하냐~~ 라는 내용을 쉽게 볼 수 있더라구요
근데 해결책은?? 아예 새롭게 URLProtocol
을 상속받고 얘의 Mock 을 만들어서 테스트하더라구요..???
- Mocking Network Calls Using URLProtocol
- Chapter 8 init() deprecated in iOS 13
- Mock URLProtocol for local unit testing
막상 보면 코드가 그렇게 어렵진 않은거 같은데... 중요한건 내부적으로 호출되는 함수가 async 로 도는건지 테스트 코드에서 wait(for:,timeout:)
안걸어주면 XCTAssert
구문 먼저 실행돼서 다 fail 뜸여;;
이 뿐만이 아니라 “ViewController 나 ViewModel 의 property 에 값이 제대로 할당 되었나” 테스트 하는 코드에서도 wait(for:,timeout:)
을 걸고 사용해야하기 때문에
아래처럼 다 completionHandler
호출하게 고쳐줘야하더라구요;; ㅠ?? 애초에 async / await 사용해서 네트워크 함수 짰으면 괜찮았으려나 ㅜ
아니~~!! 이건 아니잖아!!! 처음부터 워닝을 잘 봐야했던걸까여억..?
귀찮다 귀찮아~~ 어떻게 하지~ 라고 생각을 하다가! 지금 나는 에러가 URLSessionDataTask
을 상속 받으면서 생기는거니까, 상속 대신 URLSession
Mock 을 만들때처럼 그냥 protocol 만들고 conform 하면 되는거 아녀?!?? 라는 생각이 들었어염
MockURLSessionDataTask
를 URLSessionDataTask
의 상속이 아닌, protocol conform 으로 변경했으니, resume 앞에 override
키워드를 제거했구요
URLSessionProtocol
의 dataTask
함수에서 URLSessionDataTask
를 반환하던 부분을 URLSessionDataTaskProtocol
을 반환하도록 변경했습니다
URLSessionProtocol
을 변경하면서, 이를 conform 하는 URLSession
또한 수정이 필요했는데요! 왜냐면 URLSession
에는 URLSessionDataTaskProtocol
을 반환하는 기본 메서드가 없기 때문이져
따라서 해당 함수를 선언해주고, 이 안에서는 자신의 dataTask(with:completionHandler:)
함수를 호출하게 했습니다
나머지는 뭐 다 비슷 비슷~~! 컴파일 에러 좀좀따리 나는거 고쳐주면 됨요!
이렇게 NetworkingUnitTest 코드를 참고해서 URLSession
, URLSessionDataTask
모두 protocol 을 conform 하는 식으로 이슈 해결!!!
제가 지금까지 설명한 모든 코드는 github에서 확인 할 수 있습니다
마무리
좋아 이제 Test Double 이랑~~ 기타 등등 공부할게 산더미긴 하지만!!! 일단 오늘의 저는 여기까지 ㅋㅎ
아니 해낸게 맞나…? 저 제대로 짠거 맞아여 님덜…? ㅜ 일단 여기까지가 저의 최선이었구요! 조언은 댓글로 받습니다!
그럼 20000!