이전까지는 테스트코드나 프리뷰 코드 없이 오직 Dependency의 라이브밸류를 통해서만 개발을 테스트하고 진행했다. 하지만 이렇게 하다보니 계속 빌드를 해야한다는 단점이 있었고 원하는 플로우를 보기위해 과정이 커질수록 시간이 오래걸리고 임의의 값을 넣어 테스트하기도 힘들었다. 그래서 드디어 테스트 코드와 프리뷰코드를 공부해보려 한다. ㅠㅠㅠ 늦었따!
역시나 노션문서를 통해 참고하여 정리한다.
Dependency를 통해 의존성의 동일성을 해치지않고 안전하게 관리해줄수 있다. 그전에 Dependency를 통해 어떻게 testcode와 preveiw코드를 만들수 있는지 알아보자.
이전에 dependencyKey에서 이미 liveValue외에 previewValue, testValue를 만들 수 있는 것을 확인했다.
withDependencies를 통해 Dependency를 오버라이딩할 수 있다.
예시의 코드처럼 TestStore를 만들고 그 밑에 withDependencies에서 var getRandomImageData를 테스트에 맞게 더미또는 목업데이터를 넣는다.
또한 mock 객체를 사용하는 예제를 살펴보자면 온보딩 과정이 있다. 튜토리얼중에 실수로 데이터를 덮어쓰거나 네트워크 통신을 하게 된다면 사용자에게 좋지 않은 경험을 주기에 테스트나 튜토리얼 같은 상황에서는 실제로 영향을 주지 않으면서 동작을 시뮬레이션 할 수 있는 MOCk객체를 사용한다. 이 것은 실제 기능처럼 보이지만, 사실은 아무것도 하지 않거나 테스트에만 필요한 가짜데이터를 말한다.
final class AppModel: ObservableObject {
@Published var onboardingTodos: TodosModel?
func tutorialButtonTapped() {
self.onboardingTodos = withDependencies(from: self) {
$0.apiClient = .mock // 실제 네트워크 요청 대신 Mock 버전 사용
$0.fileManager = .mock // 실제 파일 저장 대신 Mock 버전 사용
$0.userDefaults = .mock // 실제 사용자 기본값 대신 Mock 버전 사용
} operation: {
TodosModel() // 종속성을 주입받은 TodosModel 생성
}
}
}
final class TodosModel: ObservableObject {
@Published var todos: [Todo] = []
@Published var editTodo: EditTodoModel?
@Dependency(\.apiClient) var apiClient
@Dependency(\.fileManager) var fileManager
@Dependency(\.userDefaults) var userDefaults
func tappedTodo(_ todo: Todo) {
self.editTodo = EditTodoModel(todo: todo)
}
}
예제코드이다. TodosModel 객체는 네트워크 요청, 파일관리, 사용자기본값같은 종속성을 사용한다. 하지만 튜토리얼에서는 실제 기능이 동작하는 걸 원하지 않아 Mock데이터를 넣는다.
withDependencies 메서드: 특정객체(여기선 AppModel)에서 종속성을 가져와 종속성을 재정의한다.
from: self는 이 AppModel이 사용하는 기존의 종속성 환경을 기반으로 하겠다는 뜻. 즉 원래 가지고 있던 종속성을 가져오되, 특정 부분만 우리가 원하는데로 바꾸어 쓰겟다는 뜻! 이걸로 인해 튜토리얼중에는 실제 네트워크요청이나 파일저장을 막을 수 있다.
하지만 TodosModel이 하위모델이나 더 깊은 계층에 종속성을 전달해야하는 경우 이 withDependency 메서드를 호출할때 조심해야한다. 만약 하위 모델이 상위모델의 종속성을 상속받을 경우 반드시 이 메서드 호출안에서 모든 하위모델을 생성해야 상위 모델에서 재정의한 종속성이 올바르게 전파된다.
final class TodosModel: ObservableObject {
@Published var todos: [Todo] = []
@Published var editTodo: EditTodoModel?
@Dependency(\.apiClient) var apiClient
@Dependency(\.fileManager) var fileManager
@Dependency(\.userDefaults) var userDefaults
func tappedTodo(_ todo: Todo) {
self.editTodo = EditTodoModel(todo: todo)
}
}
func tappedTodo(_ todo: Todo) {
self.editTodo = withDependencies(from: self) {
EditTodoModel(todo: todo)
}
}
위에처럼 하면 안되고 아래가 더있기에 아래처럼 withDependencies를 통해 해주어야함. 자 여기까지가 목업짜는 얘기였고
Testable Code
각 상태의 변형은 Action에 의한 변형을 주관하는 Reducer가 그 책임을 가지고 있으며 각 Reducer는 독립적이다. 그렇지만 ifLet, .forEach등 메소드를 통해 유기적으로 관계를 맺을 수 있었다. 더더욱 개발자는
1. State가 의도적으로 변형되는지, 2. 각 Reducer의 State,Action이 원활하게 전달되는지 3. 특정 Action을 트리거할때, 다른 Action이 제대로 피드백 되는지
그래서 TCA test code는 각 Action을 직접 트리거하고 야기하는 변형을 State에 직접 할당하여 비교하는 방식이다. 만약 다를때는 Fail, Log로 확인이 가능하다.
- test하고 싶은 기능 빠르게 구현 가능, Action의 흐름을 점검 ㄱㄴ
- Dependency의 커스터마이징을 통해 서버 의존성 배제한 테스트 ㄱㄴ
- Action의 기본플로우 벗어나는 사용플로우 테스트 ㄱㄴ?
- 메인쓰레드에서 확인하고 비동기 작업에 대응하기 위해 XCTestCase클래스는 @MainActor
어캐하지? UITesT를 만들면 젤 첨 이렇게 생김. 왼쪽의 마름모를 클릭하면 final Class에 속하는 모든 기능 테스트를 진행할 수 있다. 그 후 결과에 따라 체크 혹은 X아이콘으로 변경된다. 사실 위의코드는 필요하지않다. 그래서 TCA테스트를 위해서는 기본코드가 이렇게된다.
import ComposableArchitecture
import XCTest
@testable import ${ProjectName}
@MainActor
final class UnitTestFileName: XCTestCase {
// Add Each Unit Test Code
}
import ComposableArchitecture
import XCTest
@testable import TCAWorkshop
@MainActor
final class GuessMyAgeTest: XCTestCase {
func testTextField_Writing() async throws {
let testStore = TestStore(
initialState: GuessMyAgeFeature.State(name: "Name")
) {
GuessMyAgeFeature()
}
// 🧩 단순 TextField 테스트에는 의존성을 재정의할 필요가 없기 때문에
// 🧩 ``withDependencies`` 후행 클로저 생략
// 1️⃣ 특정 ``Action``을 트리거 하기 위해 ``.send(_:assert:file:line:)`` 호출
// 2️⃣ 후행 클로저에서 ``assertEquals()``가 작동하도록 값을 할당
// 3️⃣ send된 ``Action``이 실제로 예상 변경 값과 동일한 변화를 만들 경우,
// 4️⃣ Test 통과
// 🧩 initialState의 ``name``이 "NewName"을 새로 갖도록
// 🧩 ``Action``의 연관값으로 전달
await testStore.send(.nameTextFieldEditted("NewName")) { state in
// 🧩 ``TestStore``의 ``State``를 받아온 후,
// 🧩 해당 값이 실제로 "NewName"으로
// 🧩 정상적으로 할당될 것인지 테스트
$0.name = "NewName"
// ✅ ``State``의 변형이 실제와 동일하다면 테스트 통과
}
}
}
TestStore를 통해 기본적으로 action을 트리거하거나 Action이 다른 피드백하는 Action을 받아오는 2개의 메서드를 제공한다.
.send(_:assert:file:line:) : 새로운 Action 트리거한다. 비동기로 선언되어잇기에 Action을 트리거하기 전까지 일시중지가 가능하고 Action 결과를 곧바로 확인 ㄱㄴ하다. 모든 send는 @MainActor 어노테이션되어있다.
assert의 후행 클로저에서 State제공하며, 우리가 테스트하며 기대하는 값을 할당한다. 아무런 변화가 예상되지 않으면 생략ㄱㄴ.
exhaustivity 속성을 .offf = 모든 state변화 무시하고 Action플로우만 check. or 특정하게 테스트 원하는 Action에 대해서만 assert만을 진행 ㄱㄴ.
다만, State 동일성을 기준으로 테스트의 통과 유무가 정해지기 때문에 개발자가 제어할수 없는 외부 의존성의 차이가 테스트 방해하기도 하기에 state 모든 속성이 equtable을 채택하도록 보장해야한다.
이제는 액션이 다른 액션을 통해 연쇄작업을 살펴보자. 유저가 이름을 입력한 후 버튼을 누르면 해당하는 이름의 나이를 받아오는 기능이다. 뭐 단순히 입력값을 서버로 보내서 값기록해두고 테스트하면 될거같다 but) 우리는 유저가 입력할 가능성이 있는 모든 값을 테스트 할 수 없다. 테스트를 하고 싶은 대상은 이름을 전달하고 받아올 서버의 값이 아닌 구현해 둔 기능 자체이기에!! ㄱ냥 서버에 전달해주는 값과 무관하게 기능 자체만을 테스트하기위해 목업데이터로 테스트를 한다.
func testGuessAge_Success() async throws {
let guessAgeInstance = GuessAge.testInstance()
let testStore = TestStore(
initialState: GuessMyAgeFeature.State(name: "Name")
) {
GuessMyAgeFeature()
} withDependencies: {
$0.guessAgeClient.singleFetch = { _ in return guessAgeInstance }
}
// 1️⃣ 유저가 버튼을 누르는 ``Action`` 트리거
// 2️⃣ 해당 ``Action``이 수행하는 ``State`` 변형에 대한 ``assert``
await testStore.send(.guessAgeButtonTapped) {
$0.isGuessAgeButtonTapped = true
$0.age = nil
$0.isGuessAgeIncorrect = false
}
// 3️⃣ ``.guessAgeResponse``가 mock-up을 받아오도록 하여 서버 의존성 제거
// 🧩 ``Action``이 피드백되며 발생하는 ``State`` 변형에 대한 테스트 진행
// ✅ mock-up의 속성에 따라 분기처리 되는 ``State``가 올바르게 할당된다면
// ✅ 네트워크 통신에 대한 서버 비의존적인 방식의 기능 테스트 성공
await testStore.receive(.guessAgeResponse(guessAgeInstance)) {
$0.isGuessAgeButtonTapped = false
if let age = guessAgeInstance.age {
$0.age = age
} else {
$0.isGuessAgeIncorrect = true
}
}
}
다음은 실패의 경우이다. error throw하는 경우이다. 액션이 어떤 state변형을 일으키는지 알고 있기에 exhaustivity속성을 수정하여 해당 state테스트는 생략하였다.
func testGuessAge_Fail() async throws {
enum GuessAgeTestError: Error { case fetchFailed }
let guessAgeInstance = GuessAge.testInstance()
let testStore = TestStore(
initialState: GuessMyAgeFeature.State(name: guessAgeInstance.name)
) {
GuessMyAgeFeature()
} withDependencies: {
// 🧩 기존의 네트워크 통신 로직이 실패하는 상황을 가정
// 🧩 테스트가 진행되는 동안, Reducer는 아래의 재할당된 throw 클로저를 호출
$0.guessAgeClient.singleFetch = { _ in throw GuessAgeTestError.fetchFailed }
}
// 🧩 테스트가 모든 ``State`` 변형에 대해 진행되지 않도록 ``exhaustivity`` 속성을
// 🧩 ``.off(showSkippedAssertions: false)``로 재할당
// 🧩 ``.off(showSkippedAssertions: true)``로 재할당할 경우, 생략된 테스트에 대한
// 🧩 잠재적 실패 상황을 Gray Message로 확인 가능
testStore.exhaustivity = .off(showSkippedAssertions: false)
await testStore.send(.guessAgeButtonTapped)
// 1️⃣ 네트워크 통신이 실패하면 ``GuessAgeTestError.fetchFailed``를 ``throw``
// 2️⃣ 에러에 대한 처리를 진행하고 ``State``의 변형에 대한 테스트 수행 가능
// ✅ ``State`` 변형에 대한 테스트 결과에 따라 테스트 성공
await testStore.receive(.guessAgeFetchFailed)
}
자 여기가지 진행했고 이제 마지막으로 navigationstack내부에서 발생하는 부모-자식간의 데이터 흐름테스트를 진행해보자.
상위 feature에서 navigationStack을 관리할때 다음과 같은 경우가 있다.
1. Root의 NavigationStack path구조체가 갖는 Child State와 Action을 받아와야 한다.
2. Child의 Action에 반응하여 Root의 Action을 트리거할 수 있어야한다.
3. 트리거된 Action이 Child의 State와 Root의 State를 올바르게 변형을 하는지 확인가능해야한다.
즉 부조-자식이 갖는 State, Action 모두 필요. StackState가 StackElementId를보관하고 있기에 접근하면 편하겠지만!!! 하지만!!
NavigationStack은 각 기능의 Reducer에 대해 외부에서 접근 불가능하도록 불투명하게 StackElementId를 관리한다. 그. ㅏ체가 이미 Dependency로 선언되어있다.
하지만 정수형 인스턴스로 개발자가 직접 생성이 가능하다. StackElementId를 0으로 부여받고 새로운 기능이 path stack에 쌓일때마다 +1, 하지만 모든 요소를 제거하고 추가하여도 0으로 초기화 X 왜? 값들이 테스트내부에서 세대적(주로 값이나 상태가 여러 세대를 거쳐 변해가는 특성)이다. 식별자 값들은 같은 세대에서 재사용되지 X 추가된 요소가 새로운 세대에 속한다는 뜻같다.
func testNavigationStack_Child_GuessMyAge_Parent_Update() async throws {
let guessAgeMock = GuessAge.testInstance()
let testStore = TestStore(
initialState: AppFeature.State(
path: StackState([
AppFeature.Path.State
.guessMyAge(
GuessMyAgeFeature.State(name: guessAgeMock.name)
)
])
)
) {
AppFeature()
}
// 1️⃣ ``TestStore``에 ``NavigationStack``의 Child Action을 트리거
// 🧩 ``.path(_:)`` 는 ``StackAction`` 타입 열거형을 요구
// 🧩 각 ``StackAction`` 타입이 요구하는 ``id`` 값은 0부터 Stack 계층 설정 가능
// 2️⃣ Child의 ``State`` 변화도 exhaustive 테스트에서는 테스트 필수
await testStore.send(.path(.element(id: 0, action: .guessMyAge(.guessAgeButtonTapped)))) {
// 3️⃣ Root ``State``의 ``path``에서 Child의 ``State`` 정보를 ``id``와 ``case``로 전달
// 🧩 Child ``State`` 테스트 진행
$0.path[id: 0, case: /AppFeature.Path.State.guessMyAge]?.isGuessAgeButtonTapped = true
$0.path[id: 0, case: /AppFeature.Path.State.guessMyAge]?.age = nil
$0.path[id: 0, case: /AppFeature.Path.State.guessMyAge]?.isGuessAgeIncorrect = false
}
// 4️⃣ Child가 ``.guessMyAge()``의 피드백하는 ``Action``을 먼저 받음
await testStore.receive(.path(.element(id: 0, action: .guessMyAge(.guessAgeResponse(guessAgeMock))))) {
// 5️⃣ Child의 ``State`` 정보를 ``id``와 ``case``로 전달 후, 직접 속성에 접근
// 🧩 Child ``State`` 테스트 진행
$0.path[id: 0, case: /AppFeature.Path.State.guessMyAge]?.isGuessAgeButtonTapped = false
$0.path[id: 0, case: /AppFeature.Path.State.guessMyAge]?.age = 0
}
// 6️⃣ Child의 피드백 처리 이후, Root의 피드백 처리 진행
await testStore.receive(.childHasBeenModified(guessAgeMock.name)) {
// 🧩 Root ``State`` 테스트 진행
// ✅ 모든 Assertion이 통과하면 테스트 성공
$0.recentGuessMyAgeInformation = guessAgeMock.name
}
}
Child의 State를 임의로 생성해주었다. 너무 길어져서 여기까지하고 실 테스트코드에 관한 설명은 다음페이지에서 해야겠다!..
'SWIFTUI' 카테고리의 다른 글
Preference Key (0) | 2024.11.25 |
---|---|
ShareLink - 개발일기 (0) | 2024.11.09 |
TCA-Dependency...DI,DIP를 곁들인 (2) | 2024.09.20 |
TCA-3번째시간 Dependency (0) | 2024.07.19 |
TCA(2)-Store, ViewStore& Binding (4) | 2024.07.16 |