저번 시간 'Side Effect'에서는 TCA에서 어떻게 Effect를 관리하고 처리하는지 알아보았습니다. 이번 시간은 제목에서 보여지는것처럼 개발자가 구현했던 테스트에 관한 내용입니다.
TCA에서는 State, Action를 Reducer로 관리하고 이를 View에서 선언하여 뷰와 기능을 구현합니다. 기능은 개별적으로 역할을 수행하기때문에 TCA는 테스팅을 하는데 매우 적합한 아키텍처라고 볼 수 있습니다.
TCA에서 테스트가 필요한 부분은 Reducer이며 이는 두 가지를 테스트하는 것으로 귀결됩니다.
- 액션이 전송될 때 상태가 어떻게 변형되는지
- effect가 어떻게 실행되어 데이터가 다시 리듀서로 반환되는지
상태 변경은 Reducer가 순수 함수를 형성하기 때문에 테스트하기 쉽습니다.
Reducer에 State와 Action의 Effect로 어떻게 변경되었는지만 확인하면 됩니다.
Composable Architecture는 TestStore를 통해 이를 가능하게 해줍니다.
TestStore Documentation
@MainActor
struct CounterFeatureTests {
@Test
func basics() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
}
}
@ObservableState
struct State: Equatable {
var count = 0
var fact: String?
var isLoading = false
var isTimerRunning = false
}
@MainActor
struct CounterFeatureTests {
@Test
func basics() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
await store.send(.incrementButtonTapped)
await store.send(.decrementButtonTapped)
}
}
func basics() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
await store.send(.incrementButtonTapped)
// ❌ State was not expected to change, but a change occurred: …
//
// CounterFeature.State(
// − count: 0,
// + count: 1,
// fact: nil,
// isLoading: false,
// isTimerRunning: false
// )
//
// (Expected: −, Actual: +)
await store.send(.decrementButtonTapped)
// ❌ State was not expected to change, but a change occurred: …
//
// CounterFeature.State(
// − count: 1,
// + count: 0,
// fact: nil,
// isLoading: false,
// isTimerRunning: false
// )
//
// (Expected: −, Actual: +)
}
-> 이는 Action 이후에 해당 작업의 동작이 완료된 이후 State가 어떻게 변경되는지 정확하게 설명해야하기 떄문입니다. 라이브러리는 State과 Expected("-"가 있는 줄)와 Actual("+")을 보여주는 자세한 실패 메시지를 보여줍니다.
@MainActor
struct CounterFeatureTests {
@Test
func basics() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
await store.send(.incrementButtonTapped) {
$0.count = 1
}
await store.send(.decrementButtonTapped) {
$0.count = 0
}
}
}
테스트 실패를 수정하려면 Action을 보낸 후 State가 어떻게 바뀌었는지 어설션해야합니다.
메서드에 후행 클로저를 추가하면 되고, 해당 클로저는 액션이 전송되기 전 send상태의 변경 가능한 버전으로 전달받으며 액션이 전송된 후의 상태와 $0이 같도록 하면 됩니다.
위와같이 수정하면 Test는 통과됩니다!
우리는 방금 Reducer가 Action을 처리할 때 State 테스트를 배웠습니다. 이번에는 Effect를 어떻게 테스트하는지 알아봅시다!
Effect는 일반적으로 외부 시스템에 대한 의존성을 제어한 다음 테스트에 적합한 버전의 의존성을 제어해야하므로 side effect에 대해 테스트를 작성하려면 훨씬 더 많은 작업이 필요합니다.
우선 타이머 테스트를 통해 알아보도록 합시다!
func timer() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
await store.send(.toggleTimerButtonTapped) {
$0.isTimerRunning = true
}
// ❌ An effect returned for this action is still running.
// It must complete before the end of the test. …
}
Section 1에서 했던것처럼 TestStore를 만들고 .toggleTimerButtonTapped Action을 보낸뒤 State가 어떻게 바뀌는지 클로저로 assertion 해줍니다.
하지만, 테스트는 오류를 뱉습니다.
-> TestStore는 테스트가 끝나기 전에 assertion한 모든 결과를 테스트해야합니다. 아직 effect가 실행중이라는 것을 모르고 예쌍치 못한 작업을 시스템으로 다시 내보내는 경우 해당 작업의 Status mutable에 문제가 있는 경우 등의 문제를 알 수 있기 때문에 디버깅에 용이합니다.
await store.send(.toggleTimerButtonTapped) {
$0.isTimerRunning = true
}
await store.send(.toggleTimerButtonTapped) {
$0.isTimerRunning = false
}
$0.isTimerRunning의 값을 False로 바꿔주면 됩니다.
이렇게하면 테스트는 통과하게 됩니다.
하지만, 이게 맞는 테스트라고는 말할 수 없습니다. 우리는 타이머의 동작에 관한 테스트를 하고 싶은데 해당 action은 단순 토글버튼만 클릭할 뿐이기 떄문이죠.
await store.receive(\.timerTick) {
$0.count = 1
}
// ✅ Test Suite 'Selected tests' passed.
// Executed 1 test, with 0 failures (0 unexpected) in 1.044 (1.046) seconds
// or:
// ❌ Expected to receive an action, but received none after 0.1 seconds.
타이머 동작을 테스트하기위해 timerTick action을 보내고 count의 값이 1로 바뀔 것이라는 내용을 추가합니다.
하지만 이 테스트는 때로는 통과하지만 실행하는데 1초 이상 걸리고, 떄로는 실패하는 것을 알 수 있습니다.
await store.receive(\.timerTick, timeout: .seconds(2)) {
$0.count = 1
}
타이머가 방출하는 데 1초가 걸리지만 테스트 스토어는 특정 시간 동안만 기다려서 액션을 수신하고, 수신하지 않으면 테스트 실패를 생성하기 때문에 이런 일이 발생합니다. TestStore는 테스트가 느려지는 것을 원하지 않기 때문입니다. 그래서 위처럼 액션을 수신하기 위해 더 많은 시간을 기다리도록 하기 위해 할 수 있는 한 가지 방법은 매개변수를 사용하는 것입니다. 명시적인 시간 초과로 테스트가 통과하기 떄문에 1초이상 기다리게 해야 합니다. 위처럼 timeout을 걸어주면 됩니다.
하지만, 테스트를 실행하는 데 1초 이상 걸립니다. 매번 테스트를 할때마다 일정 시간 이상을 기다려야 하는 것이죠. 그런데 타이머를 10초마다 틱하도록 변경하고 싶다면 테스트를 할 때마다 10초를 기다려야 하는 것일까요??
해결책은 전역적이고 제어할 수 없는 Task.sleep에 접근하지 않는 것입니다.
다행히도, Composable Architecture에서는 종속성 관련 함수를 제공합니다.
Composable Architeceture Dependencies 문서
CounterFeature.swift 메서드에 아래 Dependency를 추가합니다.
@Dependency(\.continuousClock) var clock
case .toggleTimerButtonTapped:
state.isTimerRunning.toggle()
if state.isTimerRunning {
return .run { send in
// clock 사용
for await _ in self.clock.timer(interval: .seconds(1)) {
await send(.timerTick)
}
}
.cancellable(id: CancelID.timer)
} else {
return .cancel(id: CancelID.timer)
}
}
위처럼 CounterFeature.swift 파일을 수정해줍니다.
이후 clock 테스트를 하기 위해 다음과 같이 작성해줍니다.
func timer() async {
let clock = TestClock()
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
} withDependencies: {
$0.continuousClock = clock
}
await store.send(.toggleTimerButtonTapped) {
$0.isTimerRunning = true
}
await clock.advance(by: .seconds(1))
await store.receive(\.timerTick) {
$0.count = 1
}
await store.send(.toggleTimerButtonTapped) {
$0.isTimerRunning = false
}
}
testTimer 메서드 상단에 TestClock을 구성합니다. 이 시계는 시간을 제어할 수 있도록 기능의 축소기에 사용하고자 하는 시계입니다. 이를 위해 TestStore에 종속성이라는 또 다른 후행 클로저를 제공하며, 원하는 종속성을 재정의할 수 있습니다. 그런 다음 마지막으로 timerTick 작업을 받기 전에 테스트 클럭을 1초 앞당기라고 지시합니다.
test에서도 메서드 상단에 TestClock을 구성합니다. 이는 테스트에서 reducer가 시간을 제어할 수 있도록 합니다. TestStore의 후행 클로저에 testClock을 등록해주고 이후 timerTick작업을 받기 전에 1초가 지나게 설정해줍니다.
이처럼 dependency를 이용해 clock을 조정하면 테스트를 보다 간단하게 할 수 있습니다.
위에서 Clock으로 Side Effect 테스트를 한 것처럼 네트워킹도 할 수 있습니다.
특히, 네트워크 요청은 애플리케이션에서 가장 흔하고 주로 보이는 Side Effect 입니다. 외부 서버가 사용자의 데이터를 보관하는 경우가 많기 떄문입니다. 그렇기 때문에 네트워크 요청 테스트는 어려울 수 있습니다.
요청을 하는 것이 느릴 수도 있고, 네트워크 연결이나 서버에 따라 달라질 수 있으며, 서버에서 어떤 종류의 데이터가 다시 전송될지 예측할 방법이 없기 떄문입니다.
다음 예제를 통해 어떤게 잘못되었는지 알아봅시다.
func numberFact() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
await store.send(.factButtonTapped) {
$0.isLoading = true
}
// ❌ An effect returned for this action is still running.
// It must complete before the end of the test. …
}
해당 테스트 실패는 네트워크 요청이 아직 완료되지 않았기 때문입니다.
에러를 수정해보겠습니다.
await store.send(.factButtonTapped) {
$0.isLoading = true
}
await store.receive(\.factResponse, timeout: .seconds(1)) {
$0.isLoading = false
$0.fact = "???"
// ❌ A state change does not match expectation: …
//
// CounterFeature.State(
// count: 0,
// − fact: "???",
// + fact: "0 is the atomic number of the theoretical element tetraneutron.",
// isLoading: false,
// isTimerRunning: false
// )
}
위처럼 isLoading을 false로 만들어주어 네트워크 요청이 완료되었음을 알려줍니다. 하지만, 새로운 문제가 발생합니다. 바로 네트워킹 완료 후 내려오는 fact의 값이 어떨지 예측할 수 없다는 것입니다!! 서버는 매번 다른 값을 보내줄 것이고 설령 서버에서 보내는 데이터를 예측할 수 있다 하더라도, 인터넷 연결과 외부 서버의 가동 시간이 필요하기 때문에 테스트가 느리고 불안정해질 것이기 때문에 여전히 이상적이지 않습니다.
위의 사례를 통해 feature 코드에서 제어되지 않는 종속성을 사용하는 데 문제가 있음을 알게 되었습니다. 코드를 테스트하기 어렵게 만들고, 테스트를 실행하는 데 시간이 오래 걸리거나 불안정해질 수 있습니다. 이번 섹션에서는 외부 시스템에 대한 종속성을 제어하는 방법을 알아봅시다!!
import ComposableArchitecture
struct NumberFactClient {
var fetch: (Int) async throws -> String
}
TIP>
프로토콜은 종속성 인터페이스를 추상화하는 유명한 방법이지만 유일한 방법은 아닙니다. 우리는 인터페이스를 나타내기 위해 변경 변경 가능한 속성이 있는 구조체를 사용한 다음, 적합성을 나타내기 위해 구조체의 값을 구성하는 것을 선호합니다. 즉, 프로토콜을 사용하는 방법도 있지만 구조체 스타일로도 사용할 수 있습니다.
비디오 강의
import ComposableArchitecture
import Foundation
struct NumberFactClient {
var fetch: (Int) async throws -> String
}
extension NumberFactClient: DependencyKey {
static let liveValue = Self(
fetch: { number in
let (data, _) = try await URLSession.shared
.data(from: URL(string: "http://numbersapi.com/\(number)")!)
return String(decoding: data, as: UTF8.self)
}
)
}
NOTE> 라이브러리에 Dependency를 등록하는 것은 SwiftUI에 Environment Value를 등록하는 것과 다르지 않습니다.
var numberFact: NumberFactClient {
get { self[NumberFactClient.self] }
set { self[NumberFactClient.self] = newValue }
}
}
이제 이 기능에서 약간의 작업을 마치면 네트워크 대신 완벽하게 테스트를 통과할 수 있게됩니다.
func numberFact() async {
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
}
await store.send(.factButtonTapped) {
$0.isLoading = true
}
await store.receive(\.factResponse, timeout: .seconds(1)) {
$0.isLoading = false
$0.fact = "???"
}
// ❌ @Dependency(\.numberFact) has no test implementation, but was
// accessed from a test context:
//
// Location:
// CounterFeature.swift:70
// Dependency:
// NumberFactClient
//
// Dependencies registered with the library are not allowed to use
// their default, live implementations when run from tests.
}
테스트는 여전히 실패하지만 새로운 메시지가 있습니다. 테스트에서 라이브 종속성을 오버라이드하지 않고 사용하고 있다는 것을 알려줍니다. 이것은 테스트에서 실수로 네트워크 요청을 하지 않거나 디스크에 무언가를 쓰거나 의도하지 않은 동작을 알려주기때문에 완벽한 실패입니다.
결정적으로, 우리는 테스트에서 라이브 네트워크 요청을 하고싶지 않으므로 이를 수정해 보겠습니다.
let store = TestStore(initialState: CounterFeature.State()) {
CounterFeature()
} withDependencies: {
$0.numberFact.fetch = { "\($0) is a good number." }
}
await store.receive(\.factResponse) {
$0.isLoading = false
$0.fact = "0 is a good number."
}
이번 챕터에서는 TCA Test에 대해 전체적으로 배워보았습니다!
튜토리얼을 따라가면서 이해가 안되는 부분들도 있었지만 정리를 통해 막연했던 점이 풀릴 수 있었습니다.