HIT해
[Swift/TCA] TCA를 활용한 테스트코드 작성 (TCA ver 1.12.1) 본문
swift-composable-architecture/Examples/Search/SearchTests/SearchTests.swift at main · pointfreeco/swift-composable-architecture
A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind. - pointfreeco/swift-composable-architecture
github.com
프로젝트를 진행하면서 백엔드 작업이 뒤쳐지고 있어 모킹 기법을 활용하여 테스트 코드를 작성해보려한다.
그리고 지금까지 사용했던 TCA를 활용해 테스트코드를 간단하게 적용해보려한다.
예시는 TCA 공식 깃허브 예제로 보여주려한다.
테스트 코드를 작성하는 이유
- 버그방지
- 코드 안정성 확보
- 리팩토링 시 안전성
- 기능 명세 확인
- 개발 속도 향상
- 컴포넌트 독립성 확인
버그방지, 코드&리팩토링 안정성 확보
코드가 변경되었을때 예상하지 못한 버그가 발생하는 것을 방지할 수 있고,
코드 리팩토링이 일어났을때 테스트 코드를 실행시키는 것만으로 안정성을 검사할 수 있다.
기능 명세 확인
테스트 코드는 문서의 역할을 대체할 수 있다. 테스트 코드를 보면 해당 기능이 어떻게 동작하고 어떤 경우에 어떤 결과가 나오게 되는지 동작과정과 결과를 명확하게 알 수 있다.
개발 속도 향상
간단하게 생각을 해보자.
채팅창에 텍스트를 입력하고 전송이 제대로 됐는지 확인하는 행동을 한다하였을때
"그냥 채팅입력 화면에가서 전송버튼 클릭하면 되는거아니야?"
라고 생각할 수 있지만
여러번의 페이지 이동을 해야 해당 화면에 가야한다면 시간이 너무 소요된다.
만약 회원가입과 같이 여러 정보를 입력해야하는 동작을 테스트 해야하는 경우 테스트 코드가 없다면 더더욱 오래 걸리게된다.
그래서 테스트 코드를 작성하는 과정이 처음에는 시간이 더 소요된다 할지라도 장기적으로 보았을때 개발 속도 향상에 도움을 준다.
컴포넌트 독립성 확인
특정 컴포넌트나 모듈이 독립적으로 동작하는지 확인할 수 있다.
특히 여러 옹작들이 병행되어 사용되는 결합도가 높은 코드는 실제로 테스트하기 어렵기 때문애,
모듈화된 테스트를 통해 유지보수하기 좋은 코드를 작성할 수 있다.
이제 예제 코드를 보며 테스트 TCA 에서 테스트코드를 작성하는 방법을 알아보자
mock 데이터
extension Forecast {
static let mock = Self(
daily: Daily(
temperatureMax: [90, 70, 100],
temperatureMin: [70, 50, 80],
time: [0, 86_400, 172_800].map(Date.init(timeIntervalSince1970:))
),
dailyUnits: DailyUnits(temperatureMax: "°F", temperatureMin: "°F")
)
}
extension GeocodingSearch {
static let mock = Self(
results: [
GeocodingSearch.Result(
country: "United States",
latitude: 40.6782,
longitude: -73.9442,
id: 1,
name: "Brooklyn",
admin1: nil
),
GeocodingSearch.Result(
country: "United States",
latitude: 34.0522,
longitude: -118.2437,
id: 2,
name: "Los Angeles",
admin1: nil
),
GeocodingSearch.Result(
country: "United States",
latitude: 37.7749,
longitude: -122.4194,
id: 3,
name: "San Francisco",
admin1: nil
),
]
)
}
TestDependencyKey 프로토콜
TestDependencyKey 프로토콜을 사용하면 선언하고 있는 클라이언트는 테스트 환경과 미리보기 환경에서 사용할 수 있는 종속성을 제공한다.
extension WeatherClient: TestDependencyKey {
// 미리보기
static let previewValue = Self(
forecast: { _ in .mock },
search: { _ in .mock }
)
// 테스트
static let testValue = Self()
}
forcast API 통신을 호출하면 실제 API 요청 값이 아니라 mock 값으로 대체된다.
테스트 mock 데이터의 경우 어떻게 할당하는걸까?
테스트코드 목업 설정은 클라이언트가 아닌 테스트코드에서 TestStore선언과 함께 설정해주면된다.
TestStore
final class SearchTests: XCTestCase {
func testSearchAndClearQuery() async {
let store = await TestStore(initialState: Search.State()) {
Search()
} withDependencies: {
$0.weatherClient.search = { @Sendable _ in .mock }
}
TestStore는 TCA에서 사용하는 테스트 도구로 애플리케이션의 상태 및 행동을 테스트할 수 있는 환경을 제공한다.

그리고 원래 리듀서를 세팅해주던 것처럼 세팅을 해주면 된다.
아래의 부분에서 mock데이터를 할당해준다.
$0.weatherClient.search = { @Sendable _ in .mock }
withDependncies 블록안에서 의존성을 주입해준다.
위의 코드를 설명하자면 weatherClient.search 라는 의존성에 .mock 을 사용해 모의 데이터를 반환하도록 설정한다.
이렇게 하면 네트워크 요청 대신 고정된 모의 데이터를 사용하여 테스트를 실행시킬 수 있다!
테스트 방법
func testSearchAndClearQuery() async {
let store = await TestStore(initialState: Search.State()) {
Search()
} withDependencies: {
$0.weatherClient.search = { @Sendable _ in .mock }
}
await store.send(.searchQueryChanged("S")) {
$0.searchQuery = "S"
}
await store.send(.searchQueryChangeDebounced)
await store.receive(\.searchResponse.success) {
$0.results = GeocodingSearch.mock.results
}
await store.send(.searchQueryChanged("")) {
$0.results = []
$0.searchQuery = ""
}
}
이렇게 테스트코드상에서 Store를 선언하고 우리가 Reducer에서 사용하던 것 처럼 선언해주면 된다.
계속 나오는 $0은 Dependencies 객체로 다양한 외부 의존성 ( ex) weatherClinet.search ) 를 설정 할 수 있다.
테스트 실행 방법
Xcode > Product > Test 를 선택해서 실행시킬 수 있다

테스트 코드 전체
import ComposableArchitecture
import XCTest
@testable import Search
final class SearchTests: XCTestCase {
func testSearchAndClearQuery() async {
let store = await TestStore(initialState: Search.State()) {
Search()
} withDependencies: {
$0.weatherClient.search = { @Sendable _ in .mock }
}
await store.send(.searchQueryChanged("S")) {
$0.searchQuery = "S"
}
await store.send(.searchQueryChangeDebounced)
await store.receive(\.searchResponse.success) {
$0.results = GeocodingSearch.mock.results
}
await store.send(.searchQueryChanged("")) {
$0.results = []
$0.searchQuery = ""
}
}
func testSearchFailure() async {
let store = await TestStore(initialState: Search.State()) {
Search()
} withDependencies: {
$0.weatherClient.search = { @Sendable _ in
struct SomethingWentWrong: Error {}
throw SomethingWentWrong()
}
}
await store.send(.searchQueryChanged("S")) {
$0.searchQuery = "S"
}
await store.send(.searchQueryChangeDebounced)
await store.receive(\.searchResponse.failure)
}
func testClearQueryCancelsInFlightSearchRequest() async {
let store = await TestStore(initialState: Search.State()) {
Search()
} withDependencies: {
$0.weatherClient.search = { @Sendable _ in .mock }
}
let searchQueryChanged = await store.send(.searchQueryChanged("S")) {
$0.searchQuery = "S"
}
await searchQueryChanged.cancel()
await store.send(.searchQueryChanged("")) {
$0.searchQuery = ""
}
}
func testTapOnLocation() async {
let specialResult = GeocodingSearch.Result(
country: "Special Country",
latitude: 0,
longitude: 0,
id: 42,
name: "Special Place"
)
var results = GeocodingSearch.mock.results
results.append(specialResult)
let store = await TestStore(initialState: Search.State(results: results)) {
Search()
} withDependencies: {
$0.weatherClient.forecast = { @Sendable _ in .mock }
}
await store.send(.searchResultTapped(specialResult)) {
$0.resultForecastRequestInFlight = specialResult
}
await store.receive(\.forecastResponse) {
$0.resultForecastRequestInFlight = nil
$0.weather = Search.State.Weather(
id: 42,
days: [
Search.State.Weather.Day(
date: Date(timeIntervalSince1970: 0),
temperatureMax: 90,
temperatureMaxUnit: "°F",
temperatureMin: 70,
temperatureMinUnit: "°F"
),
Search.State.Weather.Day(
date: Date(timeIntervalSince1970: 86_400),
temperatureMax: 70,
temperatureMaxUnit: "°F",
temperatureMin: 50,
temperatureMinUnit: "°F"
),
Search.State.Weather.Day(
date: Date(timeIntervalSince1970: 172_800),
temperatureMax: 100,
temperatureMaxUnit: "°F",
temperatureMin: 80,
temperatureMinUnit: "°F"
),
]
)
}
}
func testTapOnLocationCancelsInFlightRequest() async {
print("하잉")
let specialResult = GeocodingSearch.Result(
country: "Special Country",
latitude: 0,
longitude: 0,
id: 42,
name: "Special Place"
)
var results = GeocodingSearch.mock.results
results.append(specialResult)
let clock = TestClock()
let store = await TestStore(initialState: Search.State(results: results)) {
Search()
} withDependencies: {
$0.weatherClient.forecast = { @Sendable _ in
try await clock.sleep(for: .seconds(0))
return .mock
}
}
await store.send(.searchResultTapped(results.first!)) {
$0.resultForecastRequestInFlight = results.first!
}
await store.send(.searchResultTapped(specialResult)) {
$0.resultForecastRequestInFlight = specialResult
}
await clock.advance()
await store.receive(\.forecastResponse) {
$0.resultForecastRequestInFlight = nil
$0.weather = Search.State.Weather(
id: 42,
days: [
Search.State.Weather.Day(
date: Date(timeIntervalSince1970: 0),
temperatureMax: 90,
temperatureMaxUnit: "°F",
temperatureMin: 70,
temperatureMinUnit: "°F"
),
Search.State.Weather.Day(
date: Date(timeIntervalSince1970: 86_400),
temperatureMax: 70,
temperatureMaxUnit: "°F",
temperatureMin: 50,
temperatureMinUnit: "°F"
),
Search.State.Weather.Day(
date: Date(timeIntervalSince1970: 172_800),
temperatureMax: 100,
temperatureMaxUnit: "°F",
temperatureMin: 80,
temperatureMinUnit: "°F"
),
]
)
}
}
func testTapOnLocationFailure() async {
let results = GeocodingSearch.mock.results
let store = await TestStore(initialState: Search.State(results: results)) {
Search()
} withDependencies: {
$0.weatherClient.forecast = { @Sendable _ in
struct SomethingWentWrong: Error {}
throw SomethingWentWrong()
}
}
await store.send(.searchResultTapped(results.first!)) {
$0.resultForecastRequestInFlight = results.first!
}
await store.receive(\.forecastResponse) {
$0.resultForecastRequestInFlight = nil
}
}
}
실행 결과

이렇게 TCA 로 테스트 코드를 작성하고 테스트 하는 방법을 알아보았다.
'Swift > UIKit 개발 노트' 카테고리의 다른 글
[SwiftUI/WatchOS] 애플 워치 앱 개발해보기 (0) | 2024.09.21 |
---|---|
[Swift/Xcode] Multiple commands produce 해결하기 ( feat Tuist ) (0) | 2024.09.20 |
[iOS/SwiftUI] TCA API 통신 구현하기 with AF ( TCA 1.12.1 ) (0) | 2024.09.05 |
[iOS/SceneKit] 3D 화면에 텍스트 출력하기 (0) | 2024.09.04 |
[iOS/SceneKit] 특정 노드 액션 구현하기 (0) | 2024.09.03 |