아, 나도 테스트 코드 짠다! - (1) XCTest를 이용한 테스트 코드 작성

개발을 업으로 삼은지 7년차가 되는 시점이지만, 테스트 코드를 한 번도 작성해보지 못한건 반성해야 할 부분일지도 모르겠다. 일정에 치여서 테스트 코드 같은건 엄두도 못냈고, 거쳐온 회사들은 테스트 코드를 제대로 작성하고 있는 곳이 없었다.

명언 제조기 박명수옹 (47세)

그러다 마침 일정이 비는 시기가 찾아왔고 팀 내부에서도 테스트 코드의 중요성이 언급되기 시작했다. 실제로 프로젝트를 진행하면서 유닛 테스트를 작성하고 싶었지만 어른의 사정으로 인해 못했다는 의견이 많이 나왔다. 이때다 싶어서 현재 진행중인 개인 프로젝트부터 테스트 코드를 작성해보기로 했다.

테스트 타겟 추가

현재 진행중인 개인 프로젝트는 iOS 환경에서 동작하는 뷰어 앱이다. Xcode 에서 테스트 환경을 설정하는건 비교적 간단하게 진행할 수 있다. 구체적인건 밝힐 수 없어서 스크린샷을 많이 가렸다

따로 크게 옵션을 바꿔줄 건 없고 Finish 버튼을 누르면 다음과 같은 코드가 생성된 것을 확인 할 수 있다.

import XCTest

class TestAppTests: XCTestCase {
    
    override func setUp() {
        super.setUp()
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }
    
    override func tearDown() {
        super.tearDown()
    }
    
    func testExample() {
        // This is an example of a functional test case.
        // Use XCTAssert and related functions to verify your tests produce the correct results.
    }
    
    func testPerformanceExample() {
        // This is an example of a performance test case.
        self.measure {
            // Put the code you want to measure the time of here.
        }
    }
}

CocoaPods 설정

라이브러리 관리를 위해 다들 CocoaPods를 많이 사용할 것으로 생각한다. 새로 추가한 테스트 타겟에서는 기존 타겟의 CocoaPods로 설치한 라이브러리는 사용할 수 없는데, 혹시라도 필요할 경우에는 테스트 타겟에서도 쓸 수 있게 Podfile을 수정해줘야 한다.

target 'TestApp' do
  use_frameworks!

  pod 'RealmSwift'
  pod 'Alamofire', '~> 4.5'
  pod 'PKHUD', '~> 5.0'
end

기존에 사용하던 Podfile은 위와 같다. 현재 상태로는 테스트 타겟에서 아래 라이브러리에 접근 할 수가 없다. 하지만 다음과 같이 pod 명령을 묶어서 사용하면 깔끔하게 처리할 수 있다. pods라고 선언한 부분은 취향대로 바꿔도 된다.

use_frameworks!

def pods
    pod 'RealmSwift'
    pod 'Alamofire', '~> 4.5'
    pod 'PKHUD', '~> 5.0'
end

target 'TestApp' do
    pods
end

target 'TestAppTests' do
    pods
end

이렇게 테스트 환경 구축이 끝났다. 물론 CI라던가 빌드 자동화 툴에 붙이려면 더 많은 세팅을 해야겠지만, 그 쪽은 차후에 구축을 해보기로 하고 우선은 Xcode 내에서만 테스트를 돌려보기로 했다.

테스트 코드 작성

우선 간단한 테스트 코드부터 짜 보자. 이 프로젝트에서는 로컬 DB로 Realm을 사용하고 있는데 이 DB에 기본적인 아이템 하나를 기록하고 이 아이템이 제대로 들어갔는지 확인하는 테스트 코드를 짜기로 했다.

func testStoreNewTitle() {
    let newItem = Item()
    let itemNumber = 123456
    newItem.number = itemNumber

    do {
        let realm = try Realm()
        try realm.write { realm.add(newItem) }

        let predicate = "number == \(itemNumber)"
        XCTAssertNotNil(realm.objects(Item.self).filter(predicate).first)
    } catch {
        XCTFail("Realm Failed : \(error.localizedDescription)")
    }
}

