옛날에 UIKit을 바탕으로 구현했던 터틀보카를 SwiftUI TCA를 활용해 마이그레이션을 진행했다. 진행허면서 TCA를 활용해 구현했을 때 가장 활용하고 싶었던 TestStroe를 활용한 테스트 코드 구현에 대해 알아보려고 한다. 먼저 이전에 스터디를 진행하면서 테스트 코드의 중요성? 에 대해 얘기 했을때 했던 얘기가 기억에 남는 것 같다 단순히 테스트 목적 보다 단독적으로 의존성을 분리하고 명확한 책임을 가지는 캡슐화 된 코드 구현을 지향할 수 있다는 말이 제일 와닿았던 것 같다.
먼저 TCA가 자랑하는 테스트 친화적인 구조는 입력이 같아 항상 같은 출력을 보장하도록 리듀서를 구성하여 테스트 코드 구현이 가능하다.
TCA는 TestStore를 통해 테스트 진행이 가능한데 이를 통해 완전한 경리를 통해 UI 없이 순수 로직만 테스트 가능하며, 같은 입력에 같은 출력을 보장하는 순수함수를 활용한 테스트 구현이 가능하다.
TCA에서는 의존성 주입 시스템으로 @Dependency를 통해 의존성을 주입받아 처리하기 때문에 테스트 시 Mock 의존성을 쉽게 주입할 수 있는 구성을 가질 수 있다.
실질적으로 TCA로 피처를 만들고 테스크를 어떻게 설계하고 어떻게 작성하고 검증을 어떻게 할 것인지 알아보도록 하쟈
먼저 TCA 테스트의 기본 구조는 다음과 같다.
// TCA TestStore의 기본 구조
TestStore(
initialState: Reducer.State(), // 초기 상태
reducer: { Reducer() }, // 테스트할 리듀서
withDependencies: { // Mock 의존성 주입
$0.dependency = mockValue
}
)
- 먼저 TestStore로 리듀서를 감싼다.
- 의존성을 목으로 만들어 모킹한다.
- send로 액션을 보낸다 → 상태를 변화 시킨다.
- Effect가 생산한 액션은 receive로 검증한다.
- 비동기/에러/경계 흐름을 모두 시나리오에 담는다.
현재 단어장을 생성하고 제거하는 과정을 작성하는 것으로 한번 해당 구조로 테스트를 구현해봐야겠다.
의존성 정의, 모킹 설계
리듀서 안에서 현재 coreDataDependency를 만들어 사용하고 있으므로, 테스트에서 이걸 그대로 사용하는게 아닌 Mock으로 만들어 대체할 수 있도록 구현해야한다.
// 프로덕션 코드 어딘가에 존재해야 하는 형태 (예시)
struct CoreDataDependency {
var getBookCases: @Sendable () async throws -> [BookCase]
var deleteBookCase: @Sendable (BookCase) async throws -> Void
}
private enum CoreDataDependencyKey: DependencyKey {
static let liveValue = CoreDataDependency(
getBookCases: { /* 실제 Core Data에서 읽기 */ [] },
deleteBookCase: { _ in /* 실제 Core Data에서 삭제 */ }
)
static let testValue = CoreDataDependency(
getBookCases: { [] },
deleteBookCase: { _ in }
)
}
extension DependencyValues {
var coreDataDependency: CoreDataDependency {
get { self[CoreDataDependencyKey.self] }
set { self[CoreDataDependencyKey.self] = newValue }
}
}
CoreData의 NSManagedObject를 그대로 상태로 들고 오기보다, 테스트 친화적인 타입을 만들어 매핑해 리듀서를 넣는 방식이 좋은 것 같다.
테스트 흐름 파악
먼저 테스트 시나리오는 다음과 같다. onAppear → load → loaded
저장소에 [A, B]가 있다고 가정하고 .onAppear를 보낸다. 그러면 내부적으로 .loadBookCases가 발생하고, isLoading = true가 된다. 그리고 비동기 로드 후 .bookCasesLoaded([A, B])가 발생하며 isLoading = false, 상태에 데이터가 채워진다.
import XCTest
import ComposableArchitecture
@MainActor
final class BookCaseListReducerTests: XCTestCase {
func test_OnAppear_LoadsBookCases_Success() async {
let initial = [BookCase.a, .b]
let store = TestStore(
initialState: BookCaseListReducer.State(),
reducer: { BookCaseListReducer() },
withDependencies: {
$0.coreDataDependency.getBookCases = { initial }
}
)
// onAppear → 내부적으로 loadBookCases를 "보내는" 동기 효과가 발생
await store.send(.onAppear)
// onAppear가 "보낸" 액션을 우리는 receive로 "기다려서" 검증
await store.receive(.loadBookCases) {
$0.isLoading = true
}
// 비동기 run 효과가 끝난 뒤 결과 액션을 받음
await store.receive(.bookCasesLoaded(initial)) {
$0.bookCases = initial
$0.isLoading = false
}
}
}
로드 실패 시나리오 - 빈 목록 처리 /로딩 종료 보장
private enum FakeError: Error { case boom }
@MainActor
final class BookCaseListReducerTests: XCTestCase {
func test_LoadBookCases_Failure_ResultsInEmptyList() async {
let store = TestStore(
initialState: BookCaseListReducer.State(),
reducer: { BookCaseListReducer() },
withDependencies: {
$0.coreDataDependency.getBookCases = { throw FakeError.boom }
}
)
await store.send(.onAppear)
await store.receive(.loadBookCases) {
$0.isLoading = true
}
await store.receive(.bookCasesLoaded([])) {
$0.bookCases = []
$0.isLoading = false
}
}
}
여기서 저장소/네트워크 오류가 나도 로딩 상태로 멈추지 않도록 로딩의 사양을 고정하도록 구현, 리듀서는 실패 시 bookCasesLoaded([])를 보내도록 되어 있으니 그 동작을 그대로 검증한다.
항목 선택
@MainActor
final class BookCaseListReducerTests: XCTestCase {
func test_SelectBookCase_UpdatesSelected() async {
let store = TestStore(
initialState: {
var s = BookCaseListReducer.State()
s.bookCases = [.a, .b]
return s
}(),
reducer: { BookCaseListReducer() }
)
await store.send(.bookCaseSelected(.b)) {
$0.selectedBookCase = .b
}
}
}
단순한 상태 변경도 무엇을 기대하는지를 테스트에 새겨두어 명세화 해뒀다!
새로고침 - 같은 경로를 타는지 검증
@MainActor
final class BookCaseListReducerTests: XCTestCase {
func test_RefreshBookCases_ReusesLoadPath() async {
// 1) 저장소 스냅샷(이 테스트에서만 쓰는 간단한 상태형 목)
var storage: [BookCase] = [.a]
let store = TestStore(
initialState: BookCaseListReducer.State(),
reducer: { BookCaseListReducer() },
withDependencies: {
$0.coreDataDependency.getBookCases = { storage }
}
)
// 최초 로드
await store.send(.onAppear)
await store.receive(.loadBookCases) { $0.isLoading = true }
await store.receive(.bookCasesLoaded([.a])) {
$0.bookCases = [.a]
$0.isLoading = false
}
// 2) 새 데이터가 생겼다고 가정
storage = [.a, .b]
// 새로고침 → 내부적으로 로드 트리거
await store.send(.refreshBookCases)
await store.receive(.loadBookCases) {
$0.isLoading = true
}
await store.receive(.bookCasesLoaded([.a, .b])) {
$0.bookCases = [.a, .b]
$0.isLoading = false
}
}
}
refreshBookCase가 별도의 우회 로직이 아니라, 반드시 loadBookCases 경로를 재사용한다는 보장을 만들어 둔다.
이렇게 구현한 테스트 코드를 각 메서드 별로 유닛 테스트를 진행하게 된다면 아래와 같은 결과를 얻고 정상적으로 테스트 진행이 가능하다.

