🟣 바깥 세계를 어떻게 테스트하나
1장의 Effect 안에서 apiClient.fetchItems()를 불렀다. 문제는 이 한 줄이 테스트를 통째로 어렵게 만든다는 데 있다. 테스트가 진짜 서버를 부르면 네트워크 상태에 따라 결과가 달라지고, 느리고, 서버가 특정 상황을 돌려주게 만들 수도 없다. 현재 시간, 랜덤 값, 파일 시스템도 같은 문제를 갖는다. 결과가 바깥 환경에 따라 달라지는 코드는 그대로는 테스트할 수 없다.
해법은 오래된 것이다. 이런 바깥 세계를 코드에 직접 박지 말고 밖에서 주입받게 만들면, 테스트에서는 가짜를 끼워 넣을 수 있다. TCA는 이 의존성 주입을 위한 전용 장치를 제공한다. 이 장은 그걸 어떻게 쓰고, 그 위에서 테스트를 어떻게 짜는지 정리한다.
🟣 의존성을 값처럼 다루기
TCA의 의존성 시스템은 SwiftUI의 @Environment와 닮았다. 의존성을 하나의 저장소에 등록해 두고, 필요한 Reducer가 @Dependency로 꺼내 쓴다. 먼저 클라이언트를 프로토콜이 아니라 함수를 담은 구조체로 정의하는 것이 TCA식 관례다.
struct APIClient {
var fetchItems: () async throws -> [Item]
}프로토콜 대신 구조체를 쓰는 이유가 있다. 프로토콜로 만들면 테스트용 가짜를 만들 때마다 준수 타입을 하나씩 새로 선언해야 한다. 함수를 담은 구조체는 그 자리에서 클로저만 바꿔 끼우면 되니 가짜를 만드는 비용이 훨씬 싸다. 이어서 이 클라이언트를 의존성 저장소에 등록한다.
extension APIClient: DependencyKey {
static let liveValue = APIClient(
fetchItems: { try await realNetworkCall() }
)
static let testValue = APIClient(
fetchItems: { unimplemented("APIClient.fetchItems") }
)
}
extension DependencyValues {
var apiClient: APIClient {
get { self[APIClient.self] }
set { self[APIClient.self] = newValue }
}
}liveValue는 앱이 실제로 돌 때 쓰는 진짜 구현이고, testValue는 테스트의 기본값이다. testValue를 일부러 unimplemented로 둔 게 핵심이다. 테스트에서 미처 갈아끼우지 않은 의존성이 호출되면 곧바로 실패하게 만들어, 테스트가 모르는 사이에 진짜 바깥 세계를 건드리는 사고를 막는다. Reducer에서 꺼내 쓰는 쪽은 한 줄이면 된다.
struct ItemsFeature: ReducerProtocol {
@Dependency(\.apiClient) var apiClient
// reduce 안에서 apiClient.fetchItems() 사용
}같은 Reducer가 실행 맥락에 따라 다른 구현을 받는다. 앱에서는 진짜 서버, 테스트에서는 내가 지정한 가짜, SwiftUI 프리뷰에서는 고정된 더미가 들어온다. Reducer 코드는 그대로 두고 바깥 세계만 갈아끼우는 이 구조가 테스트와 프리뷰를 동시에 편하게 만든다.
🟣 TestStore로 흐름을 검증하기
의존성을 갈아끼울 수 있게 됐으니 테스트를 짤 수 있다. TCA는 TestStore라는 전용 테스트 도구를 준다. 이게 특이한 건, 단순히 최종 결과만 확인하는 게 아니라 State가 각 Action마다 정확히 어떻게 바뀌었는지를 한 단계씩 검증하도록 강제한다는 점이다.
func testRefresh() async {
let store = TestStore(initialState: ItemsFeature.State()) {
ItemsFeature()
} withDependencies: {
$0.apiClient.fetchItems = { [Item(id: 1, name: "샘플")] }
}
await store.send(.refreshTapped) {
$0.isLoading = true
}
await store.receive(.itemsResponse([Item(id: 1, name: "샘플")])) {
$0.isLoading = false
$0.items = [Item(id: 1, name: "샘플")]
}
}withDependencies에서 fetchItems를 고정된 값을 돌려주는 가짜로 바꿨다. 이제 테스트는 네트워크와 무관하게 항상 같은 결과를 낸다. send 뒤의 클로저에는 그 Action 직후 State가 어떤 모습이어야 하는지를 적는다. 실제 State와 내가 적은 기대가 다르면 테스트가 실패한다.
receive가 특히 중요하다. refreshTapped가 Effect를 돌려 서버를 부르고, 그 응답이 itemsResponse라는 새 Action으로 되돌아온다고 1장에서 봤다. receive는 그 되돌아오는 Action이 실제로 왔는지, 그때 State가 어떻게 바뀌는지까지 검증한다. 비동기 Effect의 결과가 흘러 들어오는 지점을 테스트가 놓치지 않는 것이다.
🟣 빠짐없는 검증이 주는 것
TestStore의 기본 동작은 꽤 엄격하다. Reducer가 State를 바꿨는데 내가 send/receive 클로저에 그 변화를 안 적으면 테스트가 실패한다. 반대로 Effect가 Action을 되돌려 보냈는데 내가 receive로 받지 않으면, 그것도 실패한다. 즉 일어난 모든 변화를 테스트가 남김없이 설명해야 통과한다.
처음엔 이 엄격함이 번거로웠다. 하지만 곧 이게 안전망이라는 걸 알게 됐다. 어떤 화면을 고치다가 Reducer가 의도치 않게 다른 State까지 건드리면, 그 변화를 테스트에 안 적었으니 곧바로 빨간불이 뜬다. 의도하지 않은 부수 변경이 테스트에서 자동으로 드러나는 셈이다. 상태 변화가 많은 화면일수록 이 검증이 회귀 버그를 잡아 줬다.
물론 대가도 있다. 화면 하나에 테스트 코드가 길어지고, State를 살짝만 바꿔도 여러 테스트를 같이 고쳐야 한다. 검증 범위를 State 일부로 좁히는 방법도 있어서, 정말 중요한 흐름만 빠짐없이 검증하고 나머지는 느슨하게 두는 선택도 실무에서는 필요했다.
🟣 정리
- 서버, 시간, 랜덤처럼 결과가 바깥 환경에 좌우되는 코드는 직접 박으면 테스트할 수 없다. 밖에서 주입받아야 갈아끼울 수 있다.
- TCA는 의존성을 저장소에 등록하고
@Dependency로 꺼내 쓰는 방식으로 주입한다. 클라이언트는 프로토콜보다 함수를 담은 구조체로 만들어 가짜 생성 비용을 낮춘다. liveValue/testValue/previewValue로 같은 Reducer가 실행 맥락마다 다른 구현을 받는다. 테스트 기본값을 미구현으로 두면 갈아끼우지 않은 의존성 호출을 사고 전에 잡는다.TestStore는 각 Action마다 State 변화를 한 단계씩 검증하도록 강제하고, Effect가 되돌리는 Action까지receive로 확인한다.- 빠짐없는 검증은 의도치 않은 부수 변경을 자동으로 드러낸다. 대신 테스트가 길어지므로, 중요한 흐름과 느슨하게 둘 부분을 가르는 판단이 필요하다.
- 면접에서는 "함수 구조체 의존성 + 미구현 기본값 + 빠짐없는 검증"이 왜 안전망이 되는지를 회귀 버그 관점에서 설명할 수 있으면 된다.
기능 하나를 만들고 테스트하는 법까지 왔다. 이제 화면이 여러 개가 되면 이들 사이를 오가야 한다. 다음 장은 화면 전환인데, 지원 OS 버전에 따라 방법이 갈린다.