Swift Testing 프레임워크 완벽 가이드: @Test와 #expect로 XCTest 대체하기 (2026)

Apple의 Swift Testing으로 더 표현력 있고 안전한 테스트를 작성하는 방법을 배워보세요. @Test 매크로, #expect 표현식, 파라미터화 테스트, async/await 통합까지 Xcode 26과 Swift 6.2 환경에서 실전 예제로 살펴봅니다.

Swift Testing 가이드 2026: @Test로 XCTest 대체

솔직히 말하면, XCTest를 처음 익혔던 시절을 떠올리면 지금도 살짝 한숨이 나옵니다. 상속, setUp/tearDown, 끝없이 늘어나던 XCTAssertEqual 호출들... 그런데 Swift Testing은 그 모든 인상을 한 번에 바꿔놓더군요. Apple이 Swift 6와 함께 정식 도입한 이 차세대 프레임워크는 Xcode 26과 Swift 6.2에 이르러 정말로 안정화됐습니다. 이제는 XCTest를 대체할 "진짜" 선택지라고 자신 있게 말할 수 있죠.

이 글에서는 @Test 매크로, #expect 표현식, 파라미터화 테스트, 비동기 처리까지 — 실전에서 자주 마주치는 패턴들을 코드와 함께 차근차근 살펴보겠습니다. 자, 시작해볼까요.

Swift Testing이란 무엇인가

한 줄로 요약하자면, Swift Testing은 매크로 기반의 새로운 테스트 라이브러리입니다. XCTest보다 보일러플레이트가 훨씬 적고, 진단 정보는 비교가 안 될 정도로 풍부하죠. 더 이상 XCTestCase 클래스를 상속할 필요도 없습니다. 테스트 함수에 @Test 매크로 하나만 붙이면 끝.

XCTest와의 주요 차이점

  • 매크로 기반: @Test, #expect, #require로 의도를 명확하게 드러냅니다.
  • 병렬 실행이 기본: 모든 테스트가 별다른 설정 없이 병렬로 돌아갑니다(체감 속도가 꽤 차이 납니다).
  • 값 타입 지원: structactor로도 테스트 스위트를 정의할 수 있습니다.
  • 풍부한 진단: 실패하면 표현식의 부분 값들을 알아서 캡처해줍니다.
  • Swift Concurrency 통합: async/throws 함수를 자연스럽게 테스트합니다.

프로젝트 설정 (Xcode 26 기준)

Xcode 26 이상이라면, 새 테스트 타깃을 만들 때 Swift Testing이 기본 옵션으로 제공됩니다. 기존 프로젝트를 옮기는 경우에도 크게 손볼 건 없고, Package.swift나 테스트 타깃 설정만 한 번 확인하면 됩니다.

// Package.swift
// swift-tools-version: 6.2
import PackageDescription

let package = Package(
    name: "MyLibrary",
    targets: [
        .target(name: "MyLibrary"),
        .testTarget(
            name: "MyLibraryTests",
            dependencies: ["MyLibrary"]
        )
    ]
)

Swift 6.0 이상이라면 Swift Testing이 표준 라이브러리에 포함되어 있어서, 별도로 의존성을 추가할 필요가 없습니다. (예전에 swift-testing 패키지를 수동으로 끌어다 쓰던 분들에겐 반가운 소식이죠.)

첫 번째 테스트 작성하기

가장 기본적인 형태부터 봅시다.

import Testing
@testable import MyLibrary

@Test func 두_숫자를_더한다() {
    let result = Calculator.add(2, 3)
    #expect(result == 5)
}

핵심 포인트는 이렇습니다.

  • import Testing 한 줄로 프레임워크를 불러옵니다.
  • 함수 이름에 한글이나 밑줄을 자유롭게 쓸 수 있어요. 가독성이 정말 좋아집니다.
  • #expect가 실패하면 좌변과 우변의 실제 값을 자동으로 보여줍니다.

테스트에 사람이 읽을 수 있는 이름 부여하기