Test Suite 'BookCaseFormReducerTests' started at 2025-10-02 01:31:10.547.
Test Case '-[TURTLEVOCATests.BookCaseFormReducerTests testCreateBookCaseEmptyNameShowsError]' started.
Test Case '-[TURTLEVOCATests.BookCaseFormReducerTests testCreateBookCaseEmptyNameShowsError]' passed (0.010 seconds).
Test Suite 'BookCaseFormReducerTests' passed at 2025-10-02 01:31:10.557.
Executed 1 test, with 0 failures (0 unexpected) in 0.010 (0.010) seconds
이렇게 구현된 로직은 완전한 격리를 통해 순수한 비즈니스 로직만 테스트 가능하고, 목을 통해 외부 의존성을 제어하며 모든 상태 변화를 단계별로 검증할 수 있는 구조를 가지고 있다고 할 수 있을까? 그래도 이런 고민을 하면서 개발하는거 자체가 앞으로도 퀄리티 상승에 도움이 될 수 있을 것 같다고 생각한다.
이렇게 TCA의 TestStore를 활용한 테스트 코드 구현을 통해 단순한 테스트를 넘어 의존성을 분리하고 명확한 책임을 가지는 코드를 구현할 수 있었다. 특히 send와 receive를 통해 액션의 흐름을 추적하고, 목 의존성을 통해 외부 시스템과 완전한 격리를 통해 예측 가능하고 안정적인 테스트를 구현할 수 있다는 것을 알게 됐다!
오늘은 여기까지!
'◽️ Programming > TCA' 카테고리의 다른 글
| TCA - Effect & Test 에 대해서 조금 더 알아보기 (0) | 2025.04.09 |
|---|---|
| SwiftUI TCA - Dependency, Reducer, Effect에 대해서 알아보자!! (0) | 2024.11.21 |
| Share Run TCA 로직 구현 (3) | 2024.09.30 |
| SwiftUI TCA (3) - Binding (1) | 2024.09.23 |
| SwiftUI TCA (2) - Effect (2) | 2024.09.15 |