Meet Swift Testing

피터·2025년 10월 15일

Testing

목록 보기
2/2
post-thumbnail

오늘은 WWDC24에서 소개된 Swift Testing에 대해 알아보려고 합니다.
https://developer.apple.com/kr/videos/play/wwdc2024/10179/

Swift Testing은 사용해본 적이 있지만, 그동안 깊이 있게 공부하지 않고 XCTest보다 직관적이고 가독성이 좋다는 정도만 알고 사용했습니다. 이번 기회에 제대로 정리해보기로 했습니다.

Swift Testing의 주요 변화

기존 XCTest와 비교했을 때 체감한 주요 변화들을 먼저 정리해보면:

  • @Test 매크로: 함수명이 test로 시작하지 않아도 테스트로 인식됩니다
  • async/await 네이티브 지원: 비동기 테스트 작성이 훨씬 간편해졌습니다
  • #expect / #require: 표현력 있는 검증 매크로
  • 매개변수화된 테스트: 인자를 받아서 테스트를 반복 실행할 수 있습니다
  • Suite 개념: 테스트를 논리적으로 그룹화하고 관리합니다
  • 디버깅 개선: 매크로 기반이라 실제 결과값을 자동으로 캡처해서 디버깅이 쉬워졌습니다
  • 다양한 Traits: .tag, .bug, .disabled 등으로 테스트를 세밀하게 제어할 수 있습니다

이제 하나씩 살펴보겠습니다.

시작하기

테스트 번들 대상 추가

프로젝트에 Swift Testing을 추가하려면:
1. File > New > Target 선택
2. Test 섹션에서 Unit Testing Bundle 검색 후 추가

첫 테스트 작성

1단계: Testing 모듈 임포트

import Testing

2단계: @Test 함수 작성

@Test 속성을 함수에 붙이면 Xcode가 인식하고 옆에 Run 버튼을 표시해줍니다.

XCTest에서는 함수명이 test로 시작해야 했는데, 이제는 그럴 필요가 없어서 훨씬 편해졌습니다.

3단계: 테스트할 모듈 임포트 및 검증 작성

import Testing
@testable import Sample

@Test func example() {
    let a = 5
    let b = 10
    #expect(a + b == 16)
}

@testable 속성을 사용하면 액세스 수준이 internal인 타입도 테스트에서 접근할 수 있습니다.

4단계: #expect로 검증

테스트가 실패하면 Swift Testing은 단순히 "실패했습니다"가 아니라, 표현식의 각 하위값까지 캡처해서 보여줍니다. 이게 디버깅할 때 정말 큰 도움이 됩니다.

예를 들어 #expect(video.metadata == expectedMetadata)가 실패하면, video.metadata의 실제 값과 expectedMetadata의 값을 모두 확인할 수 있습니다.

Swift Testing의 4가지 구성 요소

1. 테스트 함수 (@Test)

@Test 속성을 가진 일반 Swift 함수가 테스트가 됩니다. 특징은:

  • 전역 함수 또는 타입의 메서드로 작성 가능
  • 비동기(async) 또는 예외 처리(throws) 가능
  • 전역 액터로 격리 가능 (예: @MainActor)

표시 이름 추가

@Test("덧셈 테스트입니다.")
func example() async throws {
    let a = 5
    let b = 10
    #expect(a + b == 16)
}

이제 테스트 이름을 정할 때 골머리 썩지 않아도 됩니다. 예전에는 XCTest에서 이런 식으로 작성해야 했습니다:

func test_덧셈테스트를_합니다() {
    // ...
}

이제는 함수명과 별개로 표시 이름을 지정할 수 있고, 테스트 네비게이터에도 그대로 표시되니 훨씬 깔끔합니다.

2. 검증 매크로: #expect#require

#expect 매크로

일반 Swift 표현식과 연산자를 그대로 사용할 수 있습니다. 실패 시 소스 코드와 하위 표현식의 값을 자동으로 캡처합니다.

