iOS/개념 & 개발

[iOS] XCTest에서 비동기 테스트하기 (XCTestExpectation)

유정주 2023. 11. 2. 14:27
반응형

서론

UnitTest 사용해보기XCTest 성능 측정 (Command Line 환경)에서 UnitTest를 하는 방법과 성능 측정 방법을 다뤘습니다.

이전에는 동기 메서드만 테스트만 다뤄서 이번에는 비동기 테스트에 대해 말해보려고 합니다.

 

대부분의 앱에는 네트워크 통신이 필요합니다.

네트워크 통신은 오래 걸리는 작업이기 때문에 반드시 비동기로 처리해야 합니다.

같은 이유로 네트워크 테스트도 비동기로 테스트해야 하며, 다른 동기 테스트와 분리해야 합니다.

 

 

비동기 테스트

비동기 메서드를 동기 테스트 코드와 똑같이 테스트하면 제대로 테스트할 수 없습니다.

비동기 메서드의 결과가 나오기 전에 테스트 함수가 종료되기 때문입니다.

그래서 비동기 메서드가 종료될 때까지 기다리는 작업이 필요한데요.

이를 위해 XCTest에서는 XCTestExpectation를 지원합니다.

클래스 설명도 대놓고 비동기 테스트를 위한 객체라고 쓰여있네요.

 

Combine

XCTestExpectation를 알아보기 전에 XCTestExpectation를 안 쓴 코드를 먼저 볼까요?

Combine을 이용해 비동기 테스트를 작성해 봤습니다.

func test_메인반찬목록_가져오기_성공() throws {
    let requestValue = OnbanAPI.dishList(requestValue: .init(type: .main))
    let publisher = Provider()
        .request(with: requestValue, type: DishResponseDTO.self)
        .eraseToAnyPublisher()

    publisher
        .sink(receiveCompletion: { completion in
            switch completion {
            case .finished: break
            case .failure:
                assertionFailure("메인 반찬목록 읽기 실패")
            }
        }, receiveValue: { dishResponseDTO in
            Logger.debug(dishResponseDTO)
            XCTAssertNotNil(dishResponseDTO)
        })
        .store(in: &cancellables)
}

Provider의 request는 네트워크 통신을 진행하는 메서드입니다.

Combine의 sink를 이용해 비동기 테스트가 완료됩니다.

물론 이 방법도 가능합니다!

정상적으로 통과도 합니다.

하지만 여러 Publisher를 한 번에 테스트할 수 없고, 테스트 시나리오도 명확하게 보이지 않는다는 단점이 있습니다.

 

XCTestExpectation

XCTestExpectation를 사용하면 이런 문제점을 해결할 수 있습니다.

func test_메인반찬목록_가져오기_성공() throws {
    // XCTestExpectation 정의
    let expectation = XCTestExpectation(description: "메인반찬목록_가져오기_성공")

    // Request
    let requestValue = OnbanAPI.dishList(requestValue: .init(type: .main))
    let publisher = Provider()
        .request(with: requestValue, type: DishResponseDTO.self)

    // Receive 데이터를 담을 변수
    var receivedValue: DishResponseDTO?
    let cancellable = publisher
        .sink(receiveCompletion: { _ in
        }, receiveValue: { dishResponseDTO in
            receivedValue = dishResponseDTO
            expectation.fulfill()
        })

    // 비동기가 종료될 때까지 대기
    wait(for: [expectation])

    // 비동기 데이터 테스트
    XCTAssertNotNil(receivedValue)

    cancellable.cancel()
}

 

XCTestExpectation을 이용한 테스트 코드입니다.

expectation 부분만 좀 더 살펴보겠습니다.

expectation.fulfill()

XCTestExpectation의 fulfill 메서드를 이용해 비동기가 종료되었음을 알리고,

wait(for: [expectation])

해당 expectation이 마킹될 때까지 대기합니다.

XCTAssertNotNil(receivedValue)

wait 이후에 데이터를 확인합니다.

wait(for: [expectation], timeout: 2)

timeout이 필요한 경우, wait 메서드 파라미터로 timeout을 전달할 수도 있습니다.

timeout을 설정하면 비동기 메서드가 timeout 내에 완료되지 않으면 테스트가 실패 처리됩니다.

 

 

XCTestExpectation 사용 이유

코드 자체는 더 길어졌지만, 실행 로직과 Assert 로직이 분리되었습니다.

