Swift MVI 패턴에서 State 변경 검증 테스트하기

MVI패턴을 이용하여 프로젝트를 진행하면서, 기존 MVVM-In/Out 패턴에서 사용하던 ViewModel 테스트 코드 작성 방식으로는 상태값을 검증하지 못하는 이슈가 발생했다.

 

기존 MVVM 패턴은 input과 그 input에 대한 상태값의 변경인 output을 받아 화면을 갱신하는 구조여서 output에 대한 검증만 하면 되었지만, MVI 패턴은 State를 UI가 의존하는 구조로 intent에 따라 State의 값이 변경되는 구조로, State가 어느 시점에 변경이 되고 종료되는지를 외부에서 알 수가 없다.

 

예를 하나 들어보자면 다음과 같이 ViewModel을 정의했다고 가정한다.

struct ImageFeedState {
    var images: [IFImage] = []
    var isLoading: Bool = false
}

enum ImageFeedIntent {
    case loadInitial
    case loadNextPage
    case toggleLike(String)
}

@Observable
@MainActor
final class ImageFeedViewModel {
    
    private(set) var state: ImageFeedState
    private var currentPage: Int = 0
    
    func trigger(_ intent: ImageFeedIntent) {
        switch intent {
        case .loadInitial:
            withLoading {
                await self.loadInitPage()
            }
        case .loadNextPage:
            withLoading {
                await self.loadNextPage()
            }
        case .toggleLike(let image):
            handleToggleLike(for: image)
        }
    }
    
    private func loadInitPage() async {
        updateState(state.with { $0.images = [] })
        await loadNextPage()
    }
    
    private func loadNextPage() async {
        do {
            let fetchImages = try await fetchImageUseCase.excute(page: currentPage)
            let existingIds = Set(state.images.map { $0.id })
            let uniqueImages = fetchImages.filter { !existingIds.contains($0.id) }
            if !uniqueImages.isEmpty {
                updateState(state.with { $0.images.append(contentsOf: uniqueImages) })
            }
        } catch {
            postEffect(.error(message: error.localizedDescription))
        }
    }
}

 

여기서 loadInital Intent의 동작을 테스트 코드로 검증해서 테스트 코드를 작성한다고 할때 MVVM-In/Out 패턴처럼 테스트 코드를 작성하게 된다면, viewModel의 intent까지만 컨트롤하고, state의 변경지점을 알 수 없기에 항상 실패한다.

class MockImageFeedRepository: ImageRepository {
    var stubImages: [ImageFeed.IFImage] = []
    
    func fetchImages(page: Int) async throws -> [ImageFeed.IFImage] {
        try await Task.sleep(for: .seconds(2))
        return stubImages
    }
}

struct ImageFeedViewModelTest {
    var mock = MockImageFeedRepository()
    func makeImage() -> IFImage {
        return IFImage(id: UUID().uuidString, url: URL(string: "test")!, thumbnailUrl: URL(string: "test")!, width: 0, height: 0, author: "", createdAt: Date(), likesCount: 0, isLiked: false)
    }
    
    func makeSut() -> ImageFeedViewModel {
        return ImageFeedViewModel(fetchImageUseCase: FetchImageUseCase(imageRepository: mock))
    }
    
    @Test func testFetchImages() throws {
        let sut = makeSut()
        mock.stubImages = [makeImage(), makeImage()]
        sut.trigger(.loadInitial)
        
        // 언제 viewModel의 state가 변경되는지 모름
        
        #expect(sut.state.images.count == 2) //테스트 실패, images는 현재 초기값
    }
    
}

 

이런 문제가 발생해서 어떤식으로 하면 이 구조를 개선할 수 있을지 여러 가지 시도를 해보았다.

State를 Publisher를 이용하여 변경 사항 테스트하기

state가 비동기적으로 변경되는 것이니 다음과 같이 state의 변경사항이 발생하면 publisher도 같이 업데이트시키는 방식으로 테스트하면 테스트가 가능하다. 

private let stateSubject = PassthroughSubject<ImageFeedState, Never>()

  var statePublisher: AnyPublisher<ImageFeedState, Never> {
      stateSubject.eraseToAnyPublisher()
  }

  func updateState(_ newState: ImageFeedState) {
      self.state = newState
      stateSubject.send(newState)  // ← 추가
      logger.debug(...)
  }
@Test func testFetchImages() async throws {
      let sut = makeSut()
      mock.stubImages = [makeImage(), makeImage()]
      var cancellable: AnyCancellable?

      await withCheckedContinuation { continuation in
          cancellable = sut.statePublisher
              .filter { !$0.images.isEmpty }
              .first()
              .sink { state in
                  #expect(state.images.count == 2)
                  continuation.resume()    // 여기서 테스트 재개
              }
          sut.trigger(.loadInitial)
      }
      cancellable = nil
  }

하지만 이 경우, sink가 호출되지 않으면 테스트가 끝나지 않고 계속 돌아간다. 또한 별도의 에러를 발생시키지 않는다. 

무엇보다 @Observable 매크로를 이용해서 옵저빙이 되는 데 publisher를 추가해야 하는 점에서 이미 publisher를 이용하여 상태를 옵저빙 하는 방식이 아니라면 굳이 이 방법을 사용했을때의 장점보다는 단점이 더 크다고 느꼈다.

Intent input할때 Task 리턴하기

사실 이걸 사용할 경우 가장 간단하게 테스트를 작성할 수 있다.

