Swift Testing으로 테스트 심화하기

피터·2025년 10월 16일

Testing

목록 보기
1/2
post-thumbnail

안녕하세요. 어제에 이어서 Swift Testing에 대한 내용입니다.
WWDC 2024 세션 "Go further with Swift Testing"을 정리한 내용입니다.

개요

이전 글에서 Swift Testing의 기본 개념과 #expect 매크로에 대해 다뤘습니다. 이번 글에서는 더 고급 기능들을 살펴보겠습니다.

1. 에러 테스트 (#expect(throws:))

이전 글에서 다룬 기본 테스트 코드입니다:

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")
    }
}

이 방식의 문제점:

  • 코드가 장황하고 복잡함
  • 에러가 발생하지 않으면 테스트가 통과되어 버림 (false positive)
  • 테스트의 의도가 명확하지 않음

#expect(throws:) 매크로 사용

Swift Testing은 에러 테스트를 위한 전용 매크로를 제공합니다:

레벨 1: 에러 발생 검증

import Testing

@Test func brewTeaError() throws {
    let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
    #expect(throws: (any Error).self) {
        try teaLeaves.brew(forMinutes: 200)
    }
}

가장 기본적인 형태로, 어떤 종류든 에러가 발생하는지 검증합니다.

레벨 2: 에러 타입 검증

import Testing

@Test func brewTeaError() throws {
    let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
    #expect(throws: BrewingError.self) {
        try teaLeaves.brew(forMinutes: 200)
    }
}

특정 타입의 에러(BrewingError)가 발생하는지 검증합니다. 다른 타입의 에러가 발생하면 테스트 실패합니다.

레벨 3: 특정 에러 케이스 검증

import Testing

@Test func brewTeaError() throws {
    let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
    #expect(throws: BrewingError.oversteeped) {
        try teaLeaves.brew(forMinutes: 200)
    }
}

에러 타입뿐 아니라 특정 에러 케이스까지 검증합니다. BrewingError.oversteeped 케이스가 아니면 테스트가 실패합니다.

레벨 4: 복잡한 에러 검증 (Associated Value 포함)

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 케이스인지 확인
  • Associated Value인 optimalBrewTime이 4인지 확인

커스텀 검증으로 다음을 확인할 수 있습니다:

  • 특정 타입이나 에러 케이스
  • Associated Value 또는 에러 속성
  • 비즈니스 로직에 맞는 에러인지 여부

2. 옵셔널 검증 (#require)

옵셔널 값을 다룰 때 전통적인 guard 문 대신 #require 매크로를 사용할 수 있습니다.

전통적인 방식 (guard + Issue.record)

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을 잊으면 크래시 발생 가능
  • 테스트의 주요 로직이 명확하지 않음

#require 매크로 사용

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)
}

장점:

  • 옵셔널을 언래핑하고 nil일 경우 자동으로 테스트 중단
  • 코드가 간결하고 의도가 명확함
  • 언래핑된 값을 안전하게 사용 가능
  • throws를 사용하여 실패를 명시적으로 처리

#require테스트 전제 조건을 검증하는데 적합합니다. nil이 반환되면 해당 테스트를 계속 진행하는 것이 무의미하므로 즉시 중단합니다.

3. 알려진 이슈 처리 (withKnownIssue)

개발 중에는 알려진 버그나 이슈 때문에 특정 테스트가 실패할 수 있습니다. 이를 어떻게 처리할까요?

❌ .disabled 사용 (권장하지 않음)

import Testing

@Test(.disabled) func softServeIceCreamInCone() throws {
    try softServeMachine.makeSoftServe(in: .cone)
}

문제점:

  • 테스트가 아예 실행되지 않음
  • 이슈가 해결되었는지 알 수 없음
  • 컴파일 오류 발생 시 알림을 받지 못함
  • 테스트가 비활성화된 이유를 잊어버릴 수 있음

✅ withKnownIssue 사용 (권장)

import Testing

@Test func softServeIceCreamInCone() throws {
    withKnownIssue {
        try softServeMachine.makeSoftServe(in: .cone)
    }
}