@Test("덧셈은 교환법칙을 만족한다")
func 교환법칙() {
    #expect(Calculator.add(2, 3) == Calculator.add(3, 2))
}

이렇게 적어두면 Xcode 테스트 네비게이터와 보고서에 한글 설명이 그대로 표시됩니다. 팀에서 비개발자도 결과를 본다면 꽤 유용한 기능이에요.

#expect와 #require의 차이

둘 다 조건을 검증한다는 점에선 비슷한데, 실패했을 때의 동작이 다릅니다.

매크로실패 시 동작사용 시점
#expect실패를 기록하고 계속 진행여러 조건을 한 번에 검증할 때
#requirethrow하여 테스트 중단이후 코드의 전제 조건일 때
@Test func 사용자_데이터_파싱() throws {
    let json = #"{"name":"홍길동","age":30}"#
    let user = try #require(User.parse(json))

    #expect(user.name == "홍길동")
    #expect(user.age == 30)
}

특히 옵셔널 언래핑이나 nil이 아님을 보장하고 다음 코드를 안전하게 진행해야 할 때, #require가 진짜 빛을 발합니다.

파라미터화 테스트로 중복 제거하기

같은 로직을 여러 입력값으로 테스트하는 경우 — 솔직히 이 부분이 Swift Testing의 가장 매력적인 기능 중 하나라고 봅니다. XCTest 시절엔 for 루프로 어떻게든 처리했지만, 실패하면 어떤 입력값에서 깨졌는지 추적하기가 꽤 번거로웠죠. Swift Testing에선 이게 일급 기능입니다.

@Test("이메일 유효성 검증", arguments: [
    "[email protected]",
    "[email protected]",
    "[email protected]"
])
func 유효한_이메일(_ email: String) {
    #expect(EmailValidator.isValid(email))
}

@Test("잘못된 이메일 형식 거부", arguments: [
    "without-at-sign.com",
    "@no-local.com",
    "[email protected]"
])
func 잘못된_이메일(_ email: String) {
    #expect(!EmailValidator.isValid(email))
}

여러 인자 조합 테스트하기

@Test("덧셈 조합", arguments: [1, 2, 3], [10, 20, 30])
func 덧셈_매트릭스(_ a: Int, _ b: Int) {
    #expect(Calculator.add(a, b) == a + b)
}

이렇게만 적어두면 9개의 조합이 자동으로 만들어지고, 각각 개별 테스트로 실행됩니다. 어떤 조합이 깨졌는지 테스트 네비게이터에서 바로 보이니까, 디버깅 시간이 확 줄어들어요.

zip으로 입력과 기대값 짝짓기

@Test("환율 변환", arguments: zip(
    [100.0, 200.0, 1000.0],
    [82_500.0, 165_000.0, 825_000.0]
))
func 환율_변환(_ usd: Double, _ expectedKrw: Double) {
    let rate = 825.0
    #expect(CurrencyConverter.toKrw(usd, rate: rate) == expectedKrw)
}

테스트 스위트로 구조화하기

관련된 테스트끼리 묶고 공통 설정을 공유하고 싶다면 @Suite를 사용합니다.

@Suite("사용자 인증 모듈")
struct AuthTests {
    let service: AuthService

    init() async throws {
        self.service = try await AuthService.makeForTesting()
    }

    @Test func 로그인_성공() async throws {
        let token = try await service.login(id: "test", pw: "1234")
        #expect(!token.isEmpty)
    }

    @Test func 잘못된_비밀번호_거부() async {
        await #expect(throws: AuthError.invalidCredentials) {
            try await service.login(id: "test", pw: "wrong")
        }
    }
}

중요한 점: init은 각 테스트마다 새로 실행됩니다. 이게 무슨 뜻이냐면 — 테스트 간 격리가 자동으로 보장된다는 의미예요. 그래서 병렬로 돌아도 안전합니다. 정리 로직이 필요하면 deinit을 쓰면 되고요(actor가 아닌 경우에 한해서).