// == 연산자 사용
#expect(video.metadata == expectedMetadata)

// 속성 접근
#expect(collection.isEmpty)

// 메서드 호출
#expect(numbers.contains(7))

특별한 API를 외울 필요 없이 #expect 하나로 거의 모든 검증이 가능합니다.

#require 매크로

#require테스트의 전제 조건을 검증하는 매크로로, 두 가지 형태로 사용됩니다.

형태 1: Bool 조건 검증
@freestanding(expression)
macro require(
    _ condition: Bool,
    _ comment: @autoclosure () -> Comment? = nil,
    sourceLocation: SourceLocation = #_sourceLocation
)

용도: Boolean 조건이 반드시 true여야 하는 경우
실패 시: 테스트를 즉시 중단하고 에러를 throw

@Test func testWithPreCondition() throws {
    let user = User(age: 20)

    // age가 18 이상이어야 테스트 진행
    try #require(user.age >= 18, "User must be adult")

    // 위 조건이 만족되면 이후 로직 실행
    #expect(user.canVote == true)
}
형태 2: 옵셔널 언래핑
@freestanding(expression)
macro require<T>(
    _ optionalValue: T?,
    _ comment: @autoclosure () -> Comment? = nil,
    sourceLocation: SourceLocation = #_sourceLocation
) -> T  // 언래핑된 값을 반환!

용도: 옵셔널 값을 안전하게 언래핑
실패 시: nil이면 테스트를 즉시 중단하고 에러를 throw
성공 시: 언래핑된 non-optional 값을 반환

실전 비교: 옵셔널 언래핑

❌ 기존 방식 (비효율적):

@Test("0이 아닌 수로 나누기")
func divisionWithNonZeroValue() throws {
    let calculator = Calculator()

    // divide는 Int?를 반환
    #expect(calculator.divide(10, by: 2) != nil)  // 1. nil 체크
    #expect(calculator.divide(10, by: 2) == 5)    // 2. 다시 호출! (비효율)
}

문제점:

  • divide 메서드를 2번 호출 (성능 저하)
  • 두 호출 사이에 상태가 바뀔 수 있음 (불안정)
  • nil이어도 두 번째 줄까지 실행됨 (불필요한 실행)

#require 사용 (권장):

@Test("0이 아닌 수로 나누기")
func divisionWithNonZeroValue() throws {
    let calculator = Calculator()

    // divide는 Int?를 반환 → #require로 언래핑
    let result = try #require(calculator.divide(10, by: 2))
    #expect(result == 5)  // result는 Int 타입 (옵셔널 아님!)
}

장점:

  • divide 메서드를 1번만 호출
  • nil이면 즉시 테스트 중단 (효율적)
  • 언래핑된 값을 안전하게 사용
  • 코드 의도가 명확함 ("이 값은 반드시 존재해야 함")

훨씬 간결하고 의도도 명확해집니다. #require는 값이 nil이면 즉시 테스트를 실패시키고, 그렇지 않으면 언래핑된 값을 반환합니다.

3. 특성 (Traits)

Traits는 테스트의 동작을 제어하는 메타데이터입니다. 테스트를 활성화/비활성화하거나, 버전별로 분기하거나, 버그를 추적할 수 있습니다.

조건부 테스트 실행

기능 플래그에 따른 테스트

struct AppFeatures {
    static let isCommentingEnabled = true
    static let isPremiumFeature = false
}

struct VideoTests {

    @Test(.enabled(if: AppFeatures.isCommentingEnabled))
    func videoCommenting() async throws {
        // 댓글 기능이 활성화되어 있을 때만 테스트
        let video = Video(title: "Test")
        #expect(video.comments.isEmpty)
    }

    @Test(.enabled(if: AppFeatures.isPremiumFeature))
    func premiumFeatureTest() {
        // 프리미엄 기능이 비활성화되어 있으므로 이 테스트는 스킵됨
    }
}

환경별 테스트

@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil))
func ciOnlyTest() {
    // CI 환경에서만 실행
}

