[UIKit] Combine: Unit Test & DI

Junyoung Park·2022년 10월 27일
0

UIKit

목록 보기
55/142
post-thumbnail

Combine framework tutorial - Part 4 - How to include unit testing and dependency injection

Combine: Unit Test & DI

구현 목표

  • Combine 프레임워크 사용을 유닛 테스트와 함께 사용하는 방법 익히기

구현 태스크

  • 유닛 테스트 타겟 추가
  • 성공 상황 테스트
  • 실패 상황 테스트

핵심 코드

import Foundation
import Combine
import UIKit

@testable import CombineMapBootCamp

struct APIMockResources: DataProvider {
    var result: Result<Data, APIResources.APIError>
    
    func fetch(url: URL) -> AnyPublisher<Data, APIResources.APIError> {
        result.publisher
            .eraseToAnyPublisher()
    }
}
  • 기존의 뷰 모델이 사용하는 데이터 프로토콜 함수를 통한 네트워킹 상황을 가정하기 위해 생성한 클래스
  • 해당 result를 호출하는 부분에서 필요로 하는 데이터 / 에러 부분을 호출 가능
    var cancellables = Set<AnyCancellable>()
    
    override func tearDown() {
        cancellables = []
    }
  • 테스트 클래스 내에서 선언되는 store에 사용되는 변수
  • 테스트 단위 별로 값을 새롭게 주는 tearDown 메소드 오버라이드
    func testLoadingAlbumAtLaunch() {
        let fetcher = AlbumCollectionViewModel()
        XCTAssertEqual(fetcher.albumSubject.value.count, 0, "starting with no images")
        let promise = expectation(description: "loading 3 images")
        fetcher
            .albumSubject
            .sink { completion in
                XCTFail()
            } receiveValue: { models in
                if models.count >= 10 {
                    promise.fulfill()
                }
            }
            .store(in: &cancellables)
        
        wait(for: [promise], timeout: 5)
    }
  • 현재 뷰 모델이 구독하는 url 정보에 따라 앨범을 다운로드받는 상황
  • 뷰 모델 시작 시 (즉 네트워킹 이전) 다운로드한 데이터 개수가 0임을 확실히 XCTAssertEqual
  • 네트워킹 실패 시 곧바로 XCTFail
  • 네트워킹을 통해 다운로드받은 데이터의 개수가 10개 이상일 경우 테스트 성공을 의미하는 promisefulfill() 메소드 실행
  • 해당 가정을 실현하기까지 5초를 기다리고 있는 wait 메소드
func testLoadingMoreThanThreeImages() {
        let fetcher = AlbumCollectionViewModel()
        let promise = expectation(description: "loading 5 images")
        fetcher
            .albumSubject
            .sink { models in
                if models.count > 5 {
                    for _ in 0..<5 {
                        if let model = models.randomElement() {
                            print("Sending")
                            fetcher.imageUrlSubject.send(model.thumbnailUrl)
                        }
                    }
                }
            }
            .store(in: &cancellables)
        fetcher
            .imagesSubject
            .dropFirst()
            .drop { images in
                images.count == 0
            }
            .output(in: 1...3) // publishes 5 values one by one
            .collect(3) // publishes 1 array of passed values
            .contains(where: { images in
                images.count == 3
            })
//            .allSatisfy({ images in
//                images.count > 0
//            })
            .sink(receiveValue: { value in
                promise.fulfill()
            })
            .store(in: &cancellables)
        
        wait(for: [promise], timeout: 5)
    }
  • 현재 뷰 모델에서는 앨범 데이터를 네트워크를 통해 모두 다운로드받은 뒤, 터치 이벤트를 통해 해당 앨범에 해당하는 이미지를 하나씩 다운로드받는 상황
  • 이미지 다운로드가 앨범 선택 시 적어도 세 번 이상 연속적으로 성공하는지 확인하는 테스트 함수
  • 5번의 앨범을 선택, 해당 이미지를 다운로드하는 데 사용할 URL 문자열을 imageUrlSubject에 전송. 뷰 모델 이니셜라이즈 단에서 해당 퍼블리셔에 값이 들어올 경우 이미지를 다운로드 및 패치
  • 이미지 다운로드 성공 이후 최초의 데이터를 받지 않고 첫 번째부터 3번째 데이터만을 아래로 내보내고 3개의 데이터를 모으는 퍼블리셔 구독 상황. contains 부분에서 collect를 통해 모은 여태까지의 데이터 개수가 3개인지를 확인, 이후 promise가 실현되었는지까지 기다리는 데 총 5초 사용하는 테스트 함수.
    func testWhichIsErrorMessage() {
        let mock = APIMockResources(result: .failure(.badResponse(statusCode: 400)))
        let fetcher = AlbumCollectionViewModel(apiResource: mock)
        fetcher
            .albumSubject
            .sink { models in
                if models.count > 1 {
                    if let model = models.randomElement() {
                        print("Sending")
                        fetcher.imageUrlSubject.send(model.thumbnailUrl)
                    }
                }
            }
            .store(in: &cancellables)
        fetcher
            .imagesSubject
            .filter { images in
                images.count > 0
            }
            .sink { image in
                XCTFail("should not have images")
            }
            .store(in: &cancellables)
        let promise = expectation(description: "should get error message")
        fetcher
            .errorMessageSubject
            .filter { error in
                error != nil
            }
            .sink { message in
                promise.fulfill()
            }
            .store(in: &cancellables)
        wait(for: [promise], timeout: 10)
    }
  • URL 정보를 통해 이미지를 다운로드받을 때 fetch 함수를 사용하는 상황 중 실패가 일어날 때를 가정한 테스트
  • 커스텀 에러를 리턴하는 mock을 뷰 모델을 이니셜라이즈하는 데 사용
  • 에러를 동반하기 때문에 해당 URL 값을 통해 이미지를 다운로드하는 데 성공한다면 테스트 실패
  • 뷰 모델 내에 선언한 에러 메시지 데이터 퍼블리셔 값이 널이 아니라면, 즉 에러가 생긴다면 promise가 실현되었다고 가정, 이를 10초 동안 기다림.