비동기 코드 테스트하기

Swift Concurrency와의 통합은 정말 매끄럽습니다. 별다른 트릭 없이 그냥 async throws를 붙이면 끝이에요.

@Test func 네트워크_요청() async throws {
    let client = APIClient(session: .mock)
    let articles = try await client.fetchArticles()

    try #require(!articles.isEmpty)
    #expect(articles.first?.title == "Hello")
}

예외 발생 검증

@Test func 빈_입력_에러() async {
    await #expect(throws: ValidationError.empty) {
        try await Validator.validate("")
    }
}

@Test func 임의의_에러_검증() async {
    await #expect(throws: (any Error).self) {
        try await riskyOperation()
    }
}

태그로 테스트 분류하기

Xcode 26에서 태그 기반 필터링이 한층 더 강화됐습니다. 느린 통합 테스트와 빠른 단위 테스트를 분리해서, CI에서는 선택적으로 돌릴 수 있죠. 개인적으로 이 기능 덕분에 PR 빌드 시간을 절반 가까이 줄였습니다.

extension Tag {
    @Tag static var integration: Self
    @Tag static var slow: Self
    @Tag static var ui: Self
}

@Test(.tags(.integration, .slow))
func 전체_결제_플로우() async throws {
    // 외부 API와 데이터베이스를 사용하는 시나리오
}

커맨드라인에선 이렇게 필터링합니다.

swift test --filter-tag integration
swift test --skip-tag slow

조건부 활성화와 비활성화

@Test(.disabled("FB12345678 해결 대기 중"))
func 알려진_버그_재현() { ... }

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

@Test(.bug("https://bugs.example.com/123", "토큰 만료 처리 누락"))
func 회귀_테스트() { ... }

XCTest에서 Swift Testing으로 마이그레이션

두 프레임워크는 같은 타깃 안에서 평화롭게 공존할 수 있습니다. 그래서 한꺼번에 다 갈아엎지 말고, 점진적으로 이전하는 걸 강력하게 추천해요. 저희 팀도 그렇게 했고, 부작용이 거의 없었습니다.

매핑 표

XCTestSwift Testing
XCTAssertEqual(a, b)#expect(a == b)
XCTAssertTrue(x)#expect(x)
XCTAssertNil(x)#expect(x == nil)
XCTUnwrap(x)try #require(x)
XCTAssertThrowsError#expect(throws: ...)
setUp / tearDowninit / deinit
XCTSkip.disabled / .enabled(if:)

단계별 마이그레이션 절차

  1. 새로 추가하는 테스트는 Swift Testing으로만 작성합니다.
  2. 가장 단순한 단위 테스트 파일부터 차근차근 변환합니다.
  3. setUp이 복잡한 스위트는 @Suiteinit으로 옮깁니다.
  4. UI 테스트는 일단 XCTest에 그대로 둡니다(2026년 5월 현재 Swift Testing은 UI 테스트를 지원하지 않습니다).
  5. CI 파이프라인의 결과 보고가 두 프레임워크를 모두 인식하는지 확인합니다.

실전 팁: 더 나은 테스트 작성

1. 표현식을 풀어쓰지 마세요

// 좋지 않음
let isValid = user.age >= 18
#expect(isValid)

// 좋음 — 실패 메시지에 비교 값이 그대로 표시됨
#expect(user.age >= 18)

2. 메시지 인자 활용하기

#expect(result.count == expected.count, "응답 항목 수가 일치하지 않습니다: \(result)")

3. CustomTestStringConvertible로 진단 강화

extension User: CustomTestStringConvertible {
    var testDescription: String {
        "User(id: \(id), name: \(name))"
    }
}

4. 직렬 실행이 필요한 경우

@Suite(.serialized)
struct DatabaseTests {
    // 데이터베이스 파일을 공유하므로 순차 실행이 필요한 테스트들
}

CI/CD와의 통합