@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] == nil))
func localOnlyTest() {
    // 로컬 환경에서만 실행
}

이런 식으로 CI/CD 파이프라인에서만 실행되어야 하는 테스트나, 로컬 개발 환경에서만 필요한 테스트를 구분할 수 있습니다.

디버그/릴리스 빌드별 테스트

@Test(.enabled(if: _isDebugAssertConfiguration()))
func debugOnlyTest() {
    // 디버그 빌드에서만 실행
}

테스트 비활성화

@Test(.disabled("아직 구현 중"))
func unfinishedFeatureTest() {
    // 이 테스트는 스킵됨
}

@Test(.disabled("버그 수정 필요: JIRA-123"))
func buggyTest() {
    // 임시로 비활성화
}

.disabled trait는 테스트를 임시로 스킵할 때 사용합니다. 주석 처리보다 훨씬 명시적이고, 테스트 결과 리포트에도 표시됩니다.

버그 추적

.bug trait를 사용하면 비활성화한 이유와 관련 문서를 연결할 수 있습니다.

@Test(
    "크래시 발생",
    .disabled("0으로 나누면 크래시"),
    .bug("https://github.com/apple/swift", "나눗셈 크래시")
)
func crashTest() throws {
    // 이 테스트는 스킵됨
}

이렇게 작성하면 Xcode 16의 테스트 리포트에서 버그 URL을 클릭할 수 있습니다.

빨간색 표시를 누르면 해당 링크로 바로 이동합니다. 노션, Jira, Linear 등 어떤 이슈 트래커든 연결할 수 있어서 팀 협업 시 유용합니다.

OS 버전 조건

// ✅ @available 속성 사용 권장
@Test
@available(macOS 15, *)
func usesNewAPIs() {
    // macOS 15 이상에서만 실행
}

// ❌ 런타임에서 #available 사용 지양
@Test func hasRuntimeVersionCheck() {
    guard #available(macOS 15, *) else { return }
    // ...
}

@available 속성을 사용하면 테스트 라이브러리가 OS 버전 조건을 인식해서 결과에 더 정확하게 반영할 수 있습니다. 런타임에서 guard #available을 사용하면 테스트가 그냥 성공으로 처리되어버려서 의도가 불명확합니다.

4. 테스트 모음 (Suite)

Suite는 관련 테스트 함수를 그룹화하는 타입입니다. 구조체(struct), 클래스(class), 액터(actor) 모두 가능합니다.

기본 사용법

struct VideoTests {

    @Test("Check video metadata")
    func videoMetadata() {
        let video = Video(fileName: "By the Lake.mov")
        let expectedMetadata = Metadata(duration: .seconds(90))
        #expect(video.metadata == expectedMetadata)
    }

    @Test
    func rating() async throws {
        let video = Video(fileName: "By the Lake.mov")
        #expect(video.contentRating == "G")
    }
}

테스트 네비게이터에 계층 구조가 반영되며, 그룹 단위로 실행할 수 있습니다.

저장 속성으로 공통 코드 추출

struct VideoTests {
    let video = Video(fileName: "By the Lake.mov")

    @Test("Check video metadata")
    func videoMetadata() {
        let expectedMetadata = Metadata(duration: .seconds(90))
        #expect(video.metadata == expectedMetadata)
    }

    @Test
    func rating() async throws {
        #expect(video.contentRating == "G")
    }
}

각 테스트 함수마다 별도의 Suite 인스턴스가 생성되므로 의도치 않은 상태 공유를 방지할 수 있습니다. XCTest의 setUp()과 비슷한 역할을 하지만 훨씬 간결합니다.

@Suite 속성

@Suite(.tags(.formatting))
struct MetadataPresentation {
    // ...
}

@Suite 속성으로 명시적으로 표시할 수도 있습니다. 테스트 함수나 다른 Suite를 포함하는 모든 타입은 암시적으로 Suite로 간주됩니다.

태그를 사용한 테스트 연결

