Clean-Swift, 무엇을 테스트 해야하는가

Clean-Swift, 무엇을 테스트 해야하는가
Photo by Angelina Litvin / Unsplash

현재 진행 중인 프로젝트에서는 Clean-Swift 를 이용해 화면 단위를 개발하고 있는데요,
Clean-Swift(VIP Pattern) 컴포넌트에 대한 단위 테스트를 작성하기 위해 어떤 부분을 고민했는지 공유해보려고 합니다.

Clean-Swift 에서는 Scene에 대한 단위 테스트를 작성할 때 크게 3가지의 단위 테스트를 통해 구현을 검증하도록 가이드합니다.

VIP Cycle
  1. ViewController => Interactor 호출 테스트
  2. Interactor => Presenter 호출 테스트
  3. Presenter => ViewController 호출 테스트

다시 말해 Clean-Swift에서는 Spy객체(모의 객체)의 특정 메서드가 호출되었는지 여부를 검사하는 것을 통해 구현을 검증하도록 가이드합니다.

1) 화면 단위를 Scene으로 표현하였습니다.

제가 생각하는 기존 단위 테스트의 문제점은, 테스트 내부에서 인터페이스를 호출했는지, 어떻게 사용하는지 하나하나를 다 검증하기 때문에 구현 내용 혹은 인터페이스가 바뀌게 될 경우 테스트가 쉽게 깨질 수 있다는 점입니다.

또한 Scene에 대한 단위 테스트를 작성하기 위해 최소 3개의 Spy객체(모의 객체)를 생성하고 관리해야 한다는 점 또한 테스트를 작성하는데 부담이 되는 지점이라 생각하였습니다.

테스트 구조도

이러한 문제점을 해결하기 위해 무엇을 테스트해야 효율적이고, 견고한 테스트를 작성할 수 있을지에 대한 고민이 들기 시작했는데요,

모의 객체의 특정 메소드를 호출했는지를 검증하는 것보다, 실제 실행 후 최종 상태에 대해 검증해야겠다는 생각을 하기 시작했습니다.

이미지 출처: https://sos-cer.github.io/testing/version/6.0.0/bbt
Black box test

마치 블랙박스 테스트처럼 내부 구현보다는 외부에서 보이는 결과를 중심으로 테스트하는 방향으로 말이죠.

이렇게 하면 인터페이스 변경에 따른 테스트 유지보수 비용을 줄이고, 테스트가 쉽게 깨지지 않고 더 견고하게 유지될 수 있을 것이라 기대했습니다.

그렇다면, VIP Cycle에 대한 Output은 무엇일까요?

VIP Cycle output

제가 생각한 VIP Cycle에 대한 output은 'view'라고 판단하였습니다.
view에서 시작된 데이터가 요청, 가공을 거쳐 view에 '결과'로써 다시 표시되기 때문입니다.

따라서 VIP Cycle이 의도한 대로 올바르게 흘러가는지 검증하기 위해서는 display logic을 검증하면 되겠다고 판단하였습니다.

그렇다면, display logic을 검증할 수 있는 환경은 어떻게 만들 수 있을까요?

제가 생각한 방법은 VIP 사이클은 그대로 진행되도록 두면서,

Presenter의 display logic을 스텁으로 대체하여 그 스텁된 객체의 흐름을 검증하는 것입니다.

마치 아래의 그림처럼 말이죠

검증하고 싶은 부분

사실, 이 그림이 오늘 얘기하고 싶은 것의 전부입니다.

그럼 사례를 통해 흐름을 보도록 하겠습니다.

extension ShareGardenSceneInteractor: ShareGardenSceneBusinessLogic {
  func requestMyGarden() {
    self.tasks[TaskKey.requestMyGarden] = Task {
    defer { self.tasks[TaskKey.requestMyGarden] = nil }
    
      do {
        try Task.checkCancellation()
        let myGarden = try await self.shareGardenSceneWorker.requestMyGarden()
        try Task.checkCancellation()
        let response = ShareGardenScene.RequestMyGarden.Response(myGarden: myGarden)
        self.presenter?.presentMyGarden(response: response)
      } catch {
        // present error
      }
    }
  }

interactor에 이러한 메서드가 있다고 가정했을 때

presenter는 interactor의 호출에 따라

  extension ShareGardenScenePresenter: ShareGardenScenePresentationLogic {
    func presentMyGarden(response: ShareGardenScene.RequestMyGarden.Response) {
     /* make view model */
      
      self.viewController?.displayMyGarden(viewModel)
    }
    
    func presentMyGardenRequestError() {
      self.viewController?.displayMyGardenRequestError()
    }
     /* present another error */
  }

view에 결과를 전달하게 될 것입니다.

이러한 구조를 이용하기 위해 앞서 살펴봤던 것처럼 display logic을 스텁하였습니다.

@MainActor
final class ShareGardenSceneViewControllerStub {
  
