이 글에서는 유닛 테스트하는 법은 다루지 않고, 비동기 메서드를 어떻게 테스트하는지만 다룬다. 프로젝트를 생성할 때 유닛 테스트를 추가하지 않았다면 이 글을 참고해서 추가한다.

웹에서 이미지를 가져온다거나, 대용량 데이터를 가져올 때에 보통 비동기로 메서드를 만들게 된다. 이렇게 만든 비동기 메서드를 테스트하는 방법을 알아보자.

create(_:completion:)는 Core Data에 데이터를 저장하는 메서드다. completion 블록을 이용해 성공 여부와 에러를 함께 반환한다. NSManagedObjectContext.perform(_:)은 비동기로 동작한다.

func create(_ newPost: Post, completion: @escaping (Result<Bool, Error>) -> Void) {
    let context: NSManagedObjectContext = coreDataStack.mainContext
    context.perform {
        do {
            let post: PostEntity = newPost.toEntity(in: context)
            try context.save()
            completion(.success(true))
        } catch {
            completion(.failure(.failedCreate))
        }
    }
}

초기 테스트 코드는 다음과 같이 작성했었다.

func test_Post를_하나_만들_수_있다() {
    // given: 필요한 모든 값 설정
    let repository: Repository = Repository()
    let newPost: Post = Post(title: "테스트 포스트")

    // when: 테스트 중인 코드 실행
    repository.create(newPost) { result in
        switch result {
        case .success(_):
            // then: 예상한 결과 확인
            repository.get(withpermalink: newPost.identifier) { getResult in
                switch getResult {
                case .success(let postEntity):
                    XCTAssertEqual(postEntity.title ?? "", newPost.title)
                case .failure(let error):
                    XCTFail(error.localizedDescription)
                }
            }
        case .failure(let error):
            XCTFail(error.localizedDescription)
        }
    }
}

참고로 테스트 코드에 있는 get(withpermalink:completion:) 도 비동기 메서드다.

처음에 작성하고, 항상 성공하길래 내가 제대로 작성한 줄 알고 있었다. 이렇게 작성하고 코드 리뷰를 받고 나서 알게 된 사실이 있는데, 테스트 코드는 비동기를 기다려주지 않는다. 그래서 항상 성공했던 거였다. 😂

테스트 코드가 비동기 메서드의 결과가 반환될 때까지 기다려주지 않는다면, 기다리게 만들자.

extension XCTestCase {
    func timeout(_ timeout: TimeInterval, completion: (XCTestExpectation) -> Void) {
        let exp: XCTestExpectation = expectation(description: "Timeout: \(timeout) seconds")
        completion(exp)

        waitForExpectations(timeout: timeout) { error in
            guard let error = error else { return }
            XCTFail("Timeout error: \(error)")
        }
    }
}

XCTestCase의 extension으로 만들어서 비동기 메서드를 테스트할 때 호출해서 사용한다. 이 코드는 같이 프로젝트 하시는 분이 알려주신 건데 무한한 감사의 말씀을 전합니다… 🙏

let exp: XCTestExpectation = expectation(description: "Timeout: \(timeout) seconds")

Use this method to create XCTestExpectation instances that can be fulfilled when asynchronous tasks in your tests complete.

이 메서드를 사용하여 테스트의 비동기 작업이 완료될 때 충족될 수 있는 XCTestExpectation 인스턴스를 만든다.

description에 들어가는 문자열은 시간 초과하면 출력될 문자열이다.

waitForExpectations(timeout: timeout) { error in
    guard let error = error else { return }
    XCTFail("Timeout error: \(error)")
}

waitForExpectations(timeout:handler:)는 테스트가 모든 기대치를 충족하거나 시간이 초과할 때까지 기다린다. 여기서 말하는 기대치 충족이란 XCTestExpectation.fulfill() 메서드 호출을 뜻한다. 위의 코드를 다시 보면 completion(exp) 으로 외부에 반환하는데 비동기 메서드를 다 기다리고 나서 기대치 충족했다고 호출하기 위함이다.

이렇게 만든 timeout(_:completion:) 메서드를 비동기 메서드 테스트할 때 사용하면 되는데 항상 성공했던 망한 코드를 timeout(_:completion:) 메서드를 이용해서 수정하면 다음과 같다.

func test_Post를_하나_만들_수_있다() {
    // given: 필요한 모든 값 설정
    let repository: Repository = Repository()
    let newPost: Post = Post(title: "테스트 포스트")

    // when: 테스트 중인 코드 실행
    timeout(5) { exp in
        repository.create(newPost) { result in
            switch result {
            case .success(_):
                // then: 예상한 결과 확인
                repository.get(withpermalink: newPost.identifier) { getResult in
                    exp.fulfill() // ← 이 위치!
                    switch getResult {
                    case .success(let postEntity):
                        XCTAssertEqual(postEntity.title ?? "", newPost.title)
                    case .failure(let error):
                        XCTFail(error.localizedDescription)
                    }
                }
            case .failure(let error):
                XCTFail(error.localizedDescription)
            }
        }
        // exp.fulfill() 여기서 호출하면 ❌
    }
}

여기서 주의해야 할 점은, fulfill() 를 호출하는 시점이 create 클로저가 끝난 후에 하면 안 되고 클로저 내부에서 호출해야 한다. 왜냐하면, 클로저가 끝나고 호출하게 되면 create 메서드가 비동기라서 끝나지 않았는데 끝났다고 처리된다. 게다가 이 코드는 비동기를 이중으로 호출하고 있으니, create 클로저 내부에 있는 get 클로저 안에서 호출해야 한다.

더불어 fulfill() 호출과 관계없이, 지정한 시간을 초과하게 되면 자연스레 테스트가 실패하게 된다.

참고 자료