[SwiftUI] Unit Testing

Junyoung Park·2022년 8월 23일
1

SwiftUI

목록 보기
45/136
post-thumbnail
post-custom-banner

Unit Testing a SwiftUI application in Xcode | Advanced Learning #17

Unit Testing

구현 목표

  • 특정 로직이 달라지거나 특정 코드가 변경되는 등 여러 가지 시나리오에 대비하기 위한 단위 테스팅
  • 이니셜라이즈, 메소드, 변수 값이 구현한 의도와 일치되는지 확인하기 → 실제 프로젝트가 아닌 해당 프로젝트를 import한 유닛 테스트를 통해 확인
  • 동기적/비동기적 코드를 테스트하는 방법 확인하기

테스트 작성 방법

  • 메소드 작명 방법: test UnitofWork StateUnderTest ExpectedBehaviortest_[struct or class]_[variable or function]-[expected result]
  • E.g.) UnitTestingBootCampViewModel 클래스의 isPremium이라는 변수 값이 참이어야 할 때, 관련 테스트를 test_UnitTestingBootCampViewModel_isPremium_ShouldBeTrue 정도로 작성할 수 있다.
  • 테스트 내에서는 주어진 조건, 테스트할 상황, 결과값에 따른 확인 세 가지 과정에 따라 일반적으로로 Given, When, Then으로 구별
  • 비동기 데이터 처리인 경우: (1). 이스케이핑 클로저 (2). Combine → 시간이 걸리는 작업이기 때문에 테스트 단위에서도 wait를 통해 특정 작업이 끝날 때까지 '기다리는' 작업이 필요
  • expectation을 작성 후 sink에서 나오는 결괏값에 따라 completionfinish인지 failure인지 알 수 있는데, 원하는 상황에 따라 Assert를 주면 된다.

소스 코드

import SwiftUI
import Combine

class UnitTestingBootCampViewModel: ObservableObject {
    @Published var isPremium: Bool
    @Published var dataArray: [String] = []
    @Published var selectedItem: String? = nil
    let dataService: NewDataServiceProtocol
    var cancellables = Set<AnyCancellable>()
    
    init(isPremium: Bool, dataService: NewDataServiceProtocol = NewMockDataService(items: nil)) {
        self.isPremium = isPremium
        self.dataService = dataService
    }
    
    func addItem(item: String) {
        guard !item.isEmpty else { return }
        dataArray.append(item)
    }
    
    func selectItem(item: String) {
        if let x = dataArray.first(where: {$0 == item}) {
            selectedItem = x
        } else {
            selectedItem = nil
        }
    }
    
    func saveItem(item: String) throws {
        guard !item.isEmpty else {
            throw DataError.noData
        }
        
        if let x = dataArray.first(where: {$0 == item}) {
            print("SAVED ITEM: \(x)")
        } else {
            print("ITEM NOT FOUND!")
            throw DataError.noItemFound
        }
        
    }
    
    func downloadWithEscaping() {
        dataService.downloadItemsWithEscaping { [weak self] items in
            self?.dataArray = items
        }
    }
    
    func downloadWithCombine() {
        dataService.downloadItemsWithCombine()
            .sink { _ in
                
            } receiveValue: { [weak self] returnedItems in
                guard let self = self else { return }
                self.dataArray = returnedItems
            }
            .store(in: &cancellables)
    }
    
    enum DataError: LocalizedError {
        case noData
        case noItemFound
    }
}
mport SwiftUI
import Combine

protocol NewDataServiceProtocol {
    func downloadItemsWithEscaping(completion: @escaping (_ items: [String]) -> Void)
    func downloadItemsWithCombine() -> AnyPublisher<[String], Error>
}

class NewMockDataService: NewDataServiceProtocol {
    let items: [String]
    
    init(items: [String]?) {
        self.items = items ?? ["One", "Two", "Three"]
    }
    
    func downloadItemsWithEscaping(completion: @escaping (_ items: [String]) -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            completion(self.items)
        }
    }
    
    func downloadItemsWithCombine() -> AnyPublisher<[String], Error> {
        return Just(items)
            .tryMap({ publishedItem in
                guard !publishedItem.isEmpty else {
                    throw URLError(.badServerResponse)
                }
                return publishedItem
            })
            .eraseToAnyPublisher()
    }
}
  • 확장성 있는 코드 작성을 위해 프로토콜 단위 코드
  • 유닛 테스트에서도 이니셜라이즈 단에서 의존성 주입을 통해 서비스 클래스 사용
import XCTest
import Combine
@testable import SwiftfulThinkingAdvancedLearning

class UnitTestingBootCampViewModel_Tests: XCTestCase {
    
    var viewModel: UnitTestingBootCampViewModel?
    var cancellables = Set<AnyCancellable>()

    override func setUpWithError() throws {
        viewModel = UnitTestingBootCampViewModel(isPremium: Bool.random())
    }

    override func tearDownWithError() throws {
        viewModel = nil
    }
    
    ...
        func test_UnitTestingBootCampViewModel_downloadWithCombine_shouldReturnItems2() {
        // Given
        let items = Array(repeating: UUID().uuidString, count: 5)
        let dataService: NewDataServiceProtocol = NewMockDataService(items: items)
        let viewModel = UnitTestingBootCampViewModel(isPremium: Bool.random(), dataService: dataService)
        
        // When
        let expectation = XCTestExpectation(description: "Should Return Items after a seconds")
        viewModel.$dataArray
            .dropFirst()
            .sink { returnedItems in
                // Extepctation Observer
                expectation.fulfill()
            }
            .store(in: &cancellables)
        viewModel.downloadWithCombine()

        // Then
        wait(for: [expectation], timeout: 1.0)
        XCTAssertGreaterThan(viewModel.dataArray.count, 0)
        XCTAssertEqual(viewModel.dataArray.count, items.count)
    }
}
  • 특정 테스트에서 공통적으로 사용되는 변수가 존재한다면 setUpWithError를 통해 초기 세팅을 공통적으로, tearDownWithError를 통해 초기화 과정을 해준다.
profile
JUST DO IT
post-custom-banner

0개의 댓글