태그를 사용하면 공통된 특징을 가진 테스트를 서로 다른 Suite나 파일에 있어도 연결할 수 있습니다.

태그 정의

extension Tag {
    @Tag static var calculating: Self
}

개별 테스트에 태그 추가

@Test(
    "영으로 나누지 않는 경우",
    .tags(.calculating)
)
func divisionWithNonZeroValue() throws {
    let calculator = Calculator()
    let result = try #require(calculator.divide(10, by: 2))
    #expect(result == 5)
}

Suite 전체를 태그로 묶기

@Suite(.tags(.calculating))
struct CalculatorTests {
    // 이 Suite 안의 모든 테스트가 .calculating 태그를 상속받음
}

Suite에 태그를 붙이면 그 안의 모든 테스트가 자동으로 해당 태그를 상속받습니다. 이렇게 하면 관련 테스트를 일일이 태그할 필요가 없어서 편합니다.

태그 활용

  • 테스트 네비게이터에서 Group By: Tag 모드로 전환하여 태그별로 테스트 확인
  • 특정 태그가 있는 모든 테스트만 실행
  • Test Report에서 필터링
  • 동일한 태그를 가진 여러 테스트가 실패하면 인사이트 확인 가능

실전 태그 예시

import Testing

extension Tag {
    // 속도별
    @Tag static var fast: Self        // < 0.1초
    @Tag static var slow: Self        // > 1초

    // 중요도별
    @Tag static var critical: Self    // 반드시 통과해야 함
    @Tag static var optional: Self    // 실패해도 배포 가능

    // 환경별
    @Tag static var requiresNetwork: Self
    @Tag static var requiresDatabase: Self
    @Tag static var offline: Self
    
    // 개발 단계별
    @Tag static var wip: Self         // Work In Progress
    @Tag static var regression: Self  // 회귀 테스트
    @Tag static var smoke: Self       // 기본 동작 확인
}

이런 식으로 태그를 정의해두면 상황에 맞게 테스트를 선별해서 실행할 수 있습니다.

커맨드 라인에서 태그 필터링

# 특정 태그만 실행
swift test --filter "tag:critical"

# 여러 태그 조합
swift test --filter "tag:fast AND tag:offline"

# 특정 태그 제외
swift test --filter "NOT tag:slow"

CI/CD 파이프라인에서 이런 식으로 필터링하면, PR마다 빠른 테스트만 돌리고 배포 전에는 전체 테스트를 돌리는 식으로 구성할 수 있습니다.

태그 vs 다른 특성

항상 각 상황에 가장 적합한 Trait를 사용해야 합니다:

  • 런타임 조건 → .enabled(if:) 사용
  • 모든 시나리오가 태그를 필요로 하는 것은 아님
  • 테스트 계획에서는 특정 이름보다 태그를 포함/제외하는 것이 더 효과적

매개변수화된 테스트

가장 강력한 기능 중 하나로, 같은 테스트 로직을 다양한 입력값으로 반복 실행할 수 있습니다.

기존 방식의 문제점

struct VideoContinentsTests {

    @Test func mentionsFor_A_Beach() async throws {
        let videoLibrary = try await VideoLibrary()
        let video = try #require(await videoLibrary.video(named: "A Beach"))
        #expect(!video.mentionedContinents.isEmpty)
        #expect(video.mentionedContinents.count <= 3)
    }

    @Test func mentionsFor_By_the_Lake() async throws {
        let videoLibrary = try await VideoLibrary()
        let video = try #require(await videoLibrary.video(named: "By the Lake"))
        #expect(!video.mentionedContinents.isEmpty)
        #expect(video.mentionedContinents.count <= 3)
    }

    @Test func mentionsFor_Camping_in_the_Woods() async throws {
        let videoLibrary = try await VideoLibrary()
        let video = try #require(await videoLibrary.video(named: "Camping in the Woods"))
        #expect(!video.mentionedContinents.isEmpty)
        #expect(video.mentionedContinents.count <= 3)
    }

