비동기 메서드 테스트하기
이 글에서는 유닛 테스트하는 법은 다루지 않고, 비동기 메서드를 어떻게 테스트하는지만 다룬다. 프로젝트를 생성할 때 유닛 테스트를 추가하지 않았다면 이 글을 참고해서 추가한다.
웹에서 이미지를 가져온다거나, 대용량 데이터를 가져올 때에 보통 비동기로 메서드를 만들게 된다. 이렇게 만든 비동기 메서드를 테스트하는 방법을 알아보자.
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()
호출과 관계없이, 지정한 시간을 초과하게 되면 자연스레 테스트가 실패하게 된다.