장점:

  • 테스트는 계속 실행됨 - 완전히 비활성화하지 않음
  • 컴파일 오류 발생 시 즉시 알림 받음
  • 예상된 실패는 테스트 결과에 포함되지 않음
  • 테스트 리포트에서 "Known Issue"로 표시됨
  • 이슈가 해결되면 자동으로 알림 - 테스트가 성공하면 경고 발생

부분 래핑

테스트 전체가 아닌 특정 부분만 알려진 이슈로 표시할 수 있습니다:

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)
    }
}

이렇게 하면 테스트 가능한 부분은 검증하고, 문제가 있는 부분만 임시로 무시할 수 있습니다.

4. 복잡한 타입을 인자로 받는 매개변수화 테스트

이전 글에서는 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]))
...

가독성이 매우 떨어집니다!

해결: CustomTestStringConvertible 프로토콜

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를 채택하면:

  • 테스트 내비게이터에서 명확한 이름 표시
  • 테스트 보고서에서 가독성 향상
  • 실패한 테스트 케이스를 빠르게 식별

5. 매개변수화된 테스트 심화

코드의 품질 유지에서 가장 어려운 부분 중 하나는 모든 엣지 케이스를 다루는 것입니다. Swift Testing의 매개변수화된 테스트는 이 문제를 효과적으로 해결합니다.

매개변수화된 테스트의 장점

  1. 코드 중복 제거: 비슷한 테스트 코드를 여러 번 작성하지 않아도 됨
  2. 병렬 처리: 모든 테스트 케이스가 자동으로 병렬 실행됨
  3. 독립적 실행: 어떤 케이스가 실패해도 나머지는 계속 진행
  4. 선택적 실행: 특정 케이스만 골라서 다시 실행 가능
  5. 가독성: 테스트 데이터가 명확하게 표현됨

여러 인수 사용 (Cartesian Product)

두 개의 컬렉션을 전달하면 모든 조합을 자동으로 테스트합니다:

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)
}

동작 방식:

  • 첫 번째 컬렉션의 모든 요소 → 첫 번째 파라미터
  • 두 번째 컬렉션의 모든 요소 → 두 번째 파라미터
  • Cartesian Product (모든 조합) 자동 생성

결과: 4개 × 4개 = 16개의 테스트 케이스 자동 생성!

cook(rice, into: onigiri)
cook(rice, into: fries)
cook(rice, into: salad)
cook(rice, into: omelette)
cook(potato, into: onigiri)
...

조합 폭발 제어 (zip 사용)

조합을 제한하고 싶다면 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)

6. 테스트 구성 (Suite & Tags)

프로젝트가 커지면서 테스트 수가 수백, 수천 개로 증가할 때 체계적인 관리가 필수적입니다. Swift Testing은 두 가지 강력한 조직화 도구를 제공합니다.

중첩 모음 (Nested Suites)

Suite는 테스트 함수를 그룹화하는 타입입니다. 표시 이름과 특성으로 테스트를 문서화할 수 있습니다.

문제: 평면적인 테스트 구조

@Suite("Various desserts")
struct DessertTests {
    @Test func applePieCrustLayers() { /* ... */ }
    @Test func lavaCakeBakingTime() { /* ... */ }
    @Test func eggWaffleFlavors() { /* ... */ }
    @Test func cheesecakeBakingStrategy() { /* ... */ }
    @Test func mangoSagoToppings() { /* ... */ }
    @Test func bananaSplitMinimumScoop() { /* ... */ }
}

모든 테스트가 한 곳에 모여있어 구조가 불명확합니다. 따뜻한 디저트와 차가운 디저트가 섞여 있습니다.

해결: 하위 Suite 추가

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() { /* ... */ }
    }
}

장점:

  • 계층 구조로 테스트가 조직화됨
  • 테스트 그룹 간의 관계가 명확해짐
  • 테스트 네비게이터에서 폴더처럼 접고 펼칠 수 있음
  • Suite별로 독립적으로 실행 가능

