@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의 카운터다.
@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 모델)으로 테스트하면 이렇다. 메서드를 부르고 결과 프로퍼티를 확인한다.
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) // 확인하지 않은 프로퍼티는 검사하지 않는다
}
}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
}
}
}틀리게 적으면 문서에 나온 형식 그대로 어느 필드가 다른지 diff로 보여 준다(− 기대, + 실제). 아래 두 방법은 통과는 하지만 증명력이 약하거나 아예 증명을 피해 간다.
await store.send(.incrementButtonTapped) {
$0.count += 1 // 통과는 하지만 "지금 몇인지"는 증명하지 못한다
}
await store.send(.incrementButtonTapped) {
$0 = store.state // 클로저 안의 store.state는 "액션 전" 상태 → 실패한다
}"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 문서 주석은 세 가지 의무를 적어 둔다. 하나라도 빠뜨리면 테스트가 실패한다.
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는 이것을 기본값으로 잡는다. 문서의 설명: 테스트가 끝났는데 이펙트가 돌고 있다면 "돌면 안 되는 이펙트이거나, 나중에 틀린 데이터를 넣을 수 있다"는 뜻이라 숨은 버그다.
@Observable 테스트는 "내가 적은 것이 맞다"를 증명하고, exhaustive TestStore는 "내가 적지 않은 일은 일어나지 않았다"까지 증명한다.
3. 이펙트 테스트 — receive(\.action) 🔥 핵심
Day 3의 숫자 사실(number fact) 기능에 Day 7의 의존성을 붙인 버전이다. 버튼을 누르면 로딩이 켜지고, 이펙트가 API를 불러 결과를 factResponse 액션으로 되돌린다.
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는 이펙트가 되돌린 액션과 그 뒤 상태를 증명한다.
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"
}
}
}receive의 네 가지 모양
@Reducer가 Action에 @CasePathable을 자동으로 붙이기 때문에 \.factResponse 같은 케이스 키패스를 쓸 수 있다(문서 Note). 연관값까지 맞추고 싶으면 두 번째 인자로 넘긴다.
| 호출 | 무엇을 맞추나 | 조건 |
|---|---|---|
receive(\.factResponse) | 케이스만 (연관값 무시) | 없음 — 기본으로 이것 |
receive(\.factResponse, "1 is a good number") | 케이스 + 연관값 | 연관값이 Equatable |
receive(.factResponse("…")) | 액션 전체 == | Action: Equatable |
receive({ … }) | 직접 쓴 조건 함수 | — |
같은 기능을 @Observable로 쓰면 메서드 하나가 await로 끝까지 기다린다. 테스트는 짧지만 중간 과정이 사라진다.
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였는지는 이 테스트로 알 수 없다
}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)를 붙여야 통과한다.
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
}
}
}
}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
}
}| 시계 | 동작 | 언제 |
|---|---|---|
TestClock() | advance(by:)를 불러야 시간이 흐름 | tick 하나하나, 디바운스 경계처럼 시간 자체가 로직일 때 |
ImmediateClock() | sleep이 즉시 끝남 | 기다림은 관심 밖이고 결과만 볼 때 (문서의 5회 타이머 예제) |
실제 시계 + timeout: | 진짜로 기다림 | 피한다 — 느리고 불안정 |
SwiftUI 모델도 같은 방법을 쓸 수 있다. 모델이 any Clock<Duration>을 생성자로 받게 하고 테스트에서 swift-clocks의 TestClock을 넣으면 된다. 차이는 기다림을 조율하는 쪽이다. 모델 테스트는 직접 Task를 띄우고 취소도 직접 해야 한다. TestStore는 이펙트 실행을 메인 스레드에서 순서대로 처리하고(useMainSerialExecutor 기본 true, 소스 기준), 남은 이펙트는 ③ 규칙으로 잡아 준다.
send는 TestStoreTask를 돌려준다. 화면이 사라질 때 부모가 취소해 주는 이펙트처럼 단독 테스트에서 끌 방법이 없다면 let task = await store.send(.startButtonTapped) { … }로 받아 두고 마지막에 await task.cancel()을 부른다. 볼트 노트 "TestStoreTask"에 SyncUps 튜토리얼의 이 패턴이 정리되어 있다.
5. 흔한 실수
@MainActor
struct FeatureTests {
let store = TestStore(initialState: Counter.State()) { Counter() } // 공유
@Test func basics() async {
await store.send(.incrementButtonTapped) { $0.count = 1 }
}
}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"). 그 밖의 실수는 짧게 정리한다.
@MainActor누락 — TestStore 클래스 자체가@MainActor다. 테스트 타입이나 함수에 붙인다.State가Equatable이 아님 —TestStore<State: Equatable, Action>라서 생성부터 컴파일 에러가 난다.- 리듀서를 여러 개 나열 —
TestStore의 리듀서 클로저는 result builder가 아니다(Swift 버그 회피, 소스 주석). 여러 개면CombineReducers { … }로 감싼다.
✅ 복습 · 5분
send 클로저에서 $0.count += 1보다 $0.count = 1이 나은 이유는?
$0은 액션 전 상태다. += 1은 "하나 늘었다"만 증명하고, 시작값이 틀렸어도 통과한다. = 1은 "지금 정확히 1이다"를 증명한다. 문서도 하드코딩 값을 권한다.exhaustive TestStore가 SwiftUI 모델 테스트에 없는 검사로 잡아 주는 두 가지는?
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 }를 부르면?
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 테스트를 쓴다.
@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으로 기다림 없이 3 → 0까지 증명하고, 마지막receive에서isFinished도 적는다.TestClock버전으로 "2초 뒤에는 remaining이 1"을 증명한다.- 확인: Xcode에서 테스트를 실행해 0.1초 안에 초록불이 뜨는지 본다.
정답 코드
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 중이라 ③ 규칙 위반으로 실패한다.