비동기 메서드 테스트하기

·

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

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

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() 호출과 관계없이, 지정한 시간을 초과하게 되면 자연스레 테스트가 실패하게 된다.

참고 자료

Notes mentioning this note

There are no notes linking to this note.