위 테스트 코드에서 일어나는 작업은 다음과 같다.

  • do-try-catch 문을 통해 DB 작업중에 발생하는 에러를 잡아내면 XCTFail 함수를 이용하여 바로 실패하도록 처리한다.
  • 실제 DB에 작성될 Item을 미리 생성하고 쿼리가 가능한 값을 할당해 준 후 DB에 기록한다.
  • 기록한 값으로 쿼리를 날려서 해당 값이 정상적으로 기록 되었는지 확인한다. XCTAssertNotNil 함수는 파라미터로 들어온 값이 Nil이 아니면 참을 반환한다.

사실 이 정도의 테스트 코드는 내 코드를 테스트 한 게 아니라 Realm 라이브러리를 테스트한거나 다름이 없지만, 테스트 코드를 어떻게 짜야 하는지에 대한 감은 잡을 수 있었다. 이제 실제 내가 작성한 로직을 테스트 할 시간이다.

비동기 작업을 위한 테스트 코드 작성

이 프로젝트에서는 웹 페이지와 JSON 문서를 네트워크로 받아야 하는 상황이 빈번하게 발생한다. 그래서 기존에 작성해 놨던 비동기 작업을 테스트 코드로 어떻게 커버할 수 있는지 알아봤다.

func build(with number: Int, success: @escaping (Title) -> (), failed: @escaping (Error?) -> ()) {
    guard   let url = URL(string: String(format: InfoAddress, number))
            else { return failed(NSError(domain: "unknown", code: -999, userInfo: nil)) }
    
    // 비동기 네트워크 작업이 있음
    Alamofire.request(url).responseString { (response) in
        if let error = response.error
        {
            failed(error)
        }
        else if let JSString = response.result.value
        {
            let props = self.parseItemArray(with: JSString)
            success(self.createNewItem(number, props: props))
        }
    }
}

위의 코드에서는 비동기 네트워크 작업으로 JSON 파일 String을 받아오고 해당 String을 파싱하여 Props를 할당하고 새로운 아이템을 만들어낸다. 비동기 네트워크 작업이 끝나고 나서야 테스트가 성공했는지 알 수 있는 상황이었다.

이러한 케이스는 참고자료에 기재한 애플 문서와 Ray Wenderlich 선생님의 문서에 친절히 나와 있었는데, XCTestExpectation 이라는 객체를 사용해서 비동기 코드 내의 블럭에서 기준을 충족 하였는지 판별할 수 있었다.

작성된 테스트 코드는 다음과 같다.

func testBuildNewItemFromNumber() {
    let promise = XCTestExpectation(description: "Read props correctly from Item Number")
    let ItemNumber = 123456
    let TestPropCount = 10    

    ItemBuilder.shared.build(
        with: ItemNumber,
        success: { item in
            XCTAssertEqual(item.count, TestPropCount, "Incorrect Prop Count")
            promise.fulfill()
        },
        failed: { error in
            XCTFail("Build Failed : \(error?.localizedDescription ?? "unknown")")
        }
    )
    
    wait(for: [promise], timeout: 10.0)
}
  • XCTestExpectation 객체를 만들면서 체크하고자 하는 목표에 대한 간단한 설명을 덧붙인다.
  • 비동기 작업을 시작하면서 wait(for:timeout:)을 호출해주면 timeout에 설정해 준 시간만큼 비동기 작업을 기다리고 그 안에 모든 Expectation이 충족되지 않는다면 테스트 실패로 간주한다.
  • 비동기 작업 안의 블럭 코드에서 XCTestExpectation 충족 처리를 위해 fulfill 메소드를 사용한다. (콜백과 유사한 느낌)

결론

어때요, 참 쉽죠?

TDD다 뭐다 하는 개념때문에 막연히 테스트 코드 작성은 어렵다고만 생각했었는데, 생각보다 테스트 코드 작성 자체는 어려운 편이 아니었다.

다만 어떤 조건을 테스트 코드로 작성할 것인가? 라는 점에서는 많은 고민이 필요할 것으로 보인다. 위의 테스트 코드처럼 라이브러리 작동과 같은 테스트는 굳이 할 필요가 없어 보였기 때문이다. 앞으로는 내가 작성한 로직이 제대로 동작하는가? 에 중점을 두고 테스트 코드를 작성해야 할 것 같다.

이후에는 Fastlane 과 같은 빌드 자동화 툴과의 연계, 그리고 프론트엔드에서 필요한 UI Test 등의 주제로 다시 글을 쓸 예정이다.

참고 자료