참고하면 좋은 자료
https://newwave.tistory.com/216
[iOS - Swift] TDD를 위한 Unit Test 코드 작성하기 (Feat. XCTest)
안녕하세요, 이번에는 Unit Test 코드를 작성하는 방법에 대해 알아보겠습니다. 이전부터 워낙 테스트 코드에 대해 많이 들었어서 중요성은 알고 있었지만, 얼핏 들은 세션에서 너무 어렵게 느껴
newwave.tistory.com
https://explorer89.tistory.com/511
🧪 Xcode에서 XCTestCase로 단위 테스트 작성하기— DiaryStore 테스트를 예시로 배우는 실전 가이드
🍋 들어가며iOS 앱을 만들다 보면“내가 만든 Core Data, ViewModel 코드가 진짜 제대로 동작하는지”눈으로만 확인하기 어렵죠. 이때 바로 사용하는 게 XCTest입니다.Xcode에 기본 내장된 공식 단위 테
explorer89.tistory.com
▶️ 테스트 배경
프로필 온보딩 과정에서 ProfileSetupViewModel은
사용자가 입력한 username에 따라 다음 상태를 만들어야 한다.
- username 형식이 올바른지
- 이미 사용 중인 이름인지
- 그 결과에 따라
- 에러 메시지를 보여줄지
- 확인 버튼을 활성화할지
이 글에서는 Firebase가 실제로 잘 동작하는지를 검증하지 않는다.
대신,
ViewModel이 설계 의도대로 “상태를 만들고 있는지”
를 Unit Test로 검증한다.
▶️ 테스트 대상과 목표
테스트 대상
- ProfileSetupViewModel
테스트 목표
- username 입력이 변경되면
- ViewModel이 내부 로직(형식 검사, 중복 검사)을 수행하고
- UI에 연결된 출력 상태가 올바르게 변경되는지 검증한다.
검증 대상 출력은 다음 두 가지다.
- usernameErrorMessage
- isConfirmEnabled
▶️ 어디서 테스트하나? (Unit Test vs UI Test)
이 테스트는 PulseBoardTests에서 수행한다.
Unit Test의 특징
- UI 없이 로직만 빠르게 검증
- 실패 원인이 명확
- 실행 속도가 빠르고 반복 실행이 쉬움
XCTest는 이를 위해 XCTestCase를 제공하며,
테스트 메서드는 async / async throws로도 작성할 수 있다.
UI Test는 왜 쓰지 않는가?
PulseBoardUITests는 다음 용도에 적합하다.
- 실제 앱 실행
- 버튼 탭
- 키보드 입력
- 화면 전환 검증
하지만 이번 테스트 목적은
“ViewModel 상태 변화 검증”이므로 UI Test와는 범위가 다르다.
▶️ 테스트 파일 구조
PulseBoardTests
├─ ProfileSetupViewModelTests.swift ✅
└─ PulseBoardTests.swift (기본 템플릿)