Swift Testing의 핵심 기능 중 하나는 Suite 안에 Suite를 중첩할 수 있다는 것입니다. 이를 통해 복잡한 테스트 구조를 유연하게 만들 수 있습니다.

태그 (Tags)

Suite는 소스 코드 레벨에서 테스트를 조직화하지만, 태그는 여러 파일과 모듈을 가로지르는 연결을 만듭니다.

태그가 필요한 이유

복잡한 프로젝트에서:

  • 수백~수천 개의 테스트가 여러 파일에 흩어져 있음
  • Suite로는 표현할 수 없는 교차 관심사(cross-cutting concerns) 존재
  • 특정 조건에서만 실행해야 하는 테스트 그룹이 필요함

실전 예시:

  • 🚀 CI 필수 테스트: PR 머지 전 반드시 통과해야 하는 테스트
  • 🎨 UI 테스트: 화면 관련 테스트만 필터링
  • 빠른 테스트: 개발 중 자주 실행하는 가벼운 테스트
  • 🔒 통합 테스트: 외부 시스템이 필요한 무거운 테스트
  • 🏷️ 플랫폼별 테스트: iOS, macOS, watchOS 등

Suite vs Tags: 언제 무엇을 사용할까?

비교 항목SuiteTags
구조계층적, 트리 구조평면적, 다대다 관계
범위단일 파일/모듈 내여러 파일/모듈 가로지름
용도테스트 그룹의 논리적 구조화실행 전략, 필터링, 분류
관계부모-자식 관계태그별 연관 관계

예시:

  • Suite: LoginTests > EmailLoginTests, SocialLoginTests (계층 구조)
  • Tags: #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() { /* ... */ }
}

카페인이나 초콜릿이 들어간 제품만 테스트하고 싶다면? → 태그 사용!

태그 선언 및 사용법

1단계: 태그 선언

import Testing

extension Tag {
    @Tag static var caffeinated: Self
    @Tag static var chocolatey: Self
}
  • Tag 타입을 extension으로 확장
  • @Tag 속성으로 태그임을 명시
  • static var로 정적 변수 선언

2단계: 태그 적용

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() { /* ... */ }
}

태그 상속 규칙:

  • Suite에 태그를 지정하면 모든 하위 테스트가 자동 상속
  • 개별 테스트에 추가 태그 지정 가능
  • 테스트는 여러 태그를 동시에 가질 수 있음

Xcode 16의 태그 활용

태그를 선언하면 Xcode 16에서 다양한 방식으로 활용할 수 있습니다.

테스트 내비게이터 필터링

  1. 검색 필터: 내비게이터 하단의 필터 필드 사용

    • 입력 시 프로젝트의 모든 태그를 자동 완성으로 제안
    • 태그를 선택하면 해당 태그가 있는 테스트만 표시
  2. 태그 뷰 모드: 테스트 내비게이터 상단의 태그 아이콘 클릭

    • 프로젝트의 모든 태그를 그룹화하여 표시
    • 태그별로 테스트를 한눈에 확인
    • 태그 옆 재생 버튼으로 해당 태그의 모든 테스트 실행

7. 병렬 테스트

테스트 코드를 체계적으로 관리했다면, 이제 실행 속도를 최적화할 차례입니다.

Swift Testing의 혁명적 변화

Swift Testing은 기본적으로 병렬 실행됩니다!

특징XCTestSwift 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일 수 있음!
    // ...
}

문제:

  • eatCupcakebakeCupcake보다 먼저 실행되면 크래시
  • 전역 변수를 통한 암묵적 의존성
  • 테스트가 순서대로 실행될 때만 성공

병렬 실행의 효과:

  • 무작위 순서로 실행되면서 문제가 즉시 드러남
  • 테스트 격리 원칙 위반을 발견
  • 리팩토링 필요성을 명확하게 알림

.serialized 특성: 순차 실행이 필요할 때

레거시 코드나 특정 상황에서는 순차 실행이 필요할 수 있습니다.

import Testing

@Suite("Cupcake tests", .serialized)
struct CupcakeTests {
    var cupcake: Cupcake?

