VIPER 패턴 테스트하기 ‐ 윤지성 - Team-HGD/SniffMEET GitHub Wiki

상황 설명

기존 프로젝트에서는 GitHub Actions를 활용하여 CI(Continuous Integration) 환경을 구축하였으며, 이를 통해 PR(Pull Request) 생성 시 자동으로 테스트가 실행되도록 설정했습니다. 이 CI 환경은 코드가 브랜치에 push될 때마다 자동으로 테스트를 수행하고, 그 결과를 기반으로 코드의 안정성을 빠르게 검증하는 역할을 합니다.

하지만 기존 테스트 방식에서는 특정 모듈에 대한 유닛 테스트에 집중한 나머지, Scene 단위에서의 흐름을 종합적으로 검증하는 테스트가 부족했습니다. 이에 따라 유지보수성을 더욱 높이기 위해 VIPER 구조의 Scene 단위로 테스트 코드를 작성하는 방향으로 개선하기로 결정했습니다. 이를 통해 각 Scene이 독립적으로 올바르게 동작하는지 확인하고, 변경 사항이 발생해도 쉽게 검증할 수 있도록 개선하고자 합니다.

백로그

  • UITest 작성하기

본격적으로 테스트에 들어가기 전

Viper 패턴

1

Viper 패턴 구조

SniffMeet 프로젝트 구조

현재 프로젝트 구조

현재 VIPER 패턴이 적용된 상태에서 테스트하고 싶은 내용은 다음과 같습니다:

  • Presenter 동작
    • View의 사용자 인터랙션에 따라 Presenter가 올바르게 동작하고 있는지 검증
    • Interactor의 응답을 받아 Presenter가 잘 처리하고 있는지 검증
  • Interactor
    • Presenter의 입력에 대해 Interactor가 적절히 반응하는지 확인
    • Usecase의 결과값에 따라 Interactor가 올바르게 동작하고 있는지 검증

현재는 모든 동작이 정상적으로 완료되는 상태를 확인할 수 있지만, 리팩토링 과정에서 특정 코드가 제대로 동작하지 않을 가능성을 배제할 수 없습니다. 이를 예방하기 위해 테스트 코드가 더더욱 필요합니다.

Presenter와 Interactor의 각 방향에 대한 동작을 어떻게 테스트할 수 있을까요?

바로 Test Double을 활용하는 것입니다.

Test Double

Test Double은 sut(system under test)을 테스트하고 싶은데 sut이 의존하고 있는 객체가 있을 때 원하는대로 제어하고 싶을 때 사용됩니다. (DOC(Depended On Component): SUT이 자신이 의존하고 있는 객체)

Test Double은 DOC와 동일한 API를 제공하기 때문에 SUT는 이것을 실제 DOC로 인식하지만, 우리는 원하는 대로 제어하고 관찰할 수 있다는 장점이 있습니다.

  • 이렇게 원하는 대로 제어할 수 있다는 점에서 여러 조건을 담고 있는 복잡한 환경 안에서 동작되는 컴포넌트에 대해서 여러 상황을 적절히 처리하는지 확인할 수 있습니다. (네트워크 오류, 서버 다운 등..)

Test Double에는 Dummy Object, Fake, Stub, Spy, Mock이 있습니다.

  1. Dummy Object
    • 단순히 인자를 채우기 위해 사용되며, 동작하지 않는 객체이다.
    • 실제로 사용되지 않지만, 메서드 호출 시 필요한 매개변수를 충족시키기 위해 전달되는 객체입니다. 이는 주로 인터페이스나 프로토콜을 만족시키기 위해 생성되지만 실제로는 사용되지 않는 객체입니다.
  2. Fake
    • Fake 테스트의 복잡성을 줄이고, 구현을 단순화하며, 실제 동작을 모방합니다.
    • 일반적으로 인메모리 데이터베이스나 간단한 구현체로 사용된다.
  3. Stub
    • Stub은 미리 정의된 동작을 하는 특별한 fake 객체로 주로 상태를 검증할 때 사용됩니다.
    • 특정 경로로 테스트를 유도하기 위해 DOC를 대신하여 미리 정의된 값을 반환하여 테스트 중인 코드에 대한 간단한 반응을 제공하는 데 사용됩니다.
  4. Spy
    • Spy는 내부적으로 실제 로직을 수행하면서(stubbing) 호출된 정보나 상태를 기록합니다. 즉, 메서드의 동작을 감시(spy)하면서 필요한 정보를 수집합니다.
    • 실제 메소드가 얼마나 자주 호출되었는지, 어떤 매개변수로 호출되었는지 등의 정보를 알 수 있으며, 필요한 경우 실제 메소드의 반환 값을 변경하거나 특정 메소드 호출을 가로챌 수도 있습니다.
  5. Mock
    • 예상된 동작을 검증할 수 있도록 미리 설정된 기대치를 가진 객체이다.
    • 테스트 실행 중 예상된 동작이 수행되지 않으면 테스트가 실패한다.