final class ImageFeedViewModel {
	func trigger(_ intent: ImageFeedIntent) -> Task<Void, Never>? {
        switch intent {
        case .loadInitial:
            return withLoading {
                await self.loadInitPage()
            }
        case .loadNextPage:
            return withLoading {
                await self.loadNextPage()
            }
        case .toggleLike(let image):
            handleToggleLike(for: image)
            return nil
        }
    }
}
@Test func testFetchImages() async throws {
        let sut = makeSut()
        mock.stubImages = [makeImage(), makeImage()]
        let task = sut.trigger(.loadInital)
        _ = await task?.value
        
        #expect(sut.state.images.count == 2)
    }

작업 단위인 task를 외부에서 접근이 가능하게끔 함으로써 언제끝날지 외부에서 알수 있는 방식이다.

다만 task가 외부에 노출 된다는 점, 테스트를 위한 구현체 변경같은 느낌이 든다.

withObservationTracking을 이용하여 트래킹

withObservationTracking은 @Observable의 파라미터 변환을 추적할 수 있는 전역함수다.

https://developer.apple.com/documentation/observation/withobservationtracking(_:onchange:)

 

withObservationTracking(_:onChange:) | Apple Developer Documentation

Tracks access to properties.

developer.apple.com

apply 클로저에 추적할 값을 넣고, onChange 클로저에 변경이 감지되면 수행할 동작을 넣으면 된다.

나는 하나의 helper 객체로 이 로직을 격리시키는 방식으로 구현했다.

@MainActor
class StateStreamer<T: Sendable> {
    let apply: () -> T
    let finishCondition: () -> Bool
    var task: Task<Void, Never>?
    private var continuation: AsyncStream<T>.Continuation?
    
    init(apply: @escaping () -> T, finishCondition: @escaping () -> Bool) {
        self.apply = apply
        self.finishCondition = finishCondition
    }
    
    func observe(timeout: Duration) -> AsyncStream<T> {
        return AsyncStream { continuation in
            self.continuation = continuation
            self.tracking()
            
            Task { @MainActor in
                try? await Task.sleep(for: timeout)
                continuation.finish()
            }
        }
    }
    
    private func tracking() {
        withObservationTracking {
            _ = apply()
        } onChange: { 
            MainActor.assumeIsolated {
                if self.finishCondition() {
                    Task { @MainActor in
                        self.continuation?.yield(self.apply())
                        self.continuation?.finish()
                    }
                } else {
                    Task { @MainActor in
                        self.continuation?.yield(self.apply())
                    }
                    self.tracking()
                }
            }
        }
    }
}

테스트 코드 작성시 다음과 같이 사용한다.

@Test func testFetchImages() async throws {
        let sut = makeSut()
        mock.stubImages = [makeImage(), makeImage()]
        let stream = StateStreamer(apply: { sut.state.images }, finishCondition: {
            !sut.state.images.isEmpty
        }).observe(timeout: .seconds(3))
        sut.trigger(.loadInitial)
        for await _ in stream {}
        
        #expect(sut.state.images.count == 2)
    }

 

withObservationTracking의 경우, 한번 onChange가 호출되면은 거기서 트래킹이 종료되기에, 재귀적으로 tracking() 함수를 호출하는 방식을 사용.

 

onChange는 non-isolated 클로저이기에 @MainActor에 격리된 continuation과 finishCondition을 사용하려면 MainActor에 격리 된 상태임을 보장받을 때 사용하는 MainActor.assumeIsolated 를 사용했다.

 

MainActor.assumeIsolated를 사용한 주된 이유는 Task에 정의된 동작은 sync하게 동작하는 게 아닌 스케줄러에 의해 동작 시점이 결정되기에, Task가 실행되기 전, State가 변경되어서 상태 변경 감지 누락 및 finish 조건을 놓치는 걸 방지하기 위해 MainActor.assumeIsolated로 sync 하게 동작하도록 구현했다. 

 

이 객체의 사용 용도가 @MainActor를 채택한 ViewModel의 상태값을 검증하기 위한 용도로만 사용하는걸 전제로 만들었기 때문. 

만약 다른 스레드에서 호출 될 수 있는 상황에 이 객체를 사용한다면 런타임 오류가 발생할 수 있으니 주의해야 한다.

 

아키텍처, 테스트 관점에서

첫번째, 세번째 방법은 결국 사람이 임의의 종료 조건을 트리거 하는 방식으로 테스트가 진행되기 때문이다. 만약 구현체에 변경사항이 생겨서 최종적인 동작은 동일한데, 종료 조건에 대한 트리거가 변경될 경우 결국에는 테스트가 실패하기 때문이다.

 

하지만 MVI패턴의 주요 목적은 단방향인데, Task를 리턴함으로써 결국 이 단방향 패턴이 깨져버린다. 아키텍처적인 관점에서 본다면 이 방법은 안티패턴이 아닐까 생각이 든다.

다만 코드에는 정답이 없으니, 상황에 따라 유동적으로 사용하는게 좋지 않을까 생각한다. 프로젝트 규모가 커서 일관성 있는 구조로 가야 한다면 첫번째, 세번째 방법을 사용하고, 그게 아닌 소규모나 중규모 정도의 프로젝트는 두번째 방법을 쓰는 편이 유지보수 및 테스트적인 관점에서 더 이득이 있지 않을까 생각한다.