TCA의 테스트코드 적용을 살펴보기 전에 먼저 TestCode에 대해 생각해보고 가려한다. 어디까지 짜야하고 어디까지 커버리지를 올려야하는가..에 관한 얘기가 될수 있을거같다.
먼저 Unit Test와 UITest가 있다.
Unit Test는 내가 원하는 메서드들이 의도한대로 작동을 하는지를 검증하는것이다.
효과적인 UnitTest를 위해 가장 먼저 생각해봐야할 First 한 FIRST가 있다.
- Fast: 테스트가 빠르게 실행되어야한다. 왜? 느린 테스트는 개발자가 또 코드를 수정하고 결과를 확인하기까지 시간이 걸리므로 생산성 저하
- Independent/Isolated: 테스트는 서로의 상태를 공유해서는 안된다. 즉 의존하지 않고 각각의 테스트 서로 독립적으로 실행되어야 신속하게 변경에 반응할 수 있다.
- Repeatable: 테스트를 실행할때마다 서로 동일한 결과를 얻어야 한다. 실행 순서나 실행환경이 달라져도 동일한 결과를 보여줘야한다.
- Self-Validating: 출력은 개발자의 해석에 의존하면 안되며, 객관적으로 "통과" 혹은 "실패"
- Timely: 개발하면서 테스트가 이루어져야함. 즉 코드가 수정되어 다른 부분에 영향을 주는지 확인하기 위해서 수정된 코드를 다른 부분에서 호출하기전 유닛테스트를 실행한다.
위에 FIRST를 갖추면 좋은 테스트 코드라고 한다. 더 나아가 어떻게 짜야하는 지 살펴보자
주로 테스트는 3가지 과정으로 이루어져야한다
1. Given(arrange): 테스트에 필요한 클래스 객체 나열
2. When(action): 테스트를 진행할 어떠한 행동 나열
3. then(assert): Act를 내가 예상한 결과값과 맞는지 비교한다.
커버리지: 테스트 케이스가 얼마나 충족되었는가! 즉 테스트코드에서 기존 클래스나 struct에서 메서드들을 다 사용하여 테스트를 했는가를 나타낸다. 사실 이게 100이라고 좋은 것은 아니다. 물론 매우 낮은것은 검증을 안했다는 뜻이므로 문제가 될 수 있다.
일일이 좋은 테스트 코드는 위에서 3가지 순서가있다. 하지만 그렇담 전체적으로 봤을때 어떤게 좋은 단위 테스트코드를 짯다고 말할 수 있을까?
- 테스트구문을 변경하지 않고도 코드를 리팩할 수 있는가? : 지속가능한 성장, 리팩토링 내성
- 신뢰성 있는 테스트는 리팩토링과 기능 추가 작업을 더 쉽게 만든다. 테스트가 변함없이 잘 작동하는 것을 보면 기존 기능에 대한 회귀가 없음을 확신할 수 있어 개발자들이 부담 없이 리팩토링하고 새 기능을 추가할 수 있어진다.
- 테스트는 코드가 운영 환경에 배포되기 전에 문제를 발견하고 해결하도록 돕는다. 즉, 코드가 예상하지 못한 오류나 기능상의 회귀를 일으킬 경우 미리 알림으로써 문제를 줄일수 있다..
- 만약 리팩햇는데? 어? 테스트가 빨간색으로 바꼈네? -> 거짓양성
- 위의 두가지 이점을 모두 방해한다. 그래서 거짓양성이 적어야한다. 그러면 어떻게 해야하는데?
- SUT의 구현 세부 사항에 덜 의존하도록 분리해야한다. 즉
- 외부 동작에만 집중: 테스트는 내부 구현보다 외부에서 딱 봤을때 드러나는동작 & 결과에만 집중
- 추상화
- 테스트 목적 명확히
- 회귀 방지: SW 버그 방지할 수 있어야한다. 코드 수정했는데? 어? 버그가 있네 그런데 왜 테스트에서는 빨간색으로 안변하고 통과하지? -> 거짓 음성
- 회귀방지 평가 ? => 코드의 커버리지, 복잡도, 도메인 유의성 고려.
뭐 초반에는 리팩을안하기에 거짓 양성 중요성이 떨어지지만 어차피 픍젝하면 리팩을 하게 됨. 그때마다 고통받는겨
와 이렇게 짜면 뭐가좋은데?
1.이렇게 하면 결국 문제가 생길시 독립적인 테스트를 통해 어느부분이 잘못되었는지 정확하게 확인 ㄱㄴ하다.
2.또한 테스트 코드를 믿고 언제든지 리팩하고 기능추가할 수 있다는데 사실 이부분은 아직까지는 체감이 안되어서 추후에 한달 뒤 업데이트 해서 적어보겠다.
3. 코드에 대한 문서 : 해당 테스트코드를 통해 개발자가 어떠한 의도로 작성했는지 알 수 있다.
자 이제 실제로 TCA를 이용하여 테스트 코드를 작성해보자.
먼저 테스트함수의 이름을 좋게 짓는법 1. 무엇을 테스트하는지 타 개발자가 봐도 알수있어야함. 2. 어떤 때 테스트가 통과하거나 실패하는지 명
//
// PhotoGridFeature.swift
// Boleto
//
// Created by Sunho on 8/30/24.
//
import SwiftUI
import PhotosUI
import ComposableArchitecture
struct GridIndex: Equatable {
let row: Int
let col: Int
var linearIndex: Int {
return row * 6 + col
}
init(_ linearIndex: Int) {
self.row = linearIndex / 6 // `row`는 `linearIndex`를 6으로 나눈 몫
self.col = linearIndex % 6 // `col`은 `linearIndex`를 6으로 나눈 나머지
}
}
@Reducer
struct PhotoGridFeature {
struct State: Equatable {
var travelID: Int
var photos: [[PhotoGridItem?]] = [Array(repeating: nil, count: 6)]
var selectedFullScreenItem: PhotoGridItem?
var selectedIndex: GridIndex?
@PresentationState var confirmationDialog: ConfirmationDialogState<Action.ConfirmationDialog>?
}
enum Action: Equatable {
case addPhotoTapped(GridIndex)
case updatePhoto(photoItem: PhotoGridItem)
case deletePhoto
case confirmationDialog(PresentationAction<ConfirmationDialog>)
case clickFullScreenImage(GridIndex)
case dismissFullScreenImage
case clickEditImage(GridIndex)
case successDelete
enum ConfirmationDialog: Equatable {
case fourCutTapped
case polaroidTapped
}
}
@Dependency(\.travelClient) var travelClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .addPhotoTapped(let gridIndex):
state.selectedIndex = gridIndex
state.confirmationDialog = ConfirmationDialogState(titleVisibility: .visible) {
TextState("추가하기")
} actions: {
ButtonState(action: .fourCutTapped){
TextState("네컷사진 추가")
}
ButtonState(action: .polaroidTapped) {
TextState("폴라로이드 사진 추가")
}
ButtonState(role:.cancel){
TextState("닫기")
}
}
return .none
case .updatePhoto( let photoItem):
guard let selectedIndex = state.selectedIndex else {return .none}
while selectedIndex.row >= state.photos.count {
state.photos.append(Array(repeating: nil, count: 6))
}
state.photos[selectedIndex.row][selectedIndex.col] = photoItem
let allSlotsFilled = state.photos.allSatisfy { row in
row.allSatisfy { $0 != nil }
}
if allSlotsFilled {
state.photos.append(Array(repeating: nil, count: 6))
}
return .none
case .deletePhoto:
guard let selectedIndex = state.selectedIndex, let selectedPhoto = state.photos[selectedIndex.row][selectedIndex.col] else { return .none}
return .run { [travelId = state.travelID, isFourCut = selectedPhoto.isFourCut] send in
let result = try await travelClient.deleteSinglePhoto(travelId,selectedIndex.linearIndex,isFourCut)
if result{
await send(.successDelete)
}
}
case .successDelete:
guard let selectedIndex = state.selectedIndex else {return .none}
state.photos[selectedIndex.row][selectedIndex.col] = nil
return .none
case .confirmationDialog:
return .none
case .clickFullScreenImage(let index):
if let photo = state.photos[index.row][index.col] {
state.selectedFullScreenItem = photo
state.selectedIndex = index
}
return .none
case .dismissFullScreenImage:
state.selectedFullScreenItem = nil
state.selectedIndex = nil
return .none
case .clickEditImage(let index):
state.selectedIndex = index
return .none
}
}
.ifLet(\.$confirmationDialog, action: \.confirmationDialog)
}
}
이 코드는 한 프레임에 최대 6개의 사진을 담을 수 있는 뷰를 구현하는 데 목적이 있습니다. 프레임이 6장의 사진으로 가득 차면 새로운 6개의 빈 슬롯을 추가하여 다음 사진들을 채울 수 있도록 합니다. 사진 슬롯 중 nil이 아닌 photoItem을 클릭할 때, 앱이 editMode에 있는 경우 해당 사진이 삭제되며, 그렇지 않은 경우 전체 화면 이미지가 표시됩니다.
addPhotoTapped 메서드는 빈 슬롯을 클릭했을 때 두 가지 confirmationDialogState를 표시합니다. 이는 '네컷 사진 추가'와 '폴라로이드 사진 추가' 옵션이며, 선택 시 관련 액션이 상위 리듀서로 방출됩니다. 이 기능은 상위 리듀서에서 관리되므로 해당 기능의 테스트는 이 부분에서 생략합니다.
updatePhoto 메서드는 선택된 위치에 사진을 추가하는 기능입니다. 여기서 단일 사진이든 네컷 사진이든 상관없이 하나의 사진 아이템을 state의 photos 배열에 추가할 수 있습니다. 만약 6개의 슬롯이 이미 가득 찼다면 새로운 6개의 nil 슬롯을 추가로 삽입하는 것이 목표입니다. 메서드는 다음과 같은 구조로 동작합니다.
- while selectedIndex.row >= state.photos.count: 선택된 인덱스의 row가 photos 배열의 현재 길이보다 클 경우, 추가 공간을 확보하기 위해 배열 뒤에 nil이 6개 포함된 배열을 추가합니다.
- 이후에는 지정된 row와 col 위치에 photoItem을 할당하여 사진을 추가합니다.
자 이제 테스트코드를 짜보자. 테스트 코드는 항상 성공이든 실패든 케이스를 다뤄야 커버리지가 올라간다.
1. 인덱스를 큰거를 넣었을때 추가 6개배열이 생성되나 확인을 해보자.
func testUpdatePhotoWithNewRowCreationSuccess() async {
var state = PhotoGridFeature.State(travelID:2)
state.selectedIndex = GridIndex(8)
let testStore = await TestStore(initialState: state) {
PhotoGridFeature()
} withDependencies: {
$0.travelClient = .testValue
}
await testStore.send(.updatePhoto(photoItem: .singlePhoto(.mock))) {
$0.photos.append(Array(repeating: nil, count: 6))
$0.photos[1][2] = .singlePhoto(.mock)
}
}
임의로 6이상의 수인 8을 넣어주었다. 당연히 첫번째 photos의 row는 [nil * 6]개로 이루어져있을거다. 그렇다면 8에 값을 삽입한다면 원하는대로 photos[1][2]에 값이 업뎃되었는지 체크하면 된다. 여기서 Given부분은 위에서 state를 다루는 곳이고 when은 send를 통해 리듀서의 action을 하는 부분이다. 마지막으로 then! 그래서 어캐되는데 클로저에서 비교를 하면된다.
자 이제 저건 테스트에 성공했다. 다음은 5개를 채운상태에서 한개를 더 채웠을때 다음 nil 6개가 추가 되는지를 테스트해보면 된다.
func testUpdatePhotoWhenFullSizeSuccess() async {
var state = PhotoGridFeature.State(travelID: 1)
state.selectedIndex = GridIndex(5)
state.photos = [[.singlePhoto(.mock),.singlePhoto(.mock),.singlePhoto(.mock),.singlePhoto(.mock),.singlePhoto(.mock),nil]]
let testStore = await TestStore(initialState: state) {
PhotoGridFeature()
} withDependencies: {
$0.travelClient = .testValue
}
await testStore.send(.updatePhoto(photoItem: .singlePhoto(.mock))) {
$0.photos[state.selectedIndex!.row][state.selectedIndex!.col] = .singlePhoto(.mock)
$0.photos = [[.singlePhoto(.mock),.singlePhoto(.mock),.singlePhoto(.mock),.singlePhoto(.mock),.singlePhoto(.mock),.singlePhoto(.mock)], [nil,nil,nil,nil,nil,nil]]
}
}
이번엔 메소드 이름을 testUpdatePhotoWhenFullSizeSuccess로 짓고 기본 상태에 5개를 채우고 한칸을 남겨두었다. 그 후 원하는 when-> send에 빈칸에 추가하고 전체 photos를 then을 통해 비교했다. 원하는 것은 [ 값 *6]개 가득 찼으므로 [nil * 6 개가 나오는 것이다]
왼쪽에 보면 초록마름모로 체크표시가 된것을 볼 수 있다. 이 뜻은 테스트에 성공했음을 의미한다. 다시 저 photoGridFeature로 돌아가보자.
guard문에는 여전히 빨간표시가 있지만 나머지에는 없는 것을 볼 수 있다. 이는 저부분을 제외하고는 테스트에 성공했음을 의미한다. 물론 커버리지를 높이기 위해서는 guard문까지 테스트하는 것이 좋지만 그러면 테스트코드의 양이 너무 많아질 것이라 생각을 하여 필요한 것 위주로 테스트 코드를 진행하여 지속가능한 코드를 만드는 것이 목적이다.
실제로 커버리지를 보면 가드문을 안했기 때문에 100은 아니지만 99.4를 달성했다는 것을 볼 수 있따.
'Swift' 카테고리의 다른 글
SilentPush&RichPush (2) | 2024.11.08 |
---|---|
CLMonitor (1) | 2024.10.22 |
Swift Performance-wwdc24 (0) | 2024.06.17 |
힙메모리 분석 - WWDC24 (1) | 2024.06.15 |
매크로(Macros) (1) | 2024.03.07 |