Viper의 Test code

→ 먼저 테스트하려는 대상(sut)과 sut의 동작에 영향을 미치는 객체를 Test Double로 선언해야 합니다.

만약 Presenter가 각 input에 대해 적절한 메서드가 호출되는지 확인하고 싶다면 Interactor를 Test Double로 선언하면 됩니다. 각 input에 대해서 올바른 메서드가 호출된다면 true를 저장할 수 있으면 호출이 올바르게 동작했음을 알 수 있습니다. 당연히 output에 대해서도 테스트할 수 있을 겁니다.

Test Double 구현 과정

Interactor에 대한 동작을 테스트해보겠습니다. 특히 Interactor에 대한 input에 대해서 Usecase를 거쳐 Presenter에게 올바른 동작 혹은 데이터를 올바르게 전달하는지 확인하려고 합니다.

아래 그림과 같이 Input이 있었을 때 1 -> 2 -> 3번에 걸쳐서 올바르게 동작되는지 테스트하려고 합니다.

하지만 Interactor 인풋에 대해서 UseCase를 거쳐 결과값이 생성됩니다. 이것은 Usecase의 동작에 따라 Interactor의 인풋에 대한 결과값이 변경될 수 있음을 의미합니다. 따라서 Usecase 동작에 따라 결과값 영향을 주지 않기 위해서 Mock 객체를 사용하여 제어하려고 합니다.

또한 Interactor가 올바르게 동작했는지 Presenter에서 확인하려고 합니다. 따라서 Presenter가 Interactor의 결과 값을 저장하여 Interactor가 의도대로 동작했는지 판단할 수 있도록 Spy 객체로 구현하려고 합니다.

→ sut인 Interactor의 DOC인 Presenter를 Spy로 구현하여 올바른 동작을 판단할 수 있도록 하고 Usecase를 Mock으로 구현하여 미리 의도된 동작을 하도록 제어하려고 합니다.

  1. Presenter Spy

    아래와 같이 Spy 내부에 호출여부를 저장하는 변수와 Interactor에게 전달받을 데이터를 저장하는 변수를 선언했습니다. 따라서 Interactor의 동작에 따라 호출되는 메서드 내부에 그 결과값을 저장하는 로직을 포함시켜 Interactor이 올바르게 동작했는지 판단할 수 있습니다.

    final class MateListPresenterSpy: MateListInteractorOutput {
        var presentMateListsCalled = false
        var receivedMateList: [Mate]?
        var presentProfileImageCalled = false
        var receivedProfileImage: Data?
        var presentProfileDataCalled = false
        var receivedProfileData: DogDTO?
        var presentNIConnectedCalled = false
        var presentNINotConnectedCalled = false
    
        func didFetchMateList(mateList: [Mate]) {
            presentMateListsCalled = true
            receivedMateList = mateList
        }
        func didFetchProfileImage(id: UUID, imageData: Data?) {
            presentProfileImageCalled = true
            receivedProfileImage = imageData
        }
        func receiveProfileData(_ data: DogDTO) {
            presentProfileDataCalled = true
            receivedProfileData = data
        }
        func didConnectNISession() {
            presentNIConnectedCalled = true
        }
        func failToConnectNISession() {
            presentNINotConnectedCalled = true
        }
    }
    
  2. Usecase Mock

    Usecase Mock은 초기화 함수를 통해서 미리 Usecase의 동작을 정의할 수 있습니다. 따라서 Interactor의 동작 혹은 결과를 제어할 수 있습니다.

    struct RequestMateListUseCaseMock: RequestMateListUseCase {
        var remoteDatabaseManager: any RemoteDatabaseManager
        var mateList: [UserInfoDTO]
    
        init(mateList: [UserInfoDTO]) {
            remoteDatabaseManager = RemoteDatabaseManagerMock(fetchData: nil, fetchListData: nil)
            self.mateList = mateList
        }
    
        func execute() async -> [Mate] {
            mateList.map{
                Mate(name: $0.dogName,
                     userID: $0.id,
                     keywords: $0.keywords,
                     profileImageURLString: $0.profileImageURL)
            }
        }
    }
    