소스 코드

import XCTest
import Combine

@testable import CombineMapBootCamp

final class CombineMapBootCampTests: XCTestCase {
    var cancellables = Set<AnyCancellable>()
    
    override func tearDown() {
        cancellables = []
    }
    
    func testLoadingAlbumAtLaunch() {
        let fetcher = AlbumCollectionViewModel()
        XCTAssertEqual(fetcher.albumSubject.value.count, 0, "starting with no images")
        let promise = expectation(description: "loading 3 images")
        fetcher
            .albumSubject
            .sink { completion in
                XCTFail()
            } receiveValue: { models in
                if models.count >= 10 {
                    promise.fulfill()
                }
            }
            .store(in: &cancellables)
        
        wait(for: [promise], timeout: 5)
    }
    
    func testLoadingMoreThanThreeImages() {
        let fetcher = AlbumCollectionViewModel()
        let promise = expectation(description: "loading 5 images")
        fetcher
            .albumSubject
            .sink { models in
                if models.count > 5 {
                    for _ in 0..<5 {
                        if let model = models.randomElement() {
                            print("Sending")
                            fetcher.imageUrlSubject.send(model.thumbnailUrl)
                        }
                    }
                }
            }
            .store(in: &cancellables)
        fetcher
            .imagesSubject
            .dropFirst()
            .drop { images in
                images.count == 0
            }
            .output(in: 1...3) // publishes 5 values one by one
            .collect(3) // publishes 1 array of passed values
            .contains(where: { images in
                images.count == 3
            })
//            .allSatisfy({ images in
//                images.count > 0
//            })
            .sink(receiveValue: { value in
                promise.fulfill()
            })
            .store(in: &cancellables)
        
        wait(for: [promise], timeout: 5)
    }
    
    func testWhichIsErrorMessage() {
        let mock = APIMockResources(result: .failure(.badResponse(statusCode: 400)))
        let fetcher = AlbumCollectionViewModel(apiResource: mock)
        fetcher
            .albumSubject
            .sink { models in
                if models.count > 1 {
                    if let model = models.randomElement() {
                        print("Sending")
                        fetcher.imageUrlSubject.send(model.thumbnailUrl)
                    }
                }
            }
            .store(in: &cancellables)
        fetcher
            .imagesSubject
            .filter { images in
                images.count > 0
            }
            .sink { image in
                XCTFail("should not have images")
            }
            .store(in: &cancellables)
        let promise = expectation(description: "should get error message")
        fetcher
            .errorMessageSubject
            .filter { error in
                error != nil
            }
            .sink { message in
                promise.fulfill()
            }
            .store(in: &cancellables)
        wait(for: [promise], timeout: 10)
    }
}

강의에서 진행한 형식대로 뷰 모델을 구성한 게 아니기 때문에 테스트하고자 하는 대상은 다소 달라졌는데, 최대한 '작은' 단위대로 테스트하는 게 원칙!

profile
JUST DO IT

0개의 댓글