Combine을 사용했을 때는 sink 안에 Assert가 있어서 실행 로직과 Assert 로직이 합쳐져 있었지만,

지금은 실행 로직과 Assert 로직이 명확하게 분리되어 테스트 시나리오를 확인하기 쉬워졌습니다.

 

또한, 여러 Publisher의 데이터를 쉽게 확인할 수도 있습니다.

위에서는 메인 반찬만 비동기로 가져왔지만, 국물 반찬, 사이드 반찬도 모두 가져오고 싶다고 해봅시다.

이 경우에는 세 개의 비동기 메서드가 모두 완료된 후 Assert 처리를 해야 합니다.

 

Combine을 쓴다면 세 개의 비동기 메서드가 완료되었는지 계속 확인해야 하고 Assert 로직도 모든 Publisher에 들어갑니다.

이렇게 되면 Assert 조건도 제약이 심해질 수 있습니다.

XCTestExpectaion 코드에서 알아본 wait는 expectation을 배열로 받기 때문에 여러 비동기 흐름을 대기할 수 있습니다.

 

func test_반찬목록_가져오기_성공() throws {
    // XCTestExpectation 정의
    let mainExpectation = XCTestExpectation(description: "메인반찬목록_가져오기_성공")
    let soupExpectation = XCTestExpectation(description: "국물반찬목록_가져오기_성공")
    let sideExpectation = XCTestExpectation(description: "사이드반찬목록_가져오기_성공")

    // Request
    let mainRequestValue = OnbanAPI.dishList(requestValue: .init(type: .main))
    let publisher1 = Provider()
        .request(with: mainRequestValue, type: DishResponseDTO.self)

    let soupRequestValue = OnbanAPI.dishList(requestValue: .init(type: .soup))
    let publisher2 = Provider()
        .request(with: soupRequestValue, type: DishResponseDTO.self)

    let sideRequestValue = OnbanAPI.dishList(requestValue: .init(type: .side))
    let publisher3 = Provider()
        .request(with: sideRequestValue, type: DishResponseDTO.self)

    // Receive 데이터를 담을 변수
    var mainReceivedValue: DishResponseDTO?
    let cancellable1 = publisher1
        .sink(receiveCompletion: { _ in
        }, receiveValue: { dishResponseDTO in
            mainReceivedValue = dishResponseDTO
            mainExpectation.fulfill()
        })

    var soupReceivedValue: DishResponseDTO?
    let cancellable2 = publisher2
        .sink(receiveCompletion: { _ in
        }, receiveValue: { dishResponseDTO in
            soupReceivedValue = dishResponseDTO
            soupExpectation.fulfill()
        })

    var sideReceivedValue: DishResponseDTO?
    let cancellable3 = publisher3
        .sink(receiveCompletion: { _ in
        }, receiveValue: { dishResponseDTO in
            sideReceivedValue = dishResponseDTO
            sideExpectation.fulfill()
        })

    // 비동기가 종료될 때까지 대기
    wait(for: [mainExpectation, soupExpectation, sideExpectation], timeout: 2)

    // 비동기 데이터 테스트
    XCTAssertNotNil(mainReceivedValue)
    XCTAssertNotNil(soupReceivedValue)
    XCTAssertNotNil(sideReceivedValue)

    cancellable1.cancel()
    cancellable2.cancel()
    cancellable3.cancel()
}

테스트하고 싶은 Publisher가 많아져도 코드 길이만 길어질 뿐, 실행 로직과 Assert 로직이 확실히 분리가 되는 걸 볼 수 있습니다.

물론 이렇게 하나의 테스트 메서드에서 여러 API를 확인하는 건 안 좋지만, 필요할 수도 있으니까요 ㅎㅎ;

 

 

마무리

이번 포스팅에서는 비동기 테스트에 대해 다뤘습니다.

네트워크 통신이 필수인 시대(?)에 비동기 테스트는 꼭 알아둬야 할 정보 같네요.

비동기를 다루는 방법은 다양해서 지금 포스팅보다 더 좋은 방법이 존재할 수 있습니다.

만약 그렇다면 댓글로 한 수 알려주시면 정말 감사하겠습니다.

 

감사합니다.

 

 

출처

https://developer.apple.com/documentation/xctest/xctestexpectation

https://medium.com/blablacar/4-tips-to-master-xctestexpectation-aee2b2631d93

https://mildwhale.tistory.com/m/31


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

반응형