    @Test func mixingIngredients() { /* ... */ }
    @Test func baking() { /* ... */ }
    @Test func decorating() { /* ... */ }
    @Test func eating() { /* ... */ }
}

.serialized 특성의 효과:

  • Suite 내 모든 테스트가 순서대로 실행
  • 테스트 간 상태 공유 가능 (권장하지 않음)
  • 병렬 실행의 성능 이점 포기

⚠️ 주의사항:

  • .serialized임시 방편으로만 사용
  • 가능하면 테스트를 독립적으로 리팩토링하는 것이 바람직
  • Swift 6의 동시성 체크를 활용하여 문제 발견

.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는 순차 실행
  • 다른 Suite의 테스트는 여전히 병렬 실행
  • 전체 성능 저하를 최소화

비동기 테스트 처리

병렬 테스트 환경에서 비동기 코드를 어떻게 테스트할까요?

async/await 사용

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로 비동기 작업을 대기
  • 대기 중에 다른 테스트가 CPU를 사용 (효율적!)
  • 작업 완료 후 테스트 재개
  • 프로덕션 코드와 동일한 방식

Completion Handler 처리

레거시 코드는 여전히 완료 핸들러를 사용할 수 있습니다.

문제: 완료 핸들러는 테스트가 끝난 후 호출됨
import Testing

@Test func bakeCookies() async throws {
    let cookies = await Cookie.bake(count: 10)
    // ❌ 테스트 함수가 반환된 후 실행됨
    eat(cookies, with: .milk) { result, error in
        #expect(result != nil)  // 이 검증이 실행되지 않음!
    }
}

완료 핸들러는 비동기로 호출되므로, 테스트 함수가 먼저 종료되고 검증이 실행되지 않습니다.

해결 방법 1: 비동기 오버로드 사용 (권장)
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로 변환해줍니다.

해결 방법 2: Continuation 사용

자동 변환이 안 되는 경우 수동으로 변환:

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 버전
  • 완료 핸들러를 async/await로 브릿징

Confirmation: 여러 번 호출되는 콜백 테스트

이벤트 핸들러처럼 여러 번 호출되는 콜백의 횟수를 검증해야 할 때가 있습니다.

문제: 동시성 안전하지 않은 카운팅
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 변수가 여러 스레드에서 동시 접근
  • Swift 6 strict concurrency에서 컴파일 에러
  • 데이터 레이스 발생 가능
해결: confirmation 사용
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의 장점:

  • 동시성 안전: 내부적으로 Actor로 구현되어 thread-safe
  • 명시적 의도: 콜백이 몇 번 호출되어야 하는지 명확히 표현
  • 자동 검증: 예상 횟수와 다르면 자동으로 테스트 실패

다양한 예상 횟수:

// 정확히 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의 고급 기능들을 살펴봤습니다.

핵심 정리

  1. 에러 테스트: #expect(throws:)로 4단계 정밀도의 에러 검증
  2. 옵셔널 검증: #require로 전제 조건을 간결하게 표현
  3. 알려진 이슈: withKnownIssue로 기술 부채를 투명하게 관리
  4. 복잡한 타입: CustomTestStringConvertible로 가독성 향상
  5. 매개변수화: Cartesian Product와 zip으로 엣지 케이스 망라
  6. Suite & Tags: 계층 구조와 교차 관심사를 모두 표현
  7. 병렬 테스트: 기본 병렬 실행으로 극적인 성능 향상
  8. 비동기 처리: async/await, Continuation, Confirmation

실전 적용 아이디어

이 세션을 보면서 다음 질문들이 떠올랐습니다:

🤔 Test Plan 활용 전략

  • CI/CD 파이프라인에서 어떤 테스트를 언제 실행할까?

🎯 태그 분류 체계

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 구조화

  • 기능별 Suite (Login, Profile, Payment 등)
  • 레이어별 중첩

테스트 리포트나 인사이트 기능은 아직 와닿지 않지만, 프로젝트 규모가 커지면서 그 가치를 체감하게 될 것 같습니다.

profile
iOS 개발자입니다.

0개의 댓글