← TCA 14일 마스터 8 / 14

Day 8 · 테스트 ① — TestStore

send의 기대 상태 클로저, exhaustive 원칙의 세 가지 의무, receive로 이펙트 액션 검증, TestClock·ImmediateClock으로 시간 제어를 @Observable 모델 테스트와 나란히 비교한다.
진행률
0 / 0 완료
SwiftUI 코스 Day 13에서는 @Observable 모델의 메서드를 부르고 #expect로 내가 고른 값 몇 개를 확인했다. 확인하지 않은 프로퍼티가 바뀌었거나 뒤에서 작업이 계속 돌고 있어도 테스트는 통과한다. TCA의 TestStore는 기본 동작이 반대다. 액션마다 상태 전체가 어떻게 바뀌었는지, 이펙트가 어떤 액션을 되돌려 보냈는지, 테스트가 끝날 때 모든 이펙트가 끝났는지를 전부 증명해야 통과한다.

오늘의 목표 · 25분

  • 리듀서 하나에 TestStore를 만들고, send의 트레일링 클로저에 "바뀐 뒤 상태"를 하드코딩 값으로 적는다.
  • 실패 메시지(A state change does not match…, Must handle N received actions…, An effect … is still running)를 보고 무엇을 빠뜨렸는지 찾는다.
  • 이펙트가 되돌린 액션을 receive(\.action)로 받고, 의존성을 withDependencies:로 가짜로 바꾼다.
  • TestClock·ImmediateClock으로 타이머 테스트를 실제로 기다리지 않고 끝낸다.

1. 첫 TestStore — send와 "바뀐 뒤 상태" 클로저 🔥 핵심

TCA 문서 · Testing의 첫 문장은 "테스트 가능성이 라이브러리의 1순위"라는 선언이다. 도구는 TestStore 하나다. Store처럼 초기 상태와 리듀서로 만들지만, 액션을 보낼 때마다 상태가 어떻게 바뀌어야 하는지를 같이 적어야 한다. 테스트할 재료는 Day 2의 카운터다.

테스트할 기능 — Day 2 카운터 + 계산 프로퍼티
@Reducer
struct Counter {
  @ObservableState
  struct State: Equatable {          // TestStore는 State: Equatable을 요구한다
    var count = 0
    var isEven: Bool { count % 2 == 0 }
  }
  enum Action {
    case decrementButtonTapped
    case incrementButtonTapped
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .decrementButtonTapped:
        state.count -= 1
        return .none
      case .incrementButtonTapped:
        state.count += 1
        return .none
      }
    }
  }
}

같은 기능을 SwiftUI 코스 방식(@Observable 모델)으로 테스트하면 이렇다. 메서드를 부르고 결과 프로퍼티를 확인한다.

SwiftUI(@Observable) — 메서드 호출 후 고른 값을 #expect
import Testing

@Observable @MainActor
final class CounterModel {
  var count = 0
  func incrementButtonTapped() { count += 1 }
}

@MainActor
struct CounterModelTests {
  @Test func increment() {
    let model = CounterModel()
    model.incrementButtonTapped()
    #expect(model.count == 1)        // 확인하지 않은 프로퍼티는 검사하지 않는다
  }
}
TCA — TestStore로 액션을 보내고 바뀐 상태를 적는다
import Testing

@MainActor                           // TestStore는 main actor에 묶여 있다
struct CounterTests {
  @Test func increment() async {     // send가 await라서 async
    let store = TestStore(initialState: Counter.State()) {
      Counter()
    }
    await store.send(.incrementButtonTapped) {
      $0.count = 1                   // $0 = 액션 "전" 상태. 바뀐 뒤 모습으로 고친다
    }
    await store.send(.incrementButtonTapped) {
      $0.count = 2
    }
    #expect(store.state.isEven)      // 계산 프로퍼티는 store.state로 확인
    await store.send(\.decrementButtonTapped) {   // 연관값 없는 케이스는 키패스로도 보낸다
      $0.count = 1
    }
  }
}
액션 전 상태 count: 0 리듀서 실행 → 실제 상태 count: 1 내 클로저 → 기대 상태 $0.count = 1 == 같음 → 통과 다름 → diff
send의 클로저는 "기대 상태"를 만드는 곳이다. TestStore는 기대 상태와 리듀서가 만든 실제 상태를 통째로 비교한다.