실제 테스트는 ViewModel 단위로 파일을 나누어 작성한다.
- PulseBoardTests.swift
- 그냥 놔두거나 삭제해도 됨
- 실제 테스트는 ViewModel 단위로 파일을 나눠서 작성
1️⃣ PulseBoardTests 우클릭
2️⃣ New File → iOS → Unit Test Case Class
3️⃣ 이름: ProfileSetupViewModelTests
4️⃣ Target: PulseBoardTests ✅
▶️ 테스트 코드 구조: Given / When / Then
모든 테스트는 다음 구조를 따른다.
- Given: 테스트 준비 (ViewModel + Mock)
- When: 입력/행동 (username 변경)
- Then: 결과 검증 (XCTAssert)
이 구조를 따르면:
- 테스트 의도가 한눈에 보이고
- 실패 시 원인을 즉시 파악할 수 있다.
▶️ Mock이 필요한 이유
ViewModel 테스트에서 다음 요소가 섞이면 안 된다.
- Firestore 네트워크
- Firebase Auth
- Storage 업로드
이 순간부터 테스트는 Unit Test가 아니라 Integration Test가 된다.
그래서 Repository와 Uploader는 Mock으로 대체한다.
final class FirestoreUserRepository: UserRepository { }
protocol UserRepository { }
이미 프로토콜로 분리되어 있기 때문에
Mock 교체가 자연스럽게 가능하다.
▶️ ViewModel 로직 흐름과 테스트 포인트
1️⃣ 형식 유효성 검사 (즉시)
$username
.removeDuplicates()
.map(validateUsernameFormat)
- 빈 값
- 너무 짧음
- 허용되지 않은 문자
→ 입력 즉시 결과가 정해진다.
removeDuplicates를 사용하는 이유
연속으로 같은 값이 들어오는 경우
불필요한 검증/요청을 줄이기 위함이다.
2️⃣ 중복 검사 (입력 멈춤 후)
$username
.removeDuplicates()
.debounce(for: .milliseconds(400), scheduler: RunLoop.main)
- 타이핑 중에는 서버를 호출하지 않고
- 입력이 멈춘 뒤에만 중복 검사 수행
이는 UX와 비용 최적화를 위한 전형적인 패턴이다.
▶️ 테스트에서 debounce를 기다리는 이유
중복 검사는 debounce 이후에 실행된다.
따라서 테스트에서도 다음을 수행한다.
try? await Task.sleep(nanoseconds: 500_000_000)
이 sleep은 로직을 위한 것이 아니라,
시간 기반 연산자가 방출할 시간을 기다리는 장치다.
▶️ 전체코드
import XCTest
@testable import PulseBoard
import UIKit
// MARK: - ProfileSetupViewModelTests
/// `ProfileSetupViewModel`의 username 유효성/중복 검사 흐름을 검증하는 Unit Test 입니다.
///
/// 테스트 목적:
/// - username 입력 변화에 따라 ViewModel의 출력 상태가 올바르게 변경되는지 확인
/// - `usernameErrorMessage`
/// - `isConfirmEnabled`
///
/// 범위(단위 테스트 원칙):
/// - 네트워크/Firestore/Storage 같은 외부 의존성은 실제로 호출하지 않음
/// - `MockUserRepository`, `MockProfileImageUploader`로 대체하여
/// ViewModel 로직만 결정적으로(deterministic) 검증
///
/// - Note:
/// ViewModel이 `@MainActor`로 동작하므로 테스트 클래스도 `@MainActor`로 실행해
/// 스레드/Actor 경합을 방지합니다.
@MainActor
final class ProfileSetupViewModelTests: XCTestCase {
// MARK: - SUT (System Under Test)
/// 테스트 대상 (ViewModel)
private var viewModel: ProfileSetupViewModel!
// MARK: - Life Cycle
/// 각 테스트 메서드 실행 전에 호출됩니다.
///
/// - Note:
/// 테스트는 서로 영향을 주면 안 되므로, 매 테스트마다 새로운 ViewModel 인스턴스를 생성합니다.
override func setUp() {
super.setUp()
viewModel = ProfileSetupViewModel(
userRepository: MockUserRepository(),
imageUploader: MockProfileImageUploader()
)
}
/// 각 테스트 메서드 실행 후에 호출됩니다.
///
/// - Note:
/// 다음 테스트에 영향을 주지 않도록 테스트 대상 객체를 해제합니다.
override func tearDown() {
viewModel = nil
super.tearDown()
}
// MARK: - Username Validation Tests
/// username이 빈 문자열일 때 확인 버튼이 비활성화되고,
/// 에러 메시지가 `.empty`에 해당하는 메시지로 설정되는지 검증합니다.
func test_usernameEmpty_shouldDisableConfirmButton() async {
// When: 사용자가 username을 비움
viewModel.username = ""
// Then: 형식 유효성 검사 실패 → 버튼 비활성화 + 에러 메시지 설정
XCTAssertFalse(viewModel.isConfirmEnabled)
XCTAssertEqual(
viewModel.usernameErrorMessage,
UsernameValidationError.empty.localizedDescription
)
}
/// username이 최소 길이(예: 2자)보다 짧을 때 확인 버튼이 비활성화되고,
/// `.tooShort` 메시지가 노출되는지 검증합니다.
func test_usernameTooShort_shouldDisableConfirmButton() async {
// when
viewModel.username = "a"
// then
XCTAssertFalse(viewModel.isConfirmEnabled)
XCTAssertEqual(
viewModel.usernameErrorMessage,
UsernameValidationError.tooShort.localizedDescription
)
}
/// username에 허용되지 않는 문자가 포함되면 확인 버튼이 비활성화되고,
/// `.invalidCharacters` 메시지가 노출되는지 검증합니다.
func test_usernameInvalidCharacters_shouldDisableConfirmButton() async {
// when
viewModel.username = "ab!!"
// then
XCTAssertFalse(viewModel.isConfirmEnabled)
XCTAssertEqual(
viewModel.usernameErrorMessage,
UsernameValidationError.invalidCharacters.localizedDescription
)
}
// MARK: - Username Duplication Tests
/// username이 이미 사용 중인 값이라고 판단될 경우(예: "admin"),
/// debounce 이후 중복 검사 결과가 반영되어 확인 버튼이 비활성화되는지 검증합니다.
///
/// - Important:
/// 중복 검사는 debounce로 지연되어 수행되므로, 테스트에서도 debounce 시간을 기다려야 합니다.
func test_usernameDuplicated_shouldDisableConfirmButton() async {
// When: 중복이라고 가정한 username 입력
viewModel.username = "admin"
// debounce 대기 (실제 코드의 debounce 시간보다 약간 여유 있게)
try? await Task.sleep(nanoseconds: 500_000_000)
// Then: 중복 → 버튼 비활성 + 중복 메시지
XCTAssertFalse(viewModel.isConfirmEnabled)
XCTAssertEqual(
viewModel.usernameErrorMessage,
UsernameValidationError.duplicated.localizedDescription
)
}
/// username이 사용 가능한 값일 경우(예: "newuser"),
/// debounce 이후 중복 검사 결과가 반영되어 확인 버튼이 활성화되는지 검증합니다.
func test_usernameAvailable_shouldEnableConfirmButton() async {
// when
viewModel.username = "newuser"
// debounce 대기
try? await Task.sleep(nanoseconds: 500_000_000)
// then
XCTAssertTrue(viewModel.isConfirmEnabled)
XCTAssertNil(viewModel.usernameErrorMessage)
}
}
// MARK: - MockUserRepository
/// `UserRepository`의 테스트용 Mock 구현체입니다.
///
/// - Note:
/// 중복 검사 로직을 결정적으로 만들기 위해,
/// 특정 username("admin")에 대해서만 중복(false)으로 응답하도록 구성합니다.
/// 그 외에는 사용 가능(true)으로 응답합니다.
final class MockUserRepository: UserRepository {
func isUsernameAvailable(_ username: String) async throws -> Bool {
// "admin"은 이미 존재하는 유저 이름이라고 가정
return username != "admin"
}
// 아래 메서드들은 이번 테스트에서 사용하지 않지만,
// 프로토콜 요구사항을 만족시키기 위해 최소 구현만 제공합니다.
func fetchCurrentUser() async throws -> PulseUser {
fatalError("fetchCurrentUser is not needed for this test")
}
func createUser(_ user: PulseUser) async throws { }
func updateUser(_ user: PulseUser) async throws { }
func deleteUser(uid: String) async throws { }
func clearCacheOnLogout() { }
func clearCacheOnWithdrawal() { }
}
// MARK: - MockProfileImageUploader
/// `ProfileImageUploading`의 테스트용 Mock 구현체입니다.
///
/// - Note:
/// 실제 Storage 업로드를 수행하지 않고, 업로드 결과로 가짜 path를 반환합니다.
final class MockProfileImageUploader: ProfileImageUploading {
func uploadProfileImage(_ image: UIImage, uid: String) async throws -> String {
return "profile_images/\(uid).jpg"
}
func deleteProfileImage(imagePath: String) async throws { }
}
▶️ 테스트 코드를 어떻게 설계 / 작성 해야 하나?
0️⃣ 전제: 테스트는 “검증 코드”가 아니라 “설계 증명서”다
테스트 코드를 작성할 때 가장 먼저 가져야 할 관점은 이것이다.
테스트는
“이미 있는 코드를 확인하는 코드”가 아니라
“내가 이런 구조로 설계했다는 사실을 증명하는 코드”다.
그래서 테스트는 항상 다음 질문에서 시작한다.
1️⃣ 테스트 파일을 만들자마자 하는 첫 질문 (코드 ❌, 사고 ✅)
테스트 파일을 생성한 직후,
아직 코드 한 줄도 쓰지 않은 상태에서 가장 먼저 던지는 질문은 다음이다.
❓ 내가 지금 검증하려는 대상(SUT)은 정확히 무엇인가?
이번 테스트의 답은 명확하다.
- ProfileSetupViewModel
이 질문에 대한 답이 정해지는 순간
테스트 클래스 이름도 자연스럽게 결정된다.
final class ProfileSetupViewModelTests: XCTestCase
이 시점에는 아직 setUp()도, 테스트 함수도 작성하지 않는다.
2️⃣ 다음 질문: “이 ViewModel에서 무엇이 변하길 기대하는가?”
이제 ViewModel 코드를 다시 본다.
@Published private(set) var usernameErrorMessage: String?
@Published private(set) var isConfirmEnabled: Bool
이 순간 명확해져야 한다.
테스트가 검증해야 할 것은
ViewModel 내부 로직이 아니라 ‘출력 상태’다.
즉, 테스트는 다음 두 가지를 확인해야 한다.
- usernameErrorMessage
- isConfirmEnabled
머릿속에 다음 구조가 그려져야 한다.
입력(username)
↓
ViewModel 내부 로직
↓
출력 상태(UI 바인딩 대상)
3️⃣ 그다음 질문: “테스트에서 조작할 수 있는 입력은 무엇인가?”
ViewModel을 다시 보면 테스트에서 직접 조작할 수 있는 입력은 딱 하나다.
@Published var username: String
즉, 모든 테스트는 다음 한 줄에서 출발한다.
viewModel.username = "..."
이 단계까지도 여전히 setUp()은 필요 없다.
4️⃣ 이제서야 setUp이 등장한다 (환경이 필요해진 순간)
지금까지 정리된 상태는 다음과 같다.
- 테스트 대상(SUT): ProfileSetupViewModel
- 입력(Input): username
- 출력(Output): isConfirmEnabled, usernameErrorMessage
이제 자연스럽게 다음 사실이 드러난다.
❗️각 테스트는
완전히 동일한 초기 상태에서 시작해야 한다.
그래서 이 시점에서 처음으로 setUp()이 필요해진다.
private var viewModel: ProfileSetupViewModel!
override func setUp() {
super.setUp()
viewModel = ProfileSetupViewModel(
userRepository: MockUserRepository(),
imageUploader: MockProfileImageUploader()
)
}
중요한 점은:
- setUp()은 “테스트니까 무조건 쓰는 것”이 아니다
- 매 테스트마다 동일한 초기 환경이 필요할 때만 등장한다
5️⃣ 외부 의존성을 발견한다 → Mock 설계
ViewModel 생성자를 보면 바로 판단할 수 있다.
init(
userRepository: UserRepository,
imageUploader: ProfileImageUploading
)
여기서 즉시 결론을 내린다.
❌ 실제 Firebase 구현체를 쓰면 이건 단위 테스트가 아니다
⭕ 테스트에서는 고정된 결과를 주는 Mock이 필요하다
그래서 테스트 파일 하단에 다음과 같은 Mock을 만든다.
final class MockUserRepository: UserRepository {
func isUsernameAvailable(_ username: String) async throws -> Bool {
return username != "admin"
}
}
이때 기준은 단 하나다.
“이 테스트에서 필요한 최소한만 구현한다.”
나머지 메서드는 프로토콜 충족을 위한 최소 구현만 제공한다.
6️⃣ 테스트 함수는 이렇게 만든다 (항상 같은 순서)
테스트 함수는 항상 같은 사고 순서로 작성한다.
6️⃣ - 1️⃣ 테스트 이름부터 작성한다 (코드보다 먼저)
func test_usernameEmpty_shouldDisableConfirmButton() async
6️⃣ - 2️⃣ Then(기대 결과)부터 고정한다
실제 코드를 작성할 때 나는 종종
Then부터 먼저 작성한다.
XCTAssertFalse(viewModel.isConfirmEnabled)
XCTAssertEqual(
viewModel.usernameErrorMessage,
UsernameValidationError.empty.localizedDescription
)
이렇게 하면 자연스럽게 이런 질문이 생긴다.
“이 상태를 만들려면 어떤 입력이 필요하지?”
6️⃣ - 3️⃣ 그다음 When(행동)을 채운다
viewModel.username = ""
끝이다.
이 테스트에는:
- async
- debounce
- sleep
이 전부 필요 없다.
형식 검증은 입력 즉시 결과가 정해지기 때문이다.
7️⃣ 중복 검사 테스트는 항상 마지막에 작성한다
중복 검사 테스트는 성격이 다르다.
- 비동기
- debounce
- 시간 개념 포함
그래서 항상 다음 원칙을 지킨다.
동기 테스트 → 비동기 테스트 순서
viewModel.username = "admin"
try? await Task.sleep(nanoseconds: 500_000_000)
이 테스트는 로직이 아니라
시간 기반 연산자의 방출 시점을 기다리는 테스트다.
8️⃣ 전체 설계 흐름 요약
테스트 코드를 설계할 때의 사고 흐름은 다음 한 줄로 요약된다.
❌ setUp부터 쓰지 않는다
⭕
- 무엇을 검증할지 정한다
- Input / Output을 명확히 한다
- 외부 의존성을 제거한다
- 가장 단순한 테스트부터 작성한다
- 마지막에 async / 시간 기반 로직을 검증한다
이 흐름이 몸에 익으면
테스트 코드는 “추가 작업”이 아니라
설계 과정의 일부가 된다.
▶️ 테스트 결과 해석
Executed 5 tests, with 0 failures (0 unexpected) in 1.044 seconds
이는 다음을 의미한다.
- 모든 테스트가 실행되었고
- 모든 규칙이 만족되었으며
- 예외 없이 정상 종료되었다.
테스트 실행 시간도 의미가 있다.
- 형식 검사 테스트: 즉시 완료
- 중복 검사 테스트: debounce만큼 소요
→ ViewModel 로직이 단계적으로 분리되어 있음을 보여준다.
▶️ 테스트를 문서처럼 읽히게 만드는 규칙
1️⃣ 테스트는 “출력 코드”가 아니라 “행동 명세서(Spec)”다
테스트는 값을 보여주는 코드가 아니라 규칙을 선언하는 코드여야 한다.
2️⃣ 테스트 이름이 이미 문서여야 한다
func test_usernameEmpty_shouldDisableConfirmButton()
✅ 문서형 테스트 이름 공식
test_[조건]_[상황]_should_[기대 결과]
3️⃣ Given / When / Then을 “보이게” 만든다
Before (기능은 같지만 문서성 ↓)
viewModel.username = "admin"
try? await Task.sleep(...)
XCTAssertFalse(viewModel.isConfirmEnabled)
After (문서처럼 읽힘)
// When: 이미 사용 중인 username을 입력했을 때
viewModel.username = "admin"
// debounce 이후 서버 중복 검사가 수행되면
try? await Task.sleep(nanoseconds: 500_000_000)
// Then: 확인 버튼은 비활성화되어야 한다
XCTAssertFalse(viewModel.isConfirmEnabled)
📌 주석은 설명이 아니라 ‘서술’이어야 한다.
4️⃣ Assert는 “값 검증”이 아니라 “의도 표현”이다
❌ 값 중심 Assert
XCTAssertEqual(viewModel.isConfirmEnabled, false)
✅ 의도 중심 Assert
XCTAssertFalse(viewModel.isConfirmEnabled)
→ 이건 “비활성화되어야 한다”는 규칙 선언
그래서 XCTest에는 일부러 이런 함수들이 있다:
- XCTAssertTrue
- XCTAssertFalse
- XCTAssertNil
- XCTAssertNotNil
📌 의미를 드러내는 Assert를 쓰는 게 문서화의 핵심
5️⃣ 테스트 하나 = 규칙 하나
❌ 나쁜 예
func test_usernameInvalidAndDuplicated() {
XCTAssertFalse(...)
XCTAssertEqual(...)
XCTAssertFalse(...)
}
→ 이건 규칙이 섞여서 읽히지도 않고, 실패 원인도 불명확
⭕ 좋은 예 (지금 네 구조)
test_usernameInvalidCharacters_shouldDisableConfirmButton
test_usernameDuplicated_shouldDisableConfirmButton
📌 테스트 파일을 위에서 아래로 읽으면,
“이 ViewModel의 규칙 목록”이 된다.
문서처럼 읽히게 리팩터링”의 실제 체크리스트
테스트 하나를 쓰고 나서, 항상 이 질문을 던져봐:
- 테스트 이름만 읽어도 규칙이 보이나?
- 실패하면 어떤 규칙이 깨졌는지 바로 알 수 있나?
- assert가 “값 비교”가 아니라 “의도 표현”인가?
- 이 테스트를 주석 없이 읽어도 이해되나?
- 테스트를 위에서 아래로 읽으면 요구사항 목록처럼 보이나?
이 기준을 만족하면 테스트는 검증 코드이자 살아 있는 문서가 된다.