    // ...더 많은 비슷한 테스트 함수들
}

이 패턴의 단점:

  • 매우 반복적인 코드
  • 유지 관리 어려움 (검증 로직 변경 시 모든 함수 수정 필요)
  • 각 테스트에 고유한 함수 이름 필요
  • 함수 이름이 읽기 어렵고 테스트 대상과 일치하지 않을 수 있음

매개변수화된 테스트로 리팩토링

struct VideoContinentsTests {

    @Test("Number of mentioned continents", arguments: [
        "A Beach",
        "By the Lake",
        "Camping in the Woods",
        "The Rolling Hills",
        "Ocean Breeze",
        "Patagonia Lake",
        "Scotland Coast",
        "China Paddy Field",
    ])
    func mentionedContinentCounts(videoName: String) async throws {
        let videoLibrary = try await VideoLibrary()
        let video = try #require(await videoLibrary.video(named: videoName))
        #expect(!video.mentionedContinents.isEmpty)
        #expect(video.mentionedContinents.count <= 3)
    }
}

장점:

  • 테스트 내비게이터에 각 인수가 별도 테스트로 표시됨
  • 개별 인수만 선택하여 디버깅 가능
  • 인수 추가가 간단 (배열에 문자열 하나만 추가하면 됨)
  • 각 인수를 병렬로 실행하여 효율성 향상
  • 검증 로직을 한 곳에서만 수정하면 모든 케이스에 적용

for...in 루프 vs 매개변수화된 테스트

// ❌ for...in 루프 사용 (권장하지 않음)
@Test func mentionedContinentCounts() async throws {
    let videoNames = [
        "A Beach",
        "By the Lake",
        "Camping in the Woods",
    ]

    let videoLibrary = try await VideoLibrary()
    for videoName in videoNames {
        let video = try #require(await videoLibrary.video(named: videoName))
        #expect(!video.mentionedContinents.isEmpty)
        #expect(video.mentionedContinents.count <= 3)
    }
}

for...in 루프를 사용하면 어느 케이스에서 실패했는지 파악하기 어렵습니다. 또한 하나가 실패하면 나머지 케이스를 테스트하지 않고 중단되며, 병렬 실행도 불가능합니다.

매개변수화된 테스트를 사용하면:

  • 각 개별 인수의 성공/실패 여부를 결과에서 명확하게 확인 가능
  • 세분화된 디버깅을 위해 인수를 독립적으로 재실행 가능
  • 각 인수를 병렬로 실행하여 더 빠른 결과 획득

리팩토링 단계

// 1. 함수에 매개변수 추가
// 2. for...in 루프 제거
// 3. 인수를 @Test 속성으로 이동

@Test(arguments: [
    "A Beach",
    "By the Lake",
    "Camping in the Woods",
])
func mentionedContinentCounts(videoName: String) async throws {
    let videoLibrary = try await VideoLibrary()
    let video = try #require(await videoLibrary.video(named: videoName))
    #expect(!video.mentionedContinents.isEmpty)
    #expect(video.mentionedContinents.count <= 3)
}

정리

Swift Testing은 XCTest의 아쉬운 점을 많이 개선한 프레임워크입니다. 특히:

  • 표현력: @Test 표시 이름, #expect 자동 캡처 덕분에 테스트 의도가 명확해집니다
  • 유연성: Traits, 태그, 매개변수화 등으로 다양한 테스트 시나리오 대응 가능
  • 생산성: 보일러플레이트 코드 감소, 디버깅 개선으로 개발 속도 향상
  • 현대적: async/await, 매크로 등 최신 Swift 기능 활용

아직 XCTest와 혼용하는 프로젝트도 많지만, 새로운 테스트는 Swift Testing으로 작성하면 확실히 생산성이 올라갑니다. 특히 매개변수화된 테스트는 정말 유용해서, 앞으로 적극적으로 활용할 생각입니다 👍

profile
iOS 개발자입니다.

0개의 댓글