cchanmi
[iOS] Moya Network, ViewModel Test Code 코드 작성 본문
안녕하세요. 이번에 Moya를 이용해 test code를 작성하게 되어서 블로그에 기록하려고 해요.
생각보다 레퍼런스가 많지는 않았어서 구현하는 데까지 시간이 꽤 걸렸던 것 같네요.
덕분에 Moya가 test code 작성할 때의 편리함을 제공해 준다는 사실을 알게 되었던 것 같네요. :)
제가 이번에 테스트 하고 싶었던 부분은 네트워크 통신이 일어나는 Service 파일과, ViewModel 테스트였습니다.
먼저 Service 파일 test부터 시작해 보겠습니다.
먼저 우리가 네트워크 test code를 작성해서 얻으려는 목적이 무엇인지 생각해 보겠습니다.
1. 응답으로 온 Json이 원하는 model 잘 decode 되는지
2. network 실패시 상황에 맞는 에러 핸들링이 잘되는지
와 같은 것들을 test 하고 싶을 거예요.
그러기 위해서 실제 서버 API를 콜하는 것은 시간이 오래 걸릴 수도 있고, 환경에 따라서는 통신에 실패할 수도 있으며, 실제 DB를 건드리는 것이기 때문에 원하지 않는 결과가 내려올 수 있어요.
결국에는 우리가 확인하고 싶은 건 어떠한 Input이 들어왔을 때, 기대한 Output이 잘 나오는지!!!에 대한 것이기 때문에, 실제 서버가 아닌 Mock 객체를 만들어서 바꿔치기를 해 줍니다.
코드로 확인해 볼게요.
네트워크 통신을 요청하고 response를 받아오는 Service test
final class OnboardingService: OnboardingServiceProtocol {
var provider: MoyaProvider<OnboardingEndPoint>!
init(provider: MoyaProvider<OnboardingEndPoint> = MoyaProvider<OnboardingEndPoint>()) {
self.provider = provider
}
func trainingGoalGetAPI(completion: @escaping (Result<[Goal], SmeemError>) -> ()) {
provider.request(.trainingGoal) { response in
switch response {
case .success(let result):
do {
try NetworkManager.statusCodeErrorHandling(statusCode: result.statusCode)
guard let data = try? result.map(GeneralResponse<TrainingGoalResponse>.self).data?.goals else {
throw SmeemError.clientError
}
completion(.success(data))
} catch let error {
guard let smeemError = error as? SmeemError else { return }
completion(.failure(smeemError))
}
case .failure(_):
completion(.failure(.userError))
}
}
}
}
Onboarding에 사용되는 API들이 모인 Service 파일입니다. Serivcec 파일 초기화시, provider를 의존성 주입받고 있어요.
기본적으로는 실제 서버에 대한 EndPoint가 담긴 값을 초기화시 넣어 주고 있습니다.
하지만 우리는 짜놓은 로직에 어떠한 Input이 들어왔을 때, 기대한 Output이 나오는지를 판단하는 것이 목적이기 때문에, 실제 서버에 대한 EndPoint가 아닌 Mock을 만들어서 테스트를 진행할 겁니다.
먼저 EndPoint의 sample 데이터로 mock으로 받을 response를 추가해 줍시다.
var sampleData: Data {
switch self {
case .trainingWay:
return Data(
"""
{
"success": true,
"message": "학습 목표 조회 성공",
"data": {
"name": "자기계발",
"way": "주 5회 이상 smeem 랜덤 주제로 일기 작성하기",
"detail": "사전 없이 일기 완성\nsmeem 연속 일기 배지 획득"
}
}
""".utf8
)
}
}
그리고 testCode 작성을 해 봅시다!
func makeProvider() -> MoyaProvider<TargetEndPoint> {
let endpointClosure = { (target: TargetEndPoint) -> Endpoint in
return Endpoint(url: target.path,
sampleResponseClosure: { .networkResponse(200, target.sampleData) },
method: target.method,
task: target.task,
httpHeaderFields: target.headers)
}
return MoyaProvider<TargetEndPoint>(endpointClosure: endpointClosure,
stubClosure: MoyaProvider.immediatelyStub)
}
Moya에서 제공하는 stub closure입니다.
target을 받고, 아까 전달받은 target.sample data를 response로 받게 됩니다.
성공한 경우만이 아닌, statusCode 400, 500일 때의 경우도 테스트 해 볼 수 있습니다.
테스를 시작하기에 앞서, 가짜 mock response를 내려 주는 provider를 생성해 아까 OnboardingSerive 생성시 주입해 줍니다.
final class OnboardingServiceTest: XCTestCase, MockProviderProtocol {
typealias TargetEndPoint = OnboardingEndPoint
var sut: OnboardingService!
override func setUpWithError() throws {
let mockProvider: MoyaProvider<OnboardingEndPoint> = makeProvider()
sut = OnboardingService(provider: mockProvider)
}
}
그러면 우리가 작성한 sample data response가 내려오는 가짜 provider가 주입된 OnboardingService 파일을 사용할 수 있습니다!
func test_goalList_성공했을때() {
let expectation = XCTestExpectation(description: "request")
var outputResult: [Goal]!
let expeactedResult = goalModel
sut.trainingGoalGetAPI { result in
switch result {
case .success(let response):
outputResult = response
expectation.fulfill()
case .failure(let error):
print(error)
}
}
wait(for: [expectation], timeout: 0.5)
XCTAssertEqual(outputResult, expeactedResult)
}
학습 목표 list를 가져오는 API를 테스트 해 보았습니다. 해당 API 통신 후, 기대하는 output와 mock response가 같을 경우 테스트를 통과하는 코드입니다.
func test_서버에러일때() {
var expeacted: SmeemError!
do {
try NetworkManager.statusCodeErrorHandling(statusCode: 500)
} catch {
guard let error = error as? SmeemError else { return }
expeacted = error
}
XCTAssertEqual(expeacted, SmeemError.serverError)
}
func test_클라에러일때() {
var expeacted: SmeemError!
do {
try NetworkManager.statusCodeErrorHandling(statusCode: 400)
} catch {
guard let error = error as? SmeemError else { return }
expeacted = error
}
XCTAssertEqual(expeacted, SmeemError.clientError)
}
에러인 경우는 상황에 맞는 statusCode 전달시, 그에 맞는 error를 throw 해 주기 때문에, 해당 메서드가 알맞는 error를 throw하는지를 위주로 테스트해 주었습니다!
모두 문제 없이 잘 동작했네요 😋
ViewModel Test
이번에는 ViewModel에서의 서버 통신을 테스트해 보겠습니다.
Input이 들어오면 ViewModel이 서버 통신을 대신해 주는데요. 해당 ViewModel이 서버 통신 후, 짜여진 로직에 맞게 테스트 해 보는 코드를 작성해 보겠습니다.
protocol OnboardingServiceProtocol {
func trainingGoalGetAPI(completion: @escaping (Result<[Goal], SmeemError>) -> ())
func trainingWayGetAPI(param: String, completion: @escaping (Result<TrainingWayResponse, SmeemError>) -> ())
...
}
먼저 OnboardingServiceProtocol을 생성합니다.
해당 Protocol을 실제 OnboardingService랑, MockOnboardingService 파일에 채택하여서 실제 서버 통신이 필요할 때는 실제 service를, 테스트가 필요할 때에는 ViewModel에 mock을 주입할 거예요!
final class OnboardingServiceMock: OnboardingServiceProtocol {
func trainingGoalGetAPI(completion: @escaping (Result<[Smeem_iOS.Goal], Smeem_iOS.SmeemError>) -> ()) {
completion(.success([Goal(goalType: "test", name: "안녕")]))
}
func trainingWayGetAPI(param: String, completion: @escaping (Result<Smeem_iOS.TrainingWayResponse, Smeem_iOS.SmeemError>) -> ()) {
completion(.success(TrainingWayResponse(name: "test",
way: "test",
detail: "test")))
}
}
먼저 OnboardingServiceMock 파일을 생성하고, 방금 말한 프토콜을 채택해 줍니다.
그리고 기대한 response를 completion로 전달해 줍니다!
final class TrainingAlarmViewModelTest: XCTestCase {
var provider: OnboardingServiceMock!
var viewModel: TrainingAlarmViewModel!
var cancelBag: Set<AnyCancellable>!
override func setUpWithError() throws {
self.provider = OnboardingServiceMock()
self.viewModel = TrainingAlarmViewModel(provider: provider)
self.cancelBag = Set<AnyCancellable>()
}
}
그러고 ViewModel 테스트시, 해당 mock 파일을 ViewModel 초기화시 넣어 줍니다.
그렇다면, ViewModel의 서버 통신시, 실제 서버의 API를 통신하는 게 아닌, 이전에 작성한 mock response의 결과가 내려오게 됩니다!
mock response의 결과가 짜여진 로직에 맞게 잘 처리되고, 바인딩까지 잘되는지 확인해 볼 수 있겠네요 :)
func test_alarmAPI_잘호출되는지() {
// Given
let expectation = XCTestExpectation()
// When
output.nicknameResult
.sink { _ in
expectation.fulfill()
}
.store(in: &cancelBag)
input.nextFlowSubject.send(())
// Then
wait(for: [expectation], timeout: 0.5)
}
전체 코드는 이렇습니다.
input nextFlowSubject로 데이터를 흘려보냈을 때, ViewModel에서 alamAPI를 잘 호출하고, 주어진 로직대로 잘 진행된 원하는 output으로 데이터가 전달되는지를 test 하는 코드입니다.
현재 운영 중인 smeem이라는 서비스를 개발해 나가면서 QA를 자주 했었는데요.
이러한 버튼을 눌렀을 때, 이러한 결과가 나오는지... 이런 상황을 시뮬레이션 하면서 직접 버튼을 눌러보고, 글을 작성해 보는 등 "누구나 할 수 있는 방법"으로 테스트를 진행해 왔던 것 같아요.
그동안은 별다른 생각을 하지 못하였는데, 어느 날 갑자기 개발자답게 test를 하고 싶다는 생각이 들기 시작했고, test code를 꼭 작성해 보자!가 이번 스프린트의 목표였습니다.
처음에는 기대하던 레퍼런스가 많이 있지 않아서 헤매게 되었지만, 프토토콜을 의존성 주입하는 이유, Moya가 test가 편리한 이유 등등... test code를 작성하는 것 그 이상의 것을 배웠다고 생각이 들었어요.
이러한 좋은 경험을 발판 삼아 더더욱 성장할 수 있도록 노력해야겠다는 생각이 들었습니다 😇
'iOS' 카테고리의 다른 글
[iOS] CollectionView Cell 재사용에 따른 중복 binding 이슈 해결 (0) | 2024.08.31 |
---|---|
[iOS] TextView Responder 권한이 남아 있어 Keyboard 감지 Notification이 중복 호출 되는 이슈 해결 (0) | 2024.08.04 |
[iOS] 토큰 만료 플로우 이슈 해결 (0) | 2024.06.14 |
[iOS] 싱글톤 패턴과 스레드 (0) | 2024.03.25 |
[iOS] 운영 앱 서비스 Network Error handling (1) | 2024.01.28 |