
지금까지 State, Action, Reducer, Store, Effect, Dependency를 배우면서 날씨 앱의 Splash → Main 전환 흐름을 실제로 구현해봤다. 이번 글에서는 이 흐름이 의도대로 동작하는지 TestStore로 검증하는 방법을 정리한다.
TestStore는 실제 앱에서 쓰는 Store와 똑같이 생겼지만, 액션을 보낼 때마다 "State가 정확히 이렇게 바뀌어야 한다"를 명시적으로 검증하도록 만들어진 테스트 전용 도구다. Reducer 로직이 의도한 대로 동작하는지 코드로 확인할 수 있다.
store.send(action)을 부르면서 트레일링 클로저로 "이 액션 때문에 State가 어떻게 바뀌어야 하는지"를 적으면 된다.
await store.send(.fetchCurrentWeather) {
$0.isLoading = true
}
$0은 액션 처리 전 State의 복사본이고, 여기서 값을 바꾼 결과와 실제 Reducer가 만든 결과를 TestStore가 자동으로 비교한다. 여기서 반드시 =(대입)로 값을 바꿔야 한다. $0.isLoading == true처럼 ==(비교)를 쓰면 그냥 Bool 값을 만들고 버리는 것뿐이라 아무것도 검증하지 못한다.
State가 그 액션으로 바뀌는 게 전혀 없다면, 트레일링 클로저 자체를 생략하면 된다.
await store.send(.fetchCurrentWeather)
.fetchCurrentWeather가 .run Effect를 반환하고 그 안에서 send(.responseCurrentWeather(...))를 호출한다면, TestStore는 이 "나중에 돌아오는 액션"까지 명시적으로 받아줘야 한다.
await store.receive(\.responseCurrentWeather) {
$0.isSplashCompleted = true
}
TestStore는 기본적으로 엄격(exhaustive)해서, Effect가 돌려보낸 액션을 receive로 받지 않고 테스트를 끝내면 "unexpected action"으로 실패한다. \.responseCurrentWeather처럼 케이스패스로 받는 이유는, Action에 Result<Weather, Error> 같은 값이 들어있을 때 Error가 Equatable이 아니라서 정확한 값까지 비교하는 receive(.responseCurrentWeather(...)) 방식이 까다롭기 때문이다. 케이스패스는 "이 케이스가 왔다"까지만 매칭하고, 실제 값 검증은 트레일링 클로저의 State 비교로 대신한다.
TestStore<State, Action>는 액션 처리 후 실제 State와 트레일링 클로저로 만든 예상 State를 비교해야 해서, State: Equatable을 요구한다.
@ObservableState
struct State: Equatable {
var isSplashCompleted = false
}
State 안에 다른 타입(Weather 같은)이 프로퍼티로 들어있다면, 그 타입도 Equatable이어야 자동 합성이 된다. 그리고 State 안에 Error 타입을 그대로 들고 있으면 이 합성이 실패하니(Error 프로토콜 자체가 Equatable이 아니라서), 에러는 문자열로 변환하거나 커스텀 Equatable 타입으로 감싸서 보관하는 게 좋다.
.run 클로저는 async throws라 try await가 문법적으로는 문제없이 컴파일되지만, 안에서 던져진 에러를 아무도 안 잡으면 TCA가 "처리되지 않은 에러"로 issue를 기록하고 테스트를 실패시킨다. do/catch로 감싸거나, .run(_:catch:)의 catch: 파라미터로 명시적으로 처리해야 한다.
return .run { send in
let weather = try await adapter.fetchCurrentWeather("Seoul")
await send(.responseCurrentWeather(weather))
} catch: { error, send in
await send(.responseFetchError(error))
}
실패 케이스는 mock이 에러를 던지도록 만들어서 테스트한다.
@Test
func test_fetch_failure() async {
struct TestError: Error {}
let store = TestStore(
initialState: Splash.State(),
reducer: { Splash() },
withDependencies: {
$0.weatherAdapter.fetchCurrentWeather = { _ in throw TestError() }
}
)
await store.send(.fetchCurrentWeather)
await store.receive(\.responseFetchError) {
$0.isSplashErrored = true
}
}
TestStore 생성 시 withDependencies로 이 테스트에서만 필요한 mock을 그 자리에서 정의할 수 있다.
withDependencies: {
$0.weatherAdapter.fetchCurrentWeather = { _ in .mock }
}
반복되는 성공 케이스라면, DependencyKey에 testValue를 미리 정의해두고 withDependencies를 생략할 수도 있다.
extension WeatherAdapter: DependencyKey {
static var testValue: WeatherAdapter {
WeatherAdapter(fetchCurrentWeather: { _ in .mock })
}
}
이렇게 해두면 XCTest/Swift Testing 컨텍스트에서는 @Dependency가 자동으로 testValue를 골라 쓴다. testValue는 "대부분의 테스트가 가정할 기본 동작"을, withDependencies는 "이 테스트만의 예외 상황"을 담당하는 식으로 역할을 나누면 된다.
Splash 하나만이 아니라, Root가 .splash(.responseCurrentWeather)에 반응해서 isSplashActive를 끄고 main을 초기화하는 것까지 테스트할 수 있다.
@Test
func test_splash_to_main_transition() async {
let store = TestStore(
initialState: Root.State(),
reducer: { Root() },
withDependencies: {
$0.weatherAdapter.fetchCurrentWeather = { _ in .mock }
}
)
await store.send(.splash(.fetchCurrentWeather))
await store.receive(\.splash.responseCurrentWeather) {
$0.splash.isSplashCompleted = true
$0.isSplashActive = false
$0.main = Main.State(weather: .mock)
}
}
액션은 .splash(...)처럼 한 겹 감싸서 보내야 하고, 케이스패스도 \.splash.responseCurrentWeather처럼 점으로 이어서 중첩 경로를 표현할 수 있다. receive의 클로저 하나 안에서 Splash 내부 상태와 Root 자체 상태가 동시에 바뀌는 걸 검증하는데, 이건 Scope(splash)가 먼저 실행되고 곧이어 Root의 Reduce가 같은 액션에 반응하는, 이전 글에서 다룬 "body 항목들이 순서대로 실행된다"는 구조가 테스트에서도 그대로 나타나는 것이다.
여러 Feature가 합쳐진 큰 State를 테스트하다 보면 관심 없는 세부 변화까지 매번 다 적어야 해서 테스트가 장황해질 수 있다. store.exhaustivity = .off로 완화하면, 트레일링 클로저나 receive 자체를 생략해도 실패하지 않고, 정말 확인하고 싶은 것만 #expect로 짚어서 검증할 수 있다.
store.exhaustivity = .off
await store.send(.splash(.fetchCurrentWeather))
await store.receive(\.splash.responseCurrentWeather)
#expect(store.state.isSplashActive == false)
대신 exhaustive 모드가 주는 "이 액션이 이런 부수효과도 만들고 있었네"라는 발견 능력을 잃는다는 대가가 있다. 작은 단위 Feature는 exhaustive 그대로 두고, 여러 Feature가 얽힌 통합 테스트에서만 선택적으로 쓰는 게 일반적이다.
프로젝트에 테스트 타겟을 처음 추가하면서 겪은 문제 두 가지를 짧게 적어둔다.
하나는 테스트 타겟에서 import ComposableArchitecture가 "No such module"이라고 실패해서, Frameworks and Libraries에 ComposableArchitecture를 직접 추가했다가 오히려 Undefined symbol(PerceptionCore, SwiftNavigation) 에러가 40개 넘게 쏟아진 일이었다. TCA 메인테이너의 답변에 따르면, 앱 타겟이 이미 TCA에 의존하고 테스트 타겟이 @testable import로 앱 타겟에 의존하는 것만으로 충분하고, 테스트 타겟에 따로 추가하면 안 된다고 한다. 테스트 타겟의 Frameworks and Libraries에서 ComposableArchitecture를 빼고, 대신 Host Application 설정만 확인하니 해결됐다.
출처 - https://github.com/pointfreeco/swift-composable-architecture/discussions/2941
다른 하나는 @testable import Weather_TCA처럼 언더스코어로 되어 있는데 실제 타겟 이름은 Weather-TCA(하이픈)라서 오타인 줄 알았던 일이다. Swift 모듈 이름에는 하이픈을 쓸 수 없어서, Xcode가 타겟 이름의 하이픈을 자동으로 언더스코어로 바꿔 Product Module Name을 만들어준다. Build Settings에서 "Product Module Name"을 확인해보면 실제 값을 알 수 있다.
TestStore의 기본 사용법부터 에러 처리, 의존성 주입, 부모-자식 통합 테스트, non-exhaustive 완화까지 정리해봤다. 아직 다루지 않은 영역(TestClock으로 시간 기반 Effect 테스트하기, Effect 취소 테스트하기, IdentifiedArrayOf 리스트 테스트하기, AlertState 테스트하기)은 해당 기능을 실제로 만들 때 이어서 공부할 예정이다.