틀리게 적으면 문서에 나온 형식 그대로 어느 필드가 다른지 diff로 보여 준다(− 기대, + 실제). 아래 두 방법은 통과는 하지만 증명력이 약하거나 아예 증명을 피해 간다.

❌ 상대값 · store.state 복사 — 검증이 약해지거나 속임수가 된다
await store.send(.incrementButtonTapped) {
  $0.count += 1          // 통과는 하지만 "지금 몇인지"는 증명하지 못한다
}
await store.send(.incrementButtonTapped) {
  $0 = store.state       // 클로저 안의 store.state는 "액션 전" 상태 → 실패한다
}
📝 TCA 문서 원문

"In general, the less logic you have in the trailing closure of send, the stronger your assertion will be. It is best to use simple, hard-coded data for the mutation."

클로저 안 로직이 적을수록 강한 검증이다. = 1, = 2처럼 하드코딩 값을 쓴다. += 1은 시작값이 틀려도 통과한다.

SwiftUI (@Observable 모델)TCA (TestStore)
입력메서드 호출 model.incrementButtonTapped()액션 전송 await store.send(.incrementButtonTapped)
확인 범위내가 #expect로 고른 프로퍼티만상태 전체 (== 비교)
실패 메시지식 하나의 참/거짓상태 구조 전체의 diff
요구 조건없음State: Equatable, @MainActor, async

2. Exhaustive 원칙 — 증명해야 하는 세 가지 🔥 핵심

TestStore의 기본 모드는 exhaustive(철저)다. TestStore.swift 문서 주석은 세 가지 의무를 적어 둔다. 하나라도 빠뜨리면 테스트가 실패한다.

이펙트 실행 중 send 액션 도착 (버퍼) receive 테스트 끝 (deinit) ① 상태 변화 전부 단언 send · receive 클로저마다 ② 받은 액션 전부 receive 안 하면 다음 send에서 실패 ③ 이펙트 전부 종료 끝에 남아 있으면 실패
의무 ①은 매 단계, ②는 다음 send 직전, ③은 TestStore가 해제될 때(deinit) 검사된다.
실패 메시지 (1.26.2 소스 문구)뜻고치는 법
A state change does not match expectation클로저로 만든 기대 상태 ≠ 실제diff의 + 쪽 값을 클로저에 반영
State was not expected to change, but a change occurred클로저 없이 보냈는데 상태가 바뀜클로저를 추가해 변화를 적는다
Must handle N received actions before sending an action이펙트가 보낸 액션을 안 받고 다음 send그 전에 receive를 N번
An effect returned for this action is still running테스트 끝에 이펙트가 살아 있음이펙트를 끝내거나 취소(4절)

SwiftUI 모델 테스트에는 ②·③에 해당하는 검사가 없다. 메서드 안에서 Task { … }를 띄우고 잊어도 테스트는 초록불이다. TestStore는 이것을 기본값으로 잡는다. 문서의 설명: 테스트가 끝났는데 이펙트가 돌고 있다면 "돌면 안 되는 이펙트이거나, 나중에 틀린 데이터를 넣을 수 있다"는 뜻이라 숨은 버그다.

🔑 SwiftUI와의 차이를 한 줄로

@Observable 테스트는 "내가 적은 것이 맞다"를 증명하고, exhaustive TestStore는 "내가 적지 않은 일은 일어나지 않았다"까지 증명한다.

