
안녕하세요. 어제에 이어서 Swift Testing에 대한 내용입니다.
WWDC 2024 세션 "Go further with Swift Testing"을 정리한 내용입니다.
이전 글에서 Swift Testing의 기본 개념과 #expect 매크로에 대해 다뤘습니다. 이번 글에서는 더 고급 기능들을 살펴보겠습니다.
이전 글에서 다룬 기본 테스트 코드입니다:
import Testing
@Test func brewTeaSuccessfully() throws {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
let cupOfTea = try teaLeaves.brew(forMinutes: 3)
#expect(cupOfTea.quality == .perfect)
}
에러가 제대로 발생하는지 테스트하려면 전통적으로 다음과 같이 작성했습니다:
import Testing
@Test func brewTeaError() throws {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 3)
do {
try teaLeaves.brew(forMinutes: 100)
} catch is BrewingError {
// This is the code path we are expecting
} catch {
Issue.record("Unexpected Error")
}
}
이 방식의 문제점:
Swift Testing은 에러 테스트를 위한 전용 매크로를 제공합니다:
import Testing
@Test func brewTeaError() throws {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
#expect(throws: (any Error).self) {
try teaLeaves.brew(forMinutes: 200)
}
}
가장 기본적인 형태로, 어떤 종류든 에러가 발생하는지 검증합니다.
import Testing
@Test func brewTeaError() throws {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
#expect(throws: BrewingError.self) {
try teaLeaves.brew(forMinutes: 200)
}
}
특정 타입의 에러(BrewingError)가 발생하는지 검증합니다. 다른 타입의 에러가 발생하면 테스트 실패합니다.
import Testing
@Test func brewTeaError() throws {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
#expect(throws: BrewingError.oversteeped) {
try teaLeaves.brew(forMinutes: 200)
}
}
에러 타입뿐 아니라 특정 에러 케이스까지 검증합니다. BrewingError.oversteeped 케이스가 아니면 테스트가 실패합니다.
import Testing
@Test func brewTea() {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
#expect {
try teaLeaves.brew(forMinutes: 3)
} throws: { error in
guard let error = error as? BrewingError,
case let .needsMoreTime(optimalBrewTime) = error else {
return false
}
return optimalBrewTime == 4
}
}
클로저를 사용하여 에러의 Associated Value까지 검증할 수 있습니다. 위 예제는:
BrewingError 타입인지 확인.needsMoreTime 케이스인지 확인optimalBrewTime이 4인지 확인커스텀 검증으로 다음을 확인할 수 있습니다:
옵셔널 값을 다룰 때 전통적인 guard 문 대신 #require 매크로를 사용할 수 있습니다.
import Testing
@Test func brewTea() throws {
let teaLeaves = TeaLeaves(name: "Sencha", optimalBrewTime: 2)
let brewedTea = try teaLeaves.brew(forMinutes: 100)
guard let color = brewedTea.color else {
Issue.record("Tea color was not available!")
return
}
#expect(color == .green)
}
문제점:
return을 잊으면 크래시 발생 가능import Testing
@Test func brewTea() throws {
let teaLeaves = TeaLeaves(name: "Sencha", optimalBrewTime: 2)
let brewedTea = try teaLeaves.brew(forMinutes: 100)
let color = try #require(brewedTea.color)
#expect(color == .green)
}
장점:
throws를 사용하여 실패를 명시적으로 처리#require는 테스트 전제 조건을 검증하는데 적합합니다. nil이 반환되면 해당 테스트를 계속 진행하는 것이 무의미하므로 즉시 중단합니다.
개발 중에는 알려진 버그나 이슈 때문에 특정 테스트가 실패할 수 있습니다. 이를 어떻게 처리할까요?
import Testing
@Test(.disabled) func softServeIceCreamInCone() throws {
try softServeMachine.makeSoftServe(in: .cone)
}
문제점:
import Testing
@Test func softServeIceCreamInCone() throws {
withKnownIssue {
try softServeMachine.makeSoftServe(in: .cone)
}
}
장점:
테스트 전체가 아닌 특정 부분만 알려진 이슈로 표시할 수 있습니다:
import Testing
@Test func softServeIceCreamInCone() throws {
// 이 부분은 정상적으로 검증
let iceCreamBatter = IceCreamBatter(flavor: .chocolate)
try #require(iceCreamBatter != nil)
#expect(iceCreamBatter.flavor == .chocolate)
// 이 부분만 알려진 이슈로 처리
withKnownIssue {
try softServeMachine.makeSoftServe(in: .cone)
}
}
이렇게 하면 테스트 가능한 부분은 검증하고, 문제가 있는 부분만 임시로 무시할 수 있습니다.
이전 글에서는 enum이나 String 배열을 인자로 받는 간단한 매개변수화 테스트를 다뤘습니다. 하지만 실제 테스트에서는 더 복잡한 구조체를 사용해야 할 때가 많습니다.
import Testing
struct SoftServe {
let flavor: Flavor
let container: Container
let toppings: [Topping]
}
@Test(arguments: [
SoftServe(flavor: .vanilla, container: .cone, toppings: [.sprinkles]),
SoftServe(flavor: .chocolate, container: .cone, toppings: [.sprinkles]),
SoftServe(flavor: .pineapple, container: .cup, toppings: [.whippedCream])
])
func softServeFlavors(_ softServe: SoftServe) { /*...*/ }
이렇게 작성하면 테스트 리포트에서 다음과 같이 표시됩니다:
softServeFlavors(SoftServe(flavor: vanilla, container: cone, toppings: [sprinkles]))
softServeFlavors(SoftServe(flavor: chocolate, container: cone, toppings: [sprinkles]))
...
가독성이 매우 떨어집니다!
import Testing
struct SoftServe: CustomTestStringConvertible {
let flavor: Flavor
let container: Container
let toppings: [Topping]
var testDescription: String {
"\(flavor) in a \(container)"
}
}
@Test(arguments: [
SoftServe(flavor: .vanilla, container: .cone, toppings: [.sprinkles]),
SoftServe(flavor: .chocolate, container: .cone, toppings: [.sprinkles]),
SoftServe(flavor: .pineapple, container: .cup, toppings: [.whippedCream])
])
func softServeFlavors(_ softServe: SoftServe) { /*...*/ }
이제 테스트 리포트가 다음과 같이 표시됩니다:
softServeFlavors(vanilla in a cone)
softServeFlavors(chocolate in a cone)
softServeFlavors(pineapple in a cup)
훨씬 읽기 쉽고 의미 있는 출력!
CustomTestStringConvertible를 채택하면:
코드의 품질 유지에서 가장 어려운 부분 중 하나는 모든 엣지 케이스를 다루는 것입니다. Swift Testing의 매개변수화된 테스트는 이 문제를 효과적으로 해결합니다.
두 개의 컬렉션을 전달하면 모든 조합을 자동으로 테스트합니다:
import Testing
enum Ingredient: CaseIterable {
case rice, potato, lettuce, egg
}
enum Dish: CaseIterable {
case onigiri, fries, salad, omelette
}
@Test(arguments: Ingredient.allCases, Dish.allCases)
func cook(_ ingredient: Ingredient, into dish: Dish) async throws {
#expect(ingredient.isFresh)
let result = try cook(ingredient)
try #require(result.isDelicious)
try #require(result == dish)
}
동작 방식:
결과: 4개 × 4개 = 16개의 테스트 케이스 자동 생성!
cook(rice, into: onigiri)
cook(rice, into: fries)
cook(rice, into: salad)
cook(rice, into: omelette)
cook(potato, into: onigiri)
...
조합을 제한하고 싶다면 zip을 사용하여 1:1 매칭을 만들 수 있습니다:
import Testing
@Test(arguments: zip(Ingredient.allCases, Dish.allCases))
func cook(_ ingredient: Ingredient, into dish: Dish) async throws {
#expect(ingredient.isFresh)
let result = try cook(ingredient)
try #require(result.isDelicious)
try #require(result == dish)
}
동작 방식:
결과: 4개의 테스트 케이스만 생성
cook(rice, into: onigiri)
cook(potato, into: fries)
cook(lettuce, into: salad)
cook(egg, into: omelette)
프로젝트가 커지면서 테스트 수가 수백, 수천 개로 증가할 때 체계적인 관리가 필수적입니다. Swift Testing은 두 가지 강력한 조직화 도구를 제공합니다.
Suite는 테스트 함수를 그룹화하는 타입입니다. 표시 이름과 특성으로 테스트를 문서화할 수 있습니다.
@Suite("Various desserts")
struct DessertTests {
@Test func applePieCrustLayers() { /* ... */ }
@Test func lavaCakeBakingTime() { /* ... */ }
@Test func eggWaffleFlavors() { /* ... */ }
@Test func cheesecakeBakingStrategy() { /* ... */ }
@Test func mangoSagoToppings() { /* ... */ }
@Test func bananaSplitMinimumScoop() { /* ... */ }
}
모든 테스트가 한 곳에 모여있어 구조가 불명확합니다. 따뜻한 디저트와 차가운 디저트가 섞여 있습니다.
import Testing
@Suite("Various desserts")
struct DessertTests {
@Suite struct WarmDesserts {
@Test func applePieCrustLayers() { /* ... */ }
@Test func lavaCakeBakingTime() { /* ... */ }
@Test func eggWaffleFlavors() { /* ... */ }
}
@Suite struct ColdDesserts {
@Test func cheesecakeBakingStrategy() { /* ... */ }
@Test func mangoSagoToppings() { /* ... */ }
@Test func bananaSplitMinimumScoop() { /* ... */ }
}
}
장점:
Swift Testing의 핵심 기능 중 하나는 Suite 안에 Suite를 중첩할 수 있다는 것입니다. 이를 통해 복잡한 테스트 구조를 유연하게 만들 수 있습니다.
Suite는 소스 코드 레벨에서 테스트를 조직화하지만, 태그는 여러 파일과 모듈을 가로지르는 연결을 만듭니다.
복잡한 프로젝트에서:
실전 예시:
| 비교 항목 | Suite | Tags |
|---|---|---|
| 구조 | 계층적, 트리 구조 | 평면적, 다대다 관계 |
| 범위 | 단일 파일/모듈 내 | 여러 파일/모듈 가로지름 |
| 용도 | 테스트 그룹의 논리적 구조화 | 실행 전략, 필터링, 분류 |
| 관계 | 부모-자식 관계 | 태그별 연관 관계 |
예시:
LoginTests > EmailLoginTests, SocialLoginTests (계층 구조)#critical, #ui, #network (여러 Suite를 가로지르는 특성)다음은 서로 다른 Suite에 있지만 공통 특성을 가진 테스트들입니다:
@Suite struct DrinkTests {
@Test func espressoExtractionTime() { /* ... */ } // 카페인 ✓
@Test func greenTeaBrewTime() { /* ... */ } // 카페인 ✓
@Test func mochaIngredientProportion() { /* ... */ } // 카페인 ✓, 초콜릿 ✓
}
@Suite struct DessertTests {
@Test func espressoBrownieTexture() { /* ... */ } // 카페인 ✓, 초콜릿 ✓
@Test func bungeoppangFilling() { /* ... */ }
@Test func fruitMochiFlavors() { /* ... */ }
}
카페인이나 초콜릿이 들어간 제품만 테스트하고 싶다면? → 태그 사용!
import Testing
extension Tag {
@Tag static var caffeinated: Self
@Tag static var chocolatey: Self
}
Tag 타입을 extension으로 확장@Tag 속성으로 태그임을 명시static var로 정적 변수 선언import Testing
// Suite 전체에 태그 적용
@Suite(.tags(.caffeinated)) struct DrinkTests {
@Test func espressoExtractionTime() { /* ... */ }
@Test func greenTeaBrewTime() { /* ... */ }
// 개별 테스트에 추가 태그 적용
@Test(.tags(.chocolatey)) func mochaIngredientProportion() { /* ... */ }
}
@Suite struct DessertTests {
// 여러 태그를 한 번에 적용
@Test(.tags(.caffeinated, .chocolatey)) func espressoBrownieTexture() { /* ... */ }
@Test func bungeoppangFilling() { /* ... */ }
@Test func fruitMochiFlavors() { /* ... */ }
}
태그 상속 규칙:
태그를 선언하면 Xcode 16에서 다양한 방식으로 활용할 수 있습니다.
검색 필터: 내비게이터 하단의 필터 필드 사용
태그 뷰 모드: 테스트 내비게이터 상단의 태그 아이콘 클릭
테스트 코드를 체계적으로 관리했다면, 이제 실행 속도를 최적화할 차례입니다.
Swift Testing은 기본적으로 병렬 실행됩니다!
| 특징 | XCTest | Swift Testing |
|---|---|---|
| 기본 실행 방식 | 직렬 (순차) | 병렬 (동시) |
| 물리 기기 지원 | 시뮬레이터만 병렬 | 물리 기기도 병렬 |
| 설정 필요 | 복잡한 설정 필요 | 추가 코드 불필요 |
| 프로세스 모델 | 다중 프로세스 | 단일 프로세스 내 병렬 |
주요 이점:
1. 실행 시간 극적 단축 - CI/CD에서 특히 중요
2. 빠른 피드백 루프 - 개발 생산성 향상
3. 리소스 효율성 - CPU 코어를 최대한 활용
4. 동기/비동기 무관 - 모든 테스트 함수가 병렬 실행 가능
Swift Testing은 테스트를 무작위 순서로 실행합니다.
왜 무작위로?
import Testing
// ❌ 동시성 안전하지 않은 코드
var cupcake: Cupcake? = nil
@Test func bakeCupcake() async {
cupcake = await Cupcake.bake(toppedWith: .frosting)
// ...
}
@Test func eatCupcake() async {
await eat(cupcake!) // 💥 cupcake이 nil일 수 있음!
// ...
}
문제:
eatCupcake이 bakeCupcake보다 먼저 실행되면 크래시병렬 실행의 효과:
레거시 코드나 특정 상황에서는 순차 실행이 필요할 수 있습니다.
import Testing
@Suite("Cupcake tests", .serialized)
struct CupcakeTests {
var cupcake: Cupcake?
@Test func mixingIngredients() { /* ... */ }
@Test func baking() { /* ... */ }
@Test func decorating() { /* ... */ }
@Test func eating() { /* ... */ }
}
.serialized 특성의 효과:
⚠️ 주의사항:
.serialized는 임시 방편으로만 사용1. 매개변수화된 테스트:
@Test(.serialized, arguments: [1, 2, 3, 4, 5])
func mixing(ingredient: Food) { /* ... */ }
→ 5개의 테스트 케이스가 순차적으로 실행
2. 중첩 Suite:
@Suite("Cupcake tests", .serialized)
struct CupcakeTests {
@Suite("Mini birthday cupcake tests")
struct MiniBirthdayCupcakeTests {
// 자동으로 .serialized 상속
}
@Test func mixing() { /* ... */ }
@Test func baking() { /* ... */ }
}
→ 부모 Suite의 .serialized가 자식에게 자동 상속
3. 성능 최적화:
.serialized Suite는 순차 실행병렬 테스트 환경에서 비동기 코드를 어떻게 테스트할까요?
Swift Testing은 async/await를 네이티브로 지원합니다.
import Testing
@Test func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
try await eat(cookies, with: .milk)
#expect(cookies.isEmpty)
}
동작 원리:
await로 비동기 작업을 대기레거시 코드는 여전히 완료 핸들러를 사용할 수 있습니다.
import Testing
@Test func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
// ❌ 테스트 함수가 반환된 후 실행됨
eat(cookies, with: .milk) { result, error in
#expect(result != nil) // 이 검증이 실행되지 않음!
}
}
완료 핸들러는 비동기로 호출되므로, 테스트 함수가 먼저 종료되고 검증이 실행되지 않습니다.
import Testing
@Test func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
try await eat(cookies, with: .milk) // ✅ async 버전 사용
}
Swift 컴파일러가 완료 핸들러 API를 자동으로 async/await로 변환해줍니다.
자동 변환이 안 되는 경우 수동으로 변환:
import Testing
@Test func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
try await withCheckedThrowingContinuation { continuation in
eat(cookies, with: .milk) { result, error in
if let result {
continuation.resume(returning: result)
} else {
continuation.resume(throwing: error!)
}
}
}
}
Continuation API:
withCheckedContinuation: non-throwing 버전withCheckedThrowingContinuation: throwing 버전이벤트 핸들러처럼 여러 번 호출되는 콜백의 횟수를 검증해야 할 때가 있습니다.
import Testing
@Test func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
// ❌ Swift 6 동시성 오류!
var cookiesEaten = 0
try await eat(cookies, with: .milk) { cookie, crumbs in
#expect(!crumbs.in(.milk))
cookiesEaten += 1 // 💥 Data race!
}
#expect(cookiesEaten == 10)
}
문제:
cookiesEaten 변수가 여러 스레드에서 동시 접근import Testing
@Test func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
try await confirmation("Ate cookies", expectedCount: 10) { ateCookie in
try await eat(cookies, with: .milk) { cookie, crumbs in
#expect(!crumbs.in(.milk))
ateCookie() // ✅ 동시성 안전하게 카운트
}
}
}
confirmation의 장점:
다양한 예상 횟수:
// 정확히 1번 (기본값)
confirmation("Event fired") { confirm in
// ...
}
// 정확히 10번
confirmation("Event fired", expectedCount: 10) { confirm in
// ...
}
// 0번 (호출되지 않아야 함)
confirmation("Should not fire", expectedCount: 0) { confirm in
// ...
}
WWDC 2024에서 소개된 Swift Testing의 고급 기능들을 살펴봤습니다.
#expect(throws:)로 4단계 정밀도의 에러 검증#require로 전제 조건을 간결하게 표현withKnownIssue로 기술 부채를 투명하게 관리CustomTestStringConvertible로 가독성 향상이 세션을 보면서 다음 질문들이 떠올랐습니다:
🤔 Test Plan 활용 전략
🎯 태그 분류 체계
extension Tag {
@Tag static var critical: Self // CI 필수
@Tag static var ui: Self // UI 관련
@Tag static var network: Self // 네트워크 필요
@Tag static var slow: Self // 실행 시간 오래 걸림
@Tag static var integration: Self // 통합 테스트
}
📊 Suite 구조화
테스트 리포트나 인사이트 기능은 아직 와닿지 않지만, 프로젝트 규모가 커지면서 그 가치를 체감하게 될 것 같습니다.