오늘은 WWDC24에서 소개된 Swift Testing에 대해 알아보려고 합니다.
https://developer.apple.com/kr/videos/play/wwdc2024/10179/
Swift Testing은 사용해본 적이 있지만, 그동안 깊이 있게 공부하지 않고 XCTest보다 직관적이고 가독성이 좋다는 정도만 알고 사용했습니다. 이번 기회에 제대로 정리해보기로 했습니다.
기존 XCTest와 비교했을 때 체감한 주요 변화들을 먼저 정리해보면:
@Test 매크로: 함수명이 test로 시작하지 않아도 테스트로 인식됩니다#expect / #require: 표현력 있는 검증 매크로.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의 값을 모두 확인할 수 있습니다.
@Test 속성을 가진 일반 Swift 함수가 테스트가 됩니다. 특징은:
async) 또는 예외 처리(throws) 가능@MainActor)표시 이름 추가
@Test("덧셈 테스트입니다.")
func example() async throws {
let a = 5
let b = 10
#expect(a + b == 16)
}
이제 테스트 이름을 정할 때 골머리 썩지 않아도 됩니다. 예전에는 XCTest에서 이런 식으로 작성해야 했습니다:
func test_덧셈테스트를_합니다() {
// ...
}
이제는 함수명과 별개로 표시 이름을 지정할 수 있고, 테스트 네비게이터에도 그대로 표시되니 훨씬 깔끔합니다.
#expect와 #require#expect 매크로일반 Swift 표현식과 연산자를 그대로 사용할 수 있습니다. 실패 시 소스 코드와 하위 표현식의 값을 자동으로 캡처합니다.
// == 연산자 사용
#expect(video.metadata == expectedMetadata)
// 속성 접근
#expect(collection.isEmpty)
// 메서드 호출
#expect(numbers.contains(7))
특별한 API를 외울 필요 없이 #expect 하나로 거의 모든 검증이 가능합니다.
#require 매크로#require는 테스트의 전제 조건을 검증하는 매크로로, 두 가지 형태로 사용됩니다.
@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)
}
@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번 호출 (성능 저하)✅ #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번만 호출훨씬 간결하고 의도도 명확해집니다. #require는 값이 nil이면 즉시 테스트를 실패시키고, 그렇지 않으면 언래핑된 값을 반환합니다.
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 등 어떤 이슈 트래커든 연결할 수 있어서 팀 협업 시 유용합니다.
// ✅ @available 속성 사용 권장
@Test
@available(macOS 15, *)
func usesNewAPIs() {
// macOS 15 이상에서만 실행
}
// ❌ 런타임에서 #available 사용 지양
@Test func hasRuntimeVersionCheck() {
guard #available(macOS 15, *) else { return }
// ...
}
@available 속성을 사용하면 테스트 라이브러리가 OS 버전 조건을 인식해서 결과에 더 정확하게 반영할 수 있습니다. 런타임에서 guard #available을 사용하면 테스트가 그냥 성공으로 처리되어버려서 의도가 불명확합니다.
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에 태그를 붙이면 그 안의 모든 테스트가 자동으로 해당 태그를 상속받습니다. 이렇게 하면 관련 테스트를 일일이 태그할 필요가 없어서 편합니다.
태그 활용
실전 태그 예시
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 자동 캡처 덕분에 테스트 의도가 명확해집니다아직 XCTest와 혼용하는 프로젝트도 많지만, 새로운 테스트는 Swift Testing으로 작성하면 확실히 생산성이 올라갑니다. 특히 매개변수화된 테스트는 정말 유용해서, 앞으로 적극적으로 활용할 생각입니다 👍