3. 이펙트 테스트 — receive(\.action) 🔥 핵심

Day 3의 숫자 사실(number fact) 기능에 Day 7의 의존성을 붙인 버전이다. 버튼을 누르면 로딩이 켜지고, 이펙트가 API를 불러 결과를 factResponse 액션으로 되돌린다.

테스트할 기능 — 의존성을 통해 API를 부르는 이펙트
struct NumberFactClient: Sendable {
  var fetch: @Sendable (Int) async throws -> String
}
extension NumberFactClient: DependencyKey {
  static let liveValue = NumberFactClient { number in
    let url = URL(string: "https://example.com/fact/\(number)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return String(decoding: data, as: UTF8.self)
  }
}
extension DependencyValues {
  var numberFact: NumberFactClient {
    get { self[NumberFactClient.self] }
    set { self[NumberFactClient.self] = newValue }
  }
}

@Reducer
struct NumberFact {
  @ObservableState
  struct State: Equatable {
    var count = 0
    var fact: String?
    var isLoading = false
  }
  enum Action {
    case factButtonTapped
    case factFailed
    case factResponse(String)
    case incrementButtonTapped
  }
  @Dependency(\.numberFact) var numberFact

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .factButtonTapped:
        state.isLoading = true
        state.fact = nil
        return .run { [count = state.count] send in
          let fact = try await numberFact.fetch(count)
          await send(.factResponse(fact))
        } catch: { _, send in
          await send(.factFailed)
        }
      case .factFailed:
        state.isLoading = false
        return .none
      case let .factResponse(fact):
        state.isLoading = false
        state.fact = fact
        return .none
      case .incrementButtonTapped:
        state.count += 1
        return .none
      }
    }
  }
}

테스트에서는 withDependencies:로 네트워크 대신 즉시 답하는 가짜를 꽂는다. send는 로딩 상태를, receive는 이펙트가 되돌린 액션과 그 뒤 상태를 증명한다.

✅ send → receive — 이펙트가 되돌린 액션까지 증명
import Testing

@MainActor
struct NumberFactTests {
  @Test func factButtonTapped() async {
    let store = TestStore(initialState: NumberFact.State()) {
      NumberFact()
    } withDependencies: {                  // $0 = DependencyValues 복사본
      $0.numberFact = NumberFactClient { "\($0) is a good number" }
    }
    await store.send(.incrementButtonTapped) {
      $0.count = 1
    }
    await store.send(.factButtonTapped) {
      $0.isLoading = true            // 중간 상태도 증명한다
    }
    await store.receive(\.factResponse) {   // 케이스 키패스로 "어떤 액션"인지
      $0.isLoading = false
      $0.fact = "1 is a good number"
    }
  }
}
테스트 리듀서 이펙트 send(.factButtonTapped) isLoading = true 가짜 fetch(1) → 문자열 .factResponse(…) receive(\.factResponse)
이펙트가 보낸 액션은 TestStore에 쌓인다. 테스트는 receive로 그것을 하나씩 꺼내며 "왔다"와 "상태가 이렇게 바뀌었다"를 함께 증명한다.

receive의 네 가지 모양

@Reducer가 Action에 @CasePathable을 자동으로 붙이기 때문에 \.factResponse 같은 케이스 키패스를 쓸 수 있다(문서 Note). 연관값까지 맞추고 싶으면 두 번째 인자로 넘긴다.

호출무엇을 맞추나조건
receive(\.factResponse)케이스만 (연관값 무시)없음 — 기본으로 이것
receive(\.factResponse, "1 is a good number")케이스 + 연관값연관값이 Equatable
receive(.factResponse("…"))액션 전체 ==Action: Equatable
receive({ … })직접 쓴 조건 함수—

같은 기능을 @Observable로 쓰면 메서드 하나가 await로 끝까지 기다린다. 테스트는 짧지만 중간 과정이 사라진다.