Xcode Cloud와 GitHub Actions 둘 다 Swift Testing 결과를 잘 인식합니다. swift test 명령은 JUnit 호환 출력도 지원하고요.

# GitHub Actions 예제
- name: Run tests
  run: swift test --parallel --xunit-output test-results.xml

- name: Upload results
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: test-results
    path: test-results.xml

자주 묻는 질문 (FAQ)

Swift Testing은 XCTest를 완전히 대체하나요?

단위 테스트와 통합 테스트는 사실상 거의 모두 대체 가능합니다. 다만 2026년 5월 기준으로 UI 테스트(XCUIApplication)와 성능 측정(measure)은 아직 XCTest를 써야 해요. Apple도 두 프레임워크를 함께 쓰는 시나리오를 공식적으로 지원합니다.

Swift Testing은 어떤 Xcode 버전부터 사용할 수 있나요?

Xcode 16부터 기본 제공되고, Xcode 26에서는 태그 시스템 강화, 더 빠른 병렬 실행, 향상된 진단 메시지 같은 개선이 들어갔습니다. Swift Package Manager에서는 Swift 6.0 이상에서 별도 설정 없이 바로 쓸 수 있고요.

@Suite 안에서 setUp/tearDown은 어떻게 처리하나요?

init()이 setUp 역할을, deinit이 tearDown 역할을 합니다. 비동기 작업이 필요하다면 init() async throws로 선언하면 끝이에요. 한 가지 주의할 점은 actor 타입의 스위트는 deinit을 사용할 수 없어서, 명시적인 정리 메서드를 따로 호출해야 한다는 겁니다.

병렬 실행이 기본인데 테스트 격리는 안전한가요?

각 테스트마다 스위트 인스턴스가 새로 생성되니까 인스턴스 상태는 안전합니다. 하지만 전역 상태, 파일 시스템, 데이터베이스처럼 공유 자원을 다루는 경우엔 .serialized 트레잇을 적용하거나, 테스트마다 격리된 임시 디렉터리를 따로 만들어주는 게 좋습니다. (이거 안 해서 한 번 크게 데인 적이 있어요.)

파라미터화 테스트의 인자는 컴파일 타임에 고정되어야 하나요?

아니요, 그럴 필요 없습니다. arguments:에는 모든 Collection을 넘길 수 있어요. JSON 파일을 읽어서 인자를 동적으로 만들거나, stride로 범위를 생성하는 것도 가능합니다. 다만 인자 컬렉션 자체는 테스트가 실행되기 전에 결정돼 있어야 합니다.

마무리

Swift Testing은 단순히 XCTest를 잇는 후속작이 아닙니다. Swift 언어 기능을 제대로 활용해서 새로운 패러다임을 만들어낸 결과물이에요. 매크로 기반의 표현력, 기본 병렬 실행, 파라미터화 테스트, Swift Concurrency 통합 — 이 모든 게 합쳐지니까 결국 더 적은 코드로 훨씬 더 많은 시나리오를 안전하게 검증할 수 있게 됩니다.

다음에 새 기능을 추가할 일이 있다면, 일단 @Test 매크로 하나로 가볍게 시작해보세요. 장담하건대, 한 번 익숙해지고 나면 다시 XCTestCase를 상속하는 자신을 발견하기는 어려울 겁니다.

저자 소개 Priya Raghavan

Priya spent six years at Instacart building the iOS shopper app, where she led the migration from UIKit to SwiftUI across 80+ screens and cut crash-free sessions from 99.2% to 99.87%. Before that, she was a contractor at a Bay Area design studio shipping App Store apps for two Fortune 500 retail clients. She focuses on practical SwiftUI architecture - what holds up when you have 12 engineers committing to the same codebase, not just toy MVVM examples. Her recent work involves The Composable Architecture, Swift concurrency migration audits, and reducing main-thread hangs on older devices like the iPhone XR that enterprise fleets still ship. Priya runs a small consultancy in Oakland and occasionally speaks at try! Swift NYC. She has been writing Swift since the Objective-C bridging days of 2015.