  weak var viewController: ShareGardenSceneDisplayLogic?
  
  let (myGardenStream, myGardenStreamContinuation) = AsyncStream.makeStream(
    of: ShareGardenScene.RequestMyGarden.ViewModel.self,
    bufferingPolicy: AsyncStream.Continuation.BufferingPolicy.bufferingNewest(1)
  )
  
  let (myGardenRequestErrorStream, myGardenRequestErrorStreamContinuation) = AsyncStream.makeStream(
    of: Void.self,
    bufferingPolicy: AsyncStream.Continuation.BufferingPolicy.bufferingNewest(1)
  )
}

extension ShareGardenSceneViewControllerStub: ShareGardenSceneDisplayLogic {
  func displayMyGarden(_ viewModel: ShareGardenScene.RequestMyGarden.ViewModel) {
    self.myGardenStreamContinuation.yield(viewModel)
    self.viewController?.displayMyGarden(viewModel)
  }
  /* display another error */
}
AsyncStream을 이용한 이유는 Swift Concurrency를 이용해 output이 준비되기까지 테스트의 흐름을 suspension point에서 중단시켜 놓기 위함입니다.

AsyncStream.Continuation의 yield(_:) 를 통해 suspend 된 작업을 재개하여suspend point에 값을 반환하도록 하고, 해당 값을 검증하도록 하였습니다.

import Testing
import AsyncAlgorithms

extension ShareGardenSceneTests {
  @Test(arguments: [true, false])
  private func myGardenRequest(isSuccessful: Bool) async {
    // Given
    / ... /
    
    // When
    self.loadView()
    
    // Then
    enum RequestMyGardenResult {
      case success(ShareGardenScene.RequestMyGarden.ViewModel)
      case error
    }
    
    let mergedStream = merge(
      self.sut.myGardenStream.map { RequestMyGardenResult.success($0) },
      self.sut.myGardenRequestErrorStream.map {
        _ in RequestMyGardenResult.error
      }
    )
    
    for await result in mergedStream {
      switch result {
      case .success(let viewModel):
        #expect(viewModel.nickname == expectedMyGarden.nickname)
        #expect(viewModel.description == expectedMyGarden.description)
        #expect(viewModel.pomodoroRecords == expectedMyGarden.pomodoroRecords)
      case .error:
        #expect(true)
      }
      
      self.sut.myGardenRequestErrorStreamContinuation.finish()
      self.sut.myGardenStreamContinuation.finish()
      return
    }
  }
parameterized test를 사용하기 위해 Swift Testing을 사용하였습니다.
이를 통해 성공, 실패 시나리오에 대한 테스트를 쉽게 진행할 수 있었습니다.

위와 비슷한 흐름으로 다른 테스트 케이스를 작성해 시나리오에 따른 VIP Cycle을 검증하였습니다.

AsyncStream Continuation의 결과가 선후 관계를 갖는 경우에는 structured concurrency를 이용해 테스트를 진행해 주었습니다.

이를 통해 VIP Cycle의 내부 구현 검증을 피하고, 실제 실행 후 최종 상태에 대해 검증함으로써 테스트가 쉽게 깨지지 않도록 하였습니다.

🧪 테스트 결과

VIP 컴포넌트 테스트 커버리지

VIP Cycle 테스트의 결과로 VIP 컴포넌트의 핵심 객체인 View, Interactor, Presenter의 테스트 커버리지는 평균 94.8%를 가질 수 있게 되었으며,

백로그

사용자가 입력하고 보게 되는 최종 상태에 대해 검증을 하게 됨으로써 기획 단계에서 백로그에 작성한 시나리오에 대한 행위 기반 테스트를 한 눈에 확인할 수 있게 되었습니다🎉

VIP Cycle을 테스트 함으로써 VIP Cycle과 별개로 별도의 로직 테스트가 필요한 부분은 개별적으로 테스트를 작성해 로직의 정확성을 확보해 나가는 방향으로 테스트를 진행하였습니다.

모든 기능을 개발한 뒤에 VIP Cycle을 통해 시나리오에 맞게 테스트가 진행되는 걸 보니 조금 더 자신 있게 새로운 기능을 개발할 수 있게 된 것 같습니다 :)

Clean-Swift를 사용하며, 어느 부분을 테스트해야 하는지 고민한 경험을 작성해 보았는데요,

아직은 현재의 테스트 구조에서 크게 부족함을 느끼지 못했지만,

경험이 부족하기 때문이겠죠 ㅎㅎ..!

더 복잡한 케이스가 등장하거나, 테스트 구조에서 개선의 여지를 느끼게 된다면, 글을 업데이트하러 오도록 하겠습니다:)

아직 모르는 것이 많고 알아가는 과정입니다. 잘못된 것이 있다면 댓글로 남겨주신다면 감사하겠습니다! 😊