SwiftUI(@Observable) — 끝난 뒤의 결과만 보인다
import Testing

@Observable @MainActor
final class NumberFactModel {
  var count = 0
  var fact: String?
  var isLoading = false
  private let fetch: @Sendable (Int) async throws -> String
  init(fetch: @escaping @Sendable (Int) async throws -> String) { self.fetch = fetch }

  func factButtonTapped() async {
    isLoading = true
    defer { isLoading = false }
    fact = try? await fetch(count)
  }
}

@MainActor
@Test func modelFact() async {
  let model = NumberFactModel { "\($0) is a good number" }
  await model.factButtonTapped()
  #expect(model.fact == "0 is a good number")
  #expect(model.isLoading == false)   // 중간에 true였는지는 이 테스트로 알 수 없다
}
⚠️ 기본 대기 시간은 1초 (소스 기준)

receive는 액션이 올 때까지 timeout만큼 기다린다. Testing 문서의 예시 메시지는 "0.1 seconds"지만, 1.26.2 소스의 TestStore.init은 self.timeout = .seconds(1)로 설정한다. 어느 쪽이든 진짜 시간을 기다리는 이펙트는 테스트를 느리고 불안정하게 만든다. 다음 절에서 시간을 가짜로 바꾼다.

4. 시간 제어 — TestClock과 ImmediateClock

문서의 타이머 예제는 처음에 Task.sleep을 쓴다. 이대로면 테스트가 실제로 5초를 기다려야 하고, receive마다 timeout: .seconds(2)를 붙여야 통과한다.

❌ 이펙트 안의 Task.sleep — 테스트가 실제 시간을 기다린다
return .run { send in
  for _ in 1...5 {
    try await Task.sleep(for: .seconds(1))   // 테스트에서 바꿀 방법이 없다
    await send(.timerTick)
  }
}

해결은 시계를 의존성으로 받는 것이다. @Dependency(\.continuousClock)은 앱에서는 진짜 시계, 테스트에서는 원하는 시계로 바꿀 수 있다. 시계 타입은 TCA가 함께 가져오는 swift-clocks 라이브러리에 들어 있다.

✅ 시계를 의존성으로 — 스톱워치
@Reducer
struct Stopwatch {
  @ObservableState
  struct State: Equatable {
    var isRunning = false
    var secondsElapsed = 0
  }
  enum Action {
    case startButtonTapped
    case stopButtonTapped
    case timerTicked
  }
  enum CancelID { case timer }
  @Dependency(\.continuousClock) var clock

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .startButtonTapped:
        state.isRunning = true
        return .run { send in
          for await _ in clock.timer(interval: .seconds(1)) {
            await send(.timerTicked)
          }
        }
        .cancellable(id: CancelID.timer)
      case .stopButtonTapped:
        state.isRunning = false
        return .cancel(id: CancelID.timer)
      case .timerTicked:
        state.secondsElapsed += 1
        return .none
      }
    }
  }
}
✅ TestClock — advance(by:)로 시간을 직접 흘린다
import Testing

@MainActor
@Test func stopwatchTicks() async {
  let clock = TestClock()
  let store = TestStore(initialState: Stopwatch.State()) {
    Stopwatch()
  } withDependencies: {
    $0.continuousClock = clock
  }
  await store.send(.startButtonTapped) {
    $0.isRunning = true
  }
  await clock.advance(by: .seconds(1))         // 1초가 "즉시" 흐른다
  await store.receive(\.timerTicked) { $0.secondsElapsed = 1 }

  await clock.advance(by: .seconds(2))         // tick 두 번
  await store.receive(\.timerTicked) { $0.secondsElapsed = 2 }
  await store.receive(\.timerTicked) { $0.secondsElapsed = 3 }

  await store.send(.stopButtonTapped) {        // 타이머를 끄지 않으면 ③ 위반
    $0.isRunning = false
  }
}
0초 1초 2초 3초 advance(by: 1초) advance(by: 2초) tick → receive elapsed 1 tick → receive elapsed 2 tick → receive elapsed 3 stop .cancel(id:)
TestClock은 테스트가 advance를 부를 때만 시간이 흐른다. 흐른 만큼 tick이 쌓이고, 테스트는 그 수만큼 receive한다.
시계동작언제
TestClock()advance(by:)를 불러야 시간이 흐름tick 하나하나, 디바운스 경계처럼 시간 자체가 로직일 때
ImmediateClock()sleep이 즉시 끝남기다림은 관심 밖이고 결과만 볼 때 (문서의 5회 타이머 예제)
실제 시계 + timeout:진짜로 기다림피한다 — 느리고 불안정

