ios dev kangwook.

iOS) Xcode Unit Test 본문

iOS

iOS) Xcode Unit Test

kangwook 2022. 10. 10. 17:45

테스트 주도 개발은 중요한 건데 아직까지 Xcode에서 Unit Test를 사용하는 게 익숙치 않아서 한 번 정리하려고 한다.

Sample App : BullsEye

  • 랜덤으로 제공되는 숫자를 슬라이더 값으로 맞추는 게임 앱
  • 숫자는 api를 통해 받아옴

주요 코드

  • 테스트를 위해 의도적으로 틀린 부분이 있음
import Foundation
 
class BullsEyeGame {
    var round = 0
    let startValue = 50
    var targetValue = 50
    var scoreRound = 0
    var scoreTotal = 0
 
    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()
            }
        }
    }
 
    @discardableResult
    func check(guess: Int) -> Int {
        let difference = guess - targetValue    // 기존 코드와 다른 부분
        scoreRound = 100 - difference
        scoreTotal += scoreRound
        return difference
    }
 
    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()
    }
}

테스트할 대상 파악

  • 일반적으로 테스트는 다음을 포함해야 함
    • 핵심 기능 : 모델 클래스, 메서드 및 컨트롤러와의 상호 작용
    • 가장 일반적인 UI workflow
    • 경계 조건
    • 버그 수정

테스트 작성

  • 테스트 메서드의 이름은 항상 test로 시작
  • given - when - then 으로 섹션 지정(AAA와 유사)
func testScoreIsComputedWhenGuessIsHigherThanTarget()  {
    // given
    let guess = sut.targetValue + 5
 
    // when
    sut.check(guess: guess)
     
    // then
    XCTAssertEqual(sut.scoreRound, 95, "guess로 계산된 점수가 잘못되었습니다.")
}
 
func testScoreIsComputedWhenGuessIsLowerThanTarget() {
    // given
    let guess = sut.targetValue - 5
 
    // when
    sut.check(guess: guess)
 
    // then
    XCTAssertEqual(sut.scoreRound, 95, "guess로 계산된 점수가 잘못되었습니다.")
  • testScoreIsComputedWhenGuessIsLowerThanTarget() 테스트를 진행할 때 통과하지 못함
    • guess = targetValue - 5 이지만 scoreRound는 95가 아니라 105가 되기 때문
    • 이에 대한 디버깅을 하기 위해서는 일반 디버깅 프로세스를 통해 알아낼 수 있음
    • check(guess: )함수에서 difference가 음수값이 될 수 있음
// ...
    @discardableResult
    func check(guess: Int) -> Int {
        let difference = guess - targetValue
        scoreRound = 100 - difference
        scoreTotal += scoreRound
        return difference
    }
// ...

  • difference가 음수가 되기 때문에 점수가 100 - (-5) = 105
  • 따라서 difference의 절대값을 사용해야 함
let difference = abs(targetValue - guess)

XCTestExpectation을 사용하여 비동기 작업 테스트

BullsEyeGame은 URLSession을 사용하여 다음 게임의 대상으로 임의의 숫자를 가져옴

URLSession 메서드는 비동기 방식이고 즉시 반환되지만 실행이 완료되지는 않음

비동기 테스트는 일반적인 테스트보다 느리므로 더 빠른 단위 테스트와 별도로 격리해야 함

BullsEyeSlowTests라는 새 단위 테스트 대상 생성

class BullsEyeSlowTests: XCTestCase {
    var sut: URLSession!
    let networkMonitor = NetworkMonitor.shared
 
    override func setUpWithError() throws {
        try super.setUpWithError()
        sut = URLSession(configuration: .default)
    }
 
    override func tearDownWithError() throws {
        sut = nil
        try super.tearDownWithError()
    }
 
    func testValidApiCallGetsHTTPStatusCode200() throws {
        // given
        let urlString = "http://www.randomnumberapi.com/api/v1.0/random?min=0&max=100&count=1"
        let url = URL(string: urlString)!
     
        // expectation(description:) : promise에 저장된 XCTestExpectation을 반환
        let promise = expectation(description: "Status code: 200")
         
        // when
        let dataTask = sut.dataTask(with: url) { _, response, error in
            // then
            if let error = error {
                XCTFail("Error: \(error.localizedDescription)")
                return
            } else if let statusCode = (response as? HTTPURLResponse)?.statusCode {
                if statusCode. == 200 {
                     
                    // promise.fulfill() : 비동기 메서드 완료 핸들러의 성공 조건 클로저에서 이것을 호출하여 expectation이 충족되었음을 플래그로 표시
                    promise.fulfill()
                } else {
                    XCTFail("Status code : \(statusCode)")
                }
            }
        }
        dataTask.resume()
         
        // wait(for:timeout:) : 모든 expectation이 충족되거나 시간 초과 간격(timeout)이 끝날 때까지 중 먼저 발생하는 시점까지 테스트를 계속 실행
        wait(for: [promise], timeout: 5)
    }
     
    func testApiCallCompletes() throws {
        // 전제 조건이 실패할 때 테스트를 건너 뛰기 위한 장치(ex. 네트워크 연결 없이 실행될 경우)
        try XCTSkipUnless(networkMonitor.isReachable, "Network connectivity needed for this test.")
         
        // given
        let urlString = "http://www.notexistrandomnumberapi.com/test"
        let url = URL(string: urlString)!
        let promise = expectation(description: "Completion handler invoked")
        var statusCode: Int?
        var responseError: Error?
 
        // when
        let dataTask = sut.dataTask(with: url) { _, response, error in
            statusCode = (response as? HTTPURLResponse)?.statusCode
            responseError = error
            promise.fulfill()
        }
        dataTask.resume()
        wait(for: [promise], timeout: 5)
 
        // then
        XCTAssertNil(responseError)
        XCTAssertEqual(statusCode, 200)
}

참조

'iOS' 카테고리의 다른 글

iOS) StoreKit (1) - StoreKit이란?  (1) 2022.10.18
iOS) Key-Value Observing  (0) 2022.10.11
iOS) Sandbox  (0) 2022.10.09
iOS) Static / Dynamic Library  (0) 2022.10.04
iOS) Architecture Pattern  (0) 2022.09.29
Comments