오늘의 목표 · 25분
- 통합 테스트를
exhaustivity = .off로 줄이고, 클로저 안의 상대 변경(removeLast())을 절대값 대입으로 고친다. store.dependencies로 테스트 중간에 의존성을 바꿔 "실패 → 재시도 성공" 흐름을 한 테스트에 담는다.- XCTest 테스트를
@MainActor @Suite struct+@Test+ 인자 테스트로 옮긴다. - "테스트는 통과했는데 스위트가 실패" 같은 증상을 보고 테스트 호스트 앱, 남은 이펙트, 받지 않은 액션 중 원인을 찾는다.
1. Non-exhaustive — 관심 있는 것만 증명한다 🔥 핵심
TCA 문서 · Testing의 기준은 분명하다. 리프 기능은 exhaustive, 여러 기능을 묶은 통합 흐름은 non-exhaustive다. 문서의 로그인 → 탭 전환 예제를 컴파일되는 크기로 줄여 쓴다.
@DependencyClient
struct AuthClient {
var login: @Sendable (_ email: String) async throws -> Void
}
extension AuthClient: DependencyKey {
static let liveValue = AuthClient(login: { _ in
try await Task.sleep(for: .seconds(1)) // 실제로는 서버 호출
})
static let testValue = AuthClient() // 모든 엔드포인트가 unimplemented
}
extension DependencyValues {
var authClient: AuthClient {
get { self[AuthClient.self] }
set { self[AuthClient.self] = newValue }
}
}
@Reducer
struct Login {
@ObservableState
struct State: Equatable {
var email = ""
var isLoading = false
var errorMessage: String?
var canSubmit: Bool { email.contains("@") && !isLoading }
}
enum Action {
case delegate(Delegate)
case loginFailed
case loginSucceeded
case submitButtonTapped
@CasePathable // 중첩 enum은 직접 붙여야 \.delegate.didLogin이 된다
enum Delegate { case didLogin }
}
@Dependency(\.authClient) var authClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .delegate:
return .none
case .loginFailed:
state.isLoading = false
state.errorMessage = "로그인에 실패했다"
return .none
case .loginSucceeded:
state.isLoading = false
return .send(.delegate(.didLogin))
case .submitButtonTapped:
state.isLoading = true
state.errorMessage = nil
return .run { [email = state.email] send in
try await authClient.login(email)
await send(.loginSucceeded)
} catch: { _, send in
await send(.loginFailed)
}
}
}
}
}@Reducer
struct AppFeature {
enum Tab: Equatable { case activity, profile }
@ObservableState
struct State: Equatable {
var isLoggedIn = false
var login = Login.State()
var selectedTab = Tab.profile
}
enum Action {
case login(Login.Action)
}
var body: some ReducerOf<Self> {
Scope(\.login, action: \.login) { Login() } // 자식이 읽는 DependencyValues 값도 그대로 물려받는다
Reduce { state, action in
switch action {
case .login(.delegate(.didLogin)):
state.isLoggedIn = true
state.selectedTab = .activity
return .none
case .login:
return .none
}
}
}
}Exhaustive로 쓰면 부모 테스트가 자식의 isLoading과 내부 액션 loginSucceeded까지 알아야 한다. 자식이 로딩 표시 방식을 바꾸면 부모 테스트가 깨진다.
import Testing
@MainActor
@Test func loginExhaustive() async {
let store = TestStore(initialState: AppFeature.State(login: Login.State(email: "blob@example.com"))) {
AppFeature()
} withDependencies: { // DependencyValues의 authClient를 성공으로
$0.authClient.login = { _ in }
}
await store.send(\.login.submitButtonTapped) {
$0.login.isLoading = true // 자식 내부 상태
}
await store.receive(\.login.loginSucceeded) {
$0.login.isLoading = false // 자식 내부 액션
}
await store.receive(\.login.delegate.didLogin) {
$0.isLoggedIn = true
$0.selectedTab = .activity // 정작 확인하고 싶은 것
}
}import Testing
@MainActor
@Test func loginSwitchesTab() async {
let store = TestStore(initialState: AppFeature.State(login: Login.State(email: "blob@example.com"))) {
AppFeature()
} withDependencies: { // DependencyValues의 authClient를 성공으로
$0.authClient.login = { _ in }
}
store.exhaustivity = .off
await store.send(\.login.submitButtonTapped) // 상태 단언 생략
await store.receive(\.login.delegate.didLogin) { // 사이의 loginSucceeded는 건너뛴다
$0.selectedTab = .activity
}
}두 테스트 모두 1.26.2에서 실행해 통과를 확인했다. .off(showSkippedAssertions: true)로 바꾸면 테스트는 여전히 통과하지만, 건너뛴 상태 변화와 액션이 회색 "expected failure"로 표시된다. 운영 버그를 쫓을 때 "안 보고 있던 곳에서 무슨 일이 있었나"를 확인하는 용도다.
$0의 뜻이 뒤집힌다
가장 헷갈리는 차이다. exhaustive에서 $0은 액션 전 상태, non-exhaustive에서는 액션 후(실제) 상태다. 그래서 .off에서는 removeLast()·append 같은 상대 변경을 쓸 수 없다.
import Testing
@Reducer
struct Recents {
@ObservableState
struct State: Equatable {
var values: [String] = []
}
enum Action { case removeButtonTapped }
var body: some ReducerOf<Self> {
Reduce { state, action in
state.values.removeLast()
return .none
}
}
}
@MainActor
@Test func removeNonExhaustive() async {
let store = TestStore(initialState: Recents.State(values: ["a", "b"])) { Recents() }
store.exhaustivity = .off
await store.send(.removeButtonTapped) {
$0.values = ["a"] // 또는 #expect($0.values.count == 1)
}
}store.exhaustivity = .off
await store.send(.removeButtonTapped) {
$0.values.removeLast() // 실행 결과: "A state change does not match expectation"
}| 도구 | 하는 일 |
|---|---|
exhaustivity = .off | 적은 것만 검사. 받지 않은 액션·남은 이펙트는 조용히 넘어감 |
.off(showSkippedAssertions: true) | 위와 같되 건너뛴 것을 회색으로 표시. 클로저가 두 번 실행되므로 클로저 안에서 부수효과(UUID 생성 등) 금지 |
withExhaustivity(.off) { … } | 블록 안에서만 모드 전환, 끝나면 복원 |
skipReceivedActions() · skipInFlightEffects() | exhaustive 스토어에서 쌓인 액션 비우기 · 실행 중 이펙트 취소. strict: true(기본)면 건너뛸 게 없을 때 실패 |
finish() · assert { } | 이펙트가 끝날 때까지 대기 · 액션 없이 현재 상태만 단언 |
#expect는 적은 식만 본다. 그래서 @Observable 모델 테스트는 늘 .off와 같은 상태다. TCA는 기본이 엄격하고 필요할 때 느슨하게, @Observable은 엄격하게 만들 방법이 없다. 이 차이가 TCA 테스트의 핵심 대가이자 이득이다.
2. 의존성 바꾸기 — 시작할 때, 중간에, 안 바꿨을 때
Day 7 2부에서 의존성을 어디서 바꾸는지 봤다. 테스트에서는 세 곳이 중요하다. TestStore의 dependencies 문서 주석은 "테스트 도중에 바꿔 실패하다가 성공하는 흐름을 시뮬레이션할 수 있다"고 적는다.
import Testing
struct LoginFailure: Error {}
@MainActor
@Test func retryAfterFailure() async {
let store = TestStore(initialState: Login.State(email: "blob@example.com")) {
Login()
} withDependencies: { // DependencyValues: 처음엔 실패하는 서버
$0.authClient.login = { _ in throw LoginFailure() }
}
await store.send(.submitButtonTapped) { $0.isLoading = true }
await store.receive(\.loginFailed) {
$0.isLoading = false
$0.errorMessage = "로그인에 실패했다"
}
store.dependencies.authClient.login = { _ in } // 이제 성공
await store.send(.submitButtonTapped) {
$0.isLoading = true
$0.errorMessage = nil
}
await store.receive(\.loginSucceeded) { $0.isLoading = false }
await store.receive(\.delegate.didLogin)
}let store = TestStore(initialState: Login.State(email: "blob@example.com")) { Login() }
await store.send(.submitButtonTapped) { $0.isLoading = true }
// 실행 결과: Unimplemented: 'AuthClient.login' ← 테스트 실패로 기록
// (unimplemented 엔드포인트는 에러를 던지므로 이어서 loginFailed가 들어온다)@Observable 모델도 swift-dependencies를 그대로 쓸 수 있다. swift-dependencies 문서 · Testing의 예제가 바로 모델 기반이다. 모델을 withDependencies 안에서 만들면 그 값이 모델에 붙는다.
import Testing
@Observable @MainActor
final class LoginModel {
var email = ""
var errorMessage: String?
@ObservationIgnored @Dependency(\.authClient) var authClient
func loginButtonTapped() async {
do {
try await authClient.login(email)
errorMessage = nil
} catch {
errorMessage = "로그인에 실패했다"
}
}
}
@MainActor
@Test func modelRetry() async {
let model = withDependencies { // DependencyValues를 이 블록 안에서만 교체
$0.authClient.login = { _ in throw LoginFailure() }
} operation: {
LoginModel()
}
await model.loginButtonTapped()
#expect(model.errorMessage == "로그인에 실패했다")
await withDependencies {
$0.authClient.login = { _ in }
} operation: {
await model.loginButtonTapped()
}
#expect(model.errorMessage == nil)
}| SwiftUI (@Observable + swift-dependencies) | TCA (TestStore) | |
|---|---|---|
| 시작 시 교체 | withDependencies { } operation: { Model() } | TestStore(…) { } withDependencies: { } |
| 중간 교체 | withDependencies로 다시 감싸서 호출 | store.dependencies.x = … 한 줄 |
중간 상태(isLoading) 확인 | 메서드가 끝난 뒤라 볼 수 없음 | send 클로저에서 증명 |
3. Swift Testing 스타일
Testing 문서의 모든 예제는 이제 @Test 기반이다. 규칙은 세 가지다. 스위트를 @MainActor로, 테스트 함수를 async로, TestStore는 테스트 안의 지역 변수로 둔다(Day 8).
import Testing
@MainActor
@Suite struct LoginTests {
@Test(arguments: ["", "blob", "blob@example.com"])
func canSubmit(email: String) {
let store = TestStore(initialState: Login.State(email: email)) { Login() }
#expect(store.state.canSubmit == email.contains("@"))
}
@Test func submitLeadsToDelegate() async {
let store = TestStore(initialState: Login.State(email: "blob@example.com")) {
Login()
} withDependencies: { // DependencyValues의 authClient를 성공으로
$0.authClient.login = { _ in }
}
store.exhaustivity = .off
await store.send(.submitButtonTapped) { $0.isLoading = true } // .off: $0은 액션 후 상태
await store.receive(\.delegate.didLogin)
}
}| XCTest | Swift Testing | |
|---|---|---|
| 선언 | final class FeatureTests: XCTestCase + func testX() async | @Suite struct + @Test func x() async |
| 단언 | XCTAssertEqual | #expect · #require |
| 의존성 일괄 교체 | invokeTest()를 오버라이드해 withDependencies로 감쌈 | .dependency(\.uuid, .incrementing) · .dependencies { … } 트레이트 |
| 실행 방식 | — | 같은 프로세스 안에서 병렬 → 베이스 스위트 권장 |
트레이트는 DependenciesTestSupport를 테스트 타깃에만 링크해야 쓸 수 있다(앱 타깃에 링크하면 컴파일이 실패한다고 문서가 경고). Swift Testing은 테스트를 같은 프로세스에서 병렬로 돌리므로, 상태를 가진 의존성이 테스트끼리 섞일 수 있다. 문서의 권장은 빈 베이스 스위트 @Suite(.dependencies) struct BaseSuite {}를 만들고 모든 스위트를 extension BaseSuite { … } 안에 두는 것이다. 이 페이지의 코드 검사기에는 이 모듈이 없어서, 트레이트 문법은 별도로 모듈을 빌드해 타입 검사만 했다.
4. 공유 상태 테스트 미리보기
Day 12에서 다룰 @Shared는 참조처럼 동작하는데도 TestStore가 exhaustive로 검사한다. TCA 문서 · Sharing state에 따르면 @Shared는 테스트 중에 액션 전 스냅샷을 따로 보관한다. 단, 이펙트 안에서 바꾼 공유 상태는 store.assert로 따로 증명해야 한다.
import Testing
@Reducer
struct LaunchCounter {
@ObservableState
struct State: Equatable {
@Shared var count: Int
}
enum Action {
case appLaunched
case resetButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .appLaunched:
state.$count.withLock { $0 += 1 }
return .none
case .resetButtonTapped:
return .run { [count = state.$count] _ in
count.withLock { $0 = 0 } // 액션 없이 이펙트가 직접 바꾼다
}
}
}
}
}
@MainActor
@Test func launchAndReset() async {
let store = TestStore(initialState: LaunchCounter.State(count: Shared(value: 0))) {
LaunchCounter()
}
await store.send(.appLaunched) {
$0.$count.withLock { $0 = 1 }
}
await store.send(.resetButtonTapped)
store.assert {
$0.$count.withLock { $0 = 0 }
}
}store.assert를 빼면 실행 결과는 Test store finished before asserting against changes to shared state.이다(1.26.2에서 확인). @Observable 모델을 여러 화면이 참조로 공유하면 테스트에서 "액션 전" 모습을 따로 얻을 방법이 없다. 끝난 뒤의 값만 볼 수 있다. .appStorage·.fileStorage 같은 영속 전략의 테스트 격리는 Day 12 2부에서 본다.
5. 흔한 함정 — 테스트는 통과했는데 스위트가 실패할 때
① 테스트 호스트 앱
Testing 문서의 "Testing gotchas"가 첫 번째로 꼽는다. 앱 타깃에서 테스트를 돌리면 시뮬레이터가 실제 앱 진입점도 같이 실행한다. 앱 코드가 live 의존성을 건드리면, 테스트 컨텍스트이기 때문에 실패가 기록된다. 개별 테스트는 모두 초록인데 스위트가 빨갛게 나오는 이유다.
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
if !isTesting { // IssueReporting(TCA가 함께 가져옴)의 전역 값
RootView()
}
}
}
}
struct RootView: View {
var body: some View { Text("홈") }
}TCA Testing 문서는 같은 목적으로 TestContext.current == nil을 보여 준다. swift-dependencies 문서와 IssueReporting 소스는 isTesting을 쓴다. 소스 주석에 따르면 isTesting은 "프로세스가 테스트를 돌리는 중인가"이고, TestContext.current는 "지금 이 작업이 테스트 안인가"다.
② 남은 이펙트 · 받지 않은 액션 · 액션 버퍼
| 증상 (실패 메시지) | 원인 | 고치는 법 |
|---|---|---|
An effect returned for this action is still running | 타이머·스트림처럼 끝나지 않는 이펙트 | 끄는 액션 보내기, TestStoreTask.cancel(), 시계 진행, 또는 skipInFlightEffects() |
Must handle N received actions before sending an action | TestStore는 이펙트가 보낸 액션을 단언 전에 먼저 처리해 버퍼에 쌓는다 | 다음 send 전에 receive, 관심 없으면 skipReceivedActions() |
| 혼자 돌리면 통과, 같이 돌리면 엉뚱한 의존성 실패 | 다른 테스트가 탈출시킨 Task가 늦게 실행됨(test case leakage) | 테스트 안에서 Task { }를 띄우고 버리지 않는다 |
| 의존성을 바꿨는데 live가 불림 + "implemented in both" 경고 | 테스트 타깃이 TCA를 정적으로 한 번 더 링크 | 테스트 타깃의 "Link Binary With Libraries"에서 제거 |
SharingState 문서는 4절의 store.assert가 필요한 이유를 이렇게 설명한다. TestStore가 @Shared보다 먼저 만들어진 도구라서, 받은 액션을 단언하기 전에 먼저 처리한다. 이펙트가 공유 상태를 바꾼 시점과 테스트가 단언하는 시점이 어긋나므로, 이펙트발 공유 상태 변화는 별도 assert로 받는다.
✅ 복습 · 5분
통합 테스트를 non-exhaustive로 쓰는 이유 두 가지는?
테스트 중간에 의존성을 바꾸는 TCA 코드 한 줄과, 그것이 @Observable 방식보다 나은 점은?
store.dependencies.authClient.login = { _ in }. @Observable 모델은 withDependencies로 호출을 다시 감싸야 한다. TCA 쪽은 그 사이의 isLoading 같은 중간 상태도 send 클로저로 증명할 수 있다.🔮 코드 예측: 1절 Recents에서 values: ["a", "b"]로 시작하고 exhaustivity = .off인 채 send(.removeButtonTapped) { $0.values.removeLast() }를 보내면? 같은 코드를 .on에서 돌리면?
.off: 실패한다. $0은 이미 ["a"]이고 한 번 더 지워 []가 되므로 실제 ["a"]와 다르다. 실제 메시지는 A state change does not match expectation이고 diff에 + [0]: "a"가 보인다. .on: 통과한다. $0이 액션 전 ["a", "b"]라서 removeLast() 결과가 실제와 같다. 둘 다 1.26.2에서 실행해 확인했다.🔁 지난 Day 복습 (Day 8): exhaustive 스토어에서 receive를 빼먹고 다음 액션을 보내면 어떤 메시지가 나오나?
Must handle N received actions before sending an action. 이펙트가 되돌린 액션이 버퍼에 남아 있다는 뜻이다. 그 액션들이 바꾼 상태까지 증명하지 않았으므로 상태 불일치 실패도 함께 나올 수 있다.🛠 미니 과제 (선택 · 10분)
아래 할 일 목록 리듀서로 세 가지 테스트를 쓴다.
struct Todo: Equatable, Identifiable {
let id: UUID
var title: String
}
@Reducer
struct TodoList {
@ObservableState
struct State: Equatable {
var todos: IdentifiedArrayOf<Todo> = []
}
enum Action {
case addButtonTapped(String)
}
@Dependency(\.uuid) var uuid
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .addButtonTapped(title):
state.todos.append(Todo(id: uuid(), title: title))
return .none
}
}
}
}- exhaustive 테스트:
$0.uuid = .incrementing로 두 개를 추가하고UUID(0),UUID(1)을 하드코딩해 증명한다. - non-exhaustive 테스트: 세 개를 추가한 뒤 개수만 확인한다(상대 변경 금지).
@Test(arguments:)로 제목 세 개를 각각 추가하는 인자 테스트를 쓴다.
정답 코드
import Testing
@MainActor
@Suite struct TodoListTests {
@Test func addTwo() async {
let store = TestStore(initialState: TodoList.State()) {
TodoList()
} withDependencies: {
$0.uuid = .incrementing
}
await store.send(.addButtonTapped("우유")) {
$0.todos = [Todo(id: UUID(0), title: "우유")]
}
await store.send(.addButtonTapped("빵")) {
$0.todos = [
Todo(id: UUID(0), title: "우유"),
Todo(id: UUID(1), title: "빵"),
]
}
}
@Test func addThreeCountOnly() async {
let store = TestStore(initialState: TodoList.State()) {
TodoList()
} withDependencies: {
$0.uuid = .incrementing
}
store.exhaustivity = .off
await store.send(.addButtonTapped("a"))
await store.send(.addButtonTapped("b"))
await store.send(.addButtonTapped("c")) {
#expect($0.todos.count == 3) // .off: $0은 액션 후 상태
}
}
@Test(arguments: ["우유", "빵", "달걀"])
func addOne(title: String) async {
let store = TestStore(initialState: TodoList.State()) {
TodoList()
} withDependencies: {
$0.uuid = .incrementing
}
await store.send(.addButtonTapped(title)) {
$0.todos = [Todo(id: UUID(0), title: title)]
}
}
}인자 테스트는 인자마다 TestStore를 새로 만들므로 매번 UUID(0)부터 시작한다.