테스트 코드

Spy와 Mock 객체를 구현한 후, Interactor 동작을 확인하기 위해서 아래 그림과 같은 구조로 테스트를 진행했습니다.

아래 코드로 확인할 수 있습니다. 미리 구현한 Spy와 Mock를 구현하여 Presenter와 Usecase 대신 포함하여 Interactor를 초기화하였습니다.

// MateListInteractorTests
   override func setUp() {
        presenterSpy = MateListPresenterSpy()
        requestMateListUseCaseMock = RequestMateListUseCaseMock(mateList: userInfoDTOList)
        requestProfileImageUseCaseMock = RequestProfileImageUseCaseMock()
        tryProfileDropUseCaseMock = TryProfileDropUseCaseMock()
        quitProfileDropUseCaseMock = QuitProfileDropUseCaseMock()
        sut = MateListInteractor(
            presenter: presenterSpy,
            requestMateListUseCase: requestMateListUseCaseMock,
            requestProfileImageUseCase: requestProfileImageUseCaseMock,
            tryProfileDropUseCase: tryProfileDropUseCaseMock,
            quitProfileDropUseCase: quitProfileDropUseCaseMock
        )
   

테스트 코드 중 interactor에 메이트리스트 정보를 요청했을 때 메이트 리스트 정보를 usecase에 요청하고 해당 리스트를 presenter에게 올바르게 전달하는지 테스트하려고 합니다.

  • requestMateListUseCaseMock 객체가 미리 초기화된 userInfoDTOList를 리턴할 수 있도록 설정하였습니다.
  • Interactor가 올바르게 동작한다면 userInfoDTOList를 올바르게 Presenter Spy에게 전달되어야 합니다.

이처럼 presenter에서 메이트 리스트를 view에게 전달하는 메서드가 호출됐는지 점검하고 전달받은 메이트리스트와 userInfoDTOList를 비교하면 Interactor가 올바르게 동작했는지 확인할 수 있습니다.

// MateListInteractorTests
   func test_requestMateList는_presenter에게_올바른_메이트_리스트를_전달한다() async {
        // Arrange

        // Act
        // 파라미터로 받은 userID는 Mock객체 동작에 상관없음
        let _ = await sut.requestMateList(userID: UUID())

        // Assert
        XCTAssertTrue(presenterSpy.presentMateListsCalled,
                      "presenter에서 메이트리스트를 view에게 전달하는 메서드를 호출한다. " )
        presenterSpy.receivedMateList?.enumerated().forEach { (idx, mate) in
            XCTAssertEqual(mate.userID, userInfoDTOList[idx].id,
                           "presenter가 성공적으로 메이트 리스트 데이터를 받는다. ")
        }
    }

인사이트

기존에는 개별 유닛 테스트 위주로 검증했지만, Scene 단위로 테스트를 확장하면서 Interactor, Presenter, Router 간의 상호작용을 더욱 명확하게 검증할 수 있었습니다.

또한, Test Double(Dummy, Fake, Stub, Spy, Mock)을 적절히 활용함으로써 외부 의존성 없이도 VIPER 모듈을 효과적으로 테스트할 수 있었습니다. 이를 통해 네트워크 응답, 데이터베이스 연산과 같은 외부 요소에 영향을 받지 않고도 일관된 테스트 환경을 유지할 수 있었습니다.

또한 매 PR 마다 Github Actions을 통해 테스트가 실행되고 유지보수성 더 견고하게 유지하는데 사용할 수 있었습니다.

프로젝트 PR에서 테스트 코드를 실행을 포함한 CI 적용 결과

레퍼런스

Unit Testing in VIPER Architecture with Swift