SwiftUI 모델도 같은 방법을 쓸 수 있다. 모델이 any Clock<Duration>을 생성자로 받게 하고 테스트에서 swift-clocks의 TestClock을 넣으면 된다. 차이는 기다림을 조율하는 쪽이다. 모델 테스트는 직접 Task를 띄우고 취소도 직접 해야 한다. TestStore는 이펙트 실행을 메인 스레드에서 순서대로 처리하고(useMainSerialExecutor 기본 true, 소스 기준), 남은 이펙트는 ③ 규칙으로 잡아 준다.

🔬 끄는 액션이 없는 이펙트는 TestStoreTask로

send는 TestStoreTask를 돌려준다. 화면이 사라질 때 부모가 취소해 주는 이펙트처럼 단독 테스트에서 끌 방법이 없다면 let task = await store.send(.startButtonTapped) { … }로 받아 두고 마지막에 await task.cancel()을 부른다. 볼트 노트 "TestStoreTask"에 SyncUps 튜토리얼의 이 패턴이 정리되어 있다.

5. 흔한 실수

❌ TestStore를 테스트 타입의 프로퍼티로 — 검사 ③이 조용히 빠진다
@MainActor
struct FeatureTests {
  let store = TestStore(initialState: Counter.State()) { Counter() }   // 공유
  @Test func basics() async {
    await store.send(.incrementButtonTapped) { $0.count = 1 }
  }
}
✅ 테스트 함수마다 지역 변수로 — 함수가 끝나면 deinit에서 검사
import Testing

@MainActor
struct FeatureTests {
  @Test func basics() async {
    let store = TestStore(initialState: Counter.State()) { Counter() }
    await store.send(.incrementButtonTapped) { $0.count = 1 }
    await store.finish()     // 해제가 보장되지 않는 구조라면 끝에 명시적으로
  }
}

이유는 소스에 있다. TestStore의 deinit이 completed()를 불러 "받지 않은 액션"과 "실행 중 이펙트"를 검사한다. 스토어가 해제되지 않으면 이 검사가 통째로 건너뛰어진다(문서 "Long-living test stores"). 그 밖의 실수는 짧게 정리한다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
send 클로저에서 $0.count += 1보다 $0.count = 1이 나은 이유는?
exhaustive 모드의 $0은 액션 전 상태다. += 1은 "하나 늘었다"만 증명하고, 시작값이 틀렸어도 통과한다. = 1은 "지금 정확히 1이다"를 증명한다. 문서도 하드코딩 값을 권한다.
쉽게 말하면 "키가 1cm 컸다"보다 "키가 정확히 150cm다"라고 재야 확실하다.
exhaustive TestStore가 SwiftUI 모델 테스트에 없는 검사로 잡아 주는 두 가지는?
(1) 이펙트가 보낸 액션을 receive하지 않고 다음 액션을 보내면 실패(Must handle N received actions…). (2) 테스트가 끝날 때 이펙트가 아직 돌고 있으면 실패(An effect returned for this action is still running…). 모델 테스트는 #expect로 적은 것만 확인한다.
쉽게 말하면 숙제 검사에서 적은 답만 보는 선생님과, "안 낸 숙제 없니? 켜 둔 불 없니?"까지 확인하는 선생님의 차이다.
🔮 코드 예측: 4절 스톱워치 테스트에서 advance(by: .seconds(3)) 직후 receive 없이 바로 send(.stopButtonTapped) { $0.isRunning = false }를 부르면?
실패가 두 개 기록된다(1.26.2에서 직접 실행해 확인). ① Must handle 3 received actions before sending an action. — tick 3개가 버퍼에 남아 있다. ② A state change does not match expectation — 기대 상태는 secondsElapsed: 0인데 실제는 3이다. tick 3개가 실제로 반영됐지만 테스트가 증명하지 않았기 때문이다. 타이머는 stop으로 취소되므로 ③ 실패는 없다.
쉽게 말하면 택배 세 상자가 문 앞에 쌓였는데 안 열어 보고 외출하려는 것이다. "상자부터 열어!"와 "집 안 물건 수가 안 맞아!" 두 번 혼난다.
🔁 지난 Day 복습 (Day 7): 3절에서 withDependencies:로 numberFact를 바꾸지 않으면 테스트에서 무슨 일이 생기나?
NumberFactClient에는 testValue가 없다. 이때 swift-dependencies의 기본 testValue는 previewValue(없으면 liveValue)를 돌려주면서 테스트 실패를 기록한다(DependencyKey.swift 주석). 진짜 네트워크를 부르는 테스트가 조용히 통과하지 못하게, "이 의존성을 테스트에서 직접 정하라"고 알려 주는 것이다.
쉽게 말하면 연습 경기에 진짜 심판을 부르려 하면 "연습용 심판을 정해 오라"고 막아 준다.

🛠 미니 과제 (선택 · 10분)

아래 카운트다운 리듀서를 프로젝트에 넣고 TestStore 테스트를 쓴다.

시작 코드 — 3초 카운트다운
@Reducer
struct Countdown {
  @ObservableState
  struct State: Equatable {
    var remaining = 3
    var isFinished = false
  }
  enum Action {
    case startButtonTapped
    case ticked
  }
  @Dependency(\.continuousClock) var clock

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .startButtonTapped:
        return .run { send in
          for _ in 1...3 {
            try await clock.sleep(for: .seconds(1))
            await send(.ticked)
          }
        }
      case .ticked:
        state.remaining -= 1
        if state.remaining == 0 { state.isFinished = true }
        return .none
      }
    }
  }
}
정답 코드
정답 — ImmediateClock · TestClock 두 버전
import Testing

@MainActor
struct CountdownTests {
  @Test func immediate() async {
    let store = TestStore(initialState: Countdown.State()) {
      Countdown()
    } withDependencies: {
      $0.continuousClock = ImmediateClock()
    }
    await store.send(.startButtonTapped)          // 상태 변화 없음 → 클로저 생략
    await store.receive(\.ticked) { $0.remaining = 2 }
    await store.receive(\.ticked) { $0.remaining = 1 }
    await store.receive(\.ticked) {
      $0.remaining = 0
      $0.isFinished = true
    }
  }

  @Test func twoSeconds() async {
    let clock = TestClock()
    let store = TestStore(initialState: Countdown.State()) {
      Countdown()
    } withDependencies: {
      $0.continuousClock = clock
    }
    await store.send(.startButtonTapped)
    await clock.advance(by: .seconds(2))
    await store.receive(\.ticked) { $0.remaining = 2 }
    await store.receive(\.ticked) { $0.remaining = 1 }
    await clock.run()                             // 남은 시간을 끝까지 흘려 이펙트 종료
    await store.receive(\.ticked) {
      $0.remaining = 0
      $0.isFinished = true
    }
  }
}

두 번째 테스트에서 마지막 tick을 받지 않고 끝내면 이펙트가 아직 sleep 중이라 ③ 규칙 위반으로 실패한다.

출처 · 참고자료