Meet Swift Testing (feat. WWDC 2024)
안녕하세요. 그린입니다 🍏
이번 포스팅에서는 WWDC 2024에서 소개된 Meet Swift Testing 세션을 보면서 Swift Testing에 대해 알아보려합니다 🙋🏻
Meet Swift Testing
우리는 그전까지 테스트하면 XCTest를 사용했습니다.
그런데 이제 Swift 6부터는 Swift Testing이 도입되었어요.
테스팅에서도 이제는 매크로와 같은 최신 기능을 도입하고 동시성과 원활하게 통합해줄 수 있습니다 😃
또 중요한건 크로스 플랫폼을 염두해 개발되었다는 점이에요!
이제 Swift Testing의 비전 자체는 기본 테스트 솔루션이 되는것에 중점을 두고 있습니다.
그럼 본격적으로 알아볼까요?
Building blocks
먼저 Swift Testing의 구성 요소를 살펴보겠습니다.
만약 테스팅 번들 타겟을 만들지 않았다면 File > New > Target을 통해 필요한 테스팅 번들을 추가해줍니다.
특히 Xcode 16부터는 Swift Testing이 테스팅 시스템에서 디폴트 선택입니다.
이제 테스트 코드를 작성해봅니다.
틀부터 먼저 볼께요.
import Testing
@Test func videoMetadata() {
...
}
먼저 테스트 모듈을 가져오고, @Testing 속성을 추가합니다.
해당 함수가 테스트 함수임을 나타내죠.
해당 속성을 추가하면 Xcode가 테스트로 인식하여 테스트 실행 버튼을 표시해줍니다.
테스트 함수는 @Testing 속성을 가진 함수로 타입의 전역 함수 혹은 메서드일 수 있습니다.
그리고 비동기 혹은 에러로 표시되거나 필요한 경우 전역 액터로 격리될 수 있습니다.
이제 내용도 한번 채워볼께요.
import Testing
@testable import DestinationVideo
@Test func videoMetadata() {
let video = Video(fileName: "Green")
let expectedMetadata = Metadata(duration: .seconds(60))
#expect(video.metadata == expectedMetadata)
}
이렇게 먼저 해당 테스트할 모듈을 가져와야합니다.
@testable을 사용해줍니다.
그리고 #expect 매크로를 통해 테스트를 해줍니다.
기대값처럼 예상하는 값의 조건을 넣는것이죠.
일반적인 표현과 연산자들을 허용하고 만약 실패할 경우엔 소스 코드와 하위 표현식의 값을 캡쳐합니다.
만약 실패하면 아래처럼 X 표시가 노출되며 어떤것이 잘못되어 실패했는지 자세한 메시지가 나옵니다.
이걸 show를 눌러 더 확장해보면 더 편하게 볼 수 있어요.
기간과 해상도 값이 다 다른걸 확인할 수 있습니다.
추측해보면, 비디오 타입이 초기화된 후 메타데이터를 로드하지 않은걸로 보입니다.
추측을 기반으로 해당 코드로 가서 누락된 코드를 추가해주면 해결할 수 있겠죠?
앞서 설명했듯 여기서 #expect 매크로는 연산자나 메서드 호출을 포함한 모든 표현식을 전달할 수 있어요.
이런식으로 모두 가능합니다.
때론 결과한것이 실패할 경우 테스트를 곧바로 종료시키고 싶을 수 있습니다.
#require 매크로를 사용하면 됩니다.
expect 매크로와 비슷하지만 필수적이라는것에서 다릅니다.
try #require(session.isValid)
session.invalidate() // 실패 시, 실행 안됨
이렇게 try를 붙여 해당 식이 거짓이면 오류가 발생하고 테스트가 실패해 더 이상 진행하지 않기에 다음 코드도 실행되지 않습니다.
let method = try #require(paymentMethods.first)
#expect(method.isDefault) // 실패 시, 실행 안됨
다른 예시론 이렇게 require 매크로를 사용하면서 옵셔널 값을 안전히 언래핑하고 nil인 경우 테스트를 중지할수도 있습니다.
왜냐면 두번째 expect로 테스트 할땐 method값에 의존적이기에 해당 값이 nil이면 해당 테스트를 진행할 필요가 전혀 없습니다 😃
그 다음으로, 테스트의 목적을 좀 더 명확히 나타낼 수 있는 방법으로 커스텀한 이름을 @Test 속성에 전달할 수 있습니다.
@Test("Check video metadata") func videoMetadata() {
...
}
이렇게 레이블을 붙이듯이 넣으면 됩니다.
그럼 해당 이름이 테스트 네비게이터와 Xcode 상 다른 위치들에서 표시됩니다.
해당 특성으로 테스트에 대한 설명 정보를 추가할 수도 있고, 테스트 실행 시기나 실행 여부를 커스텀화할 수 있습니다.
또 테스트 작동 방식을 수정할 수도 있죠.
다음으론 몇가지 예시를 보여줍니다.
정보 추가 외에도 관련 버그를 참조하거나 커스텀 태그를 추가할 수도 있죠.
그럼 이제 하나의 메서드를 더 추가하고 타입으로 감싸 볼까요?
struct VideoTests {
@Test("Check video metadata") func videoMetadata() {
let video = Video(fileName: "Green")
let expectedMetadata = Meatadata(duration: .seconds(90))
#expect(video.metadata == expectedMetadata)
}
@Test func rating() async throws {
let video = Video(fileName: "Green")
#expect(video.contentRating == "G")
}
}
이렇게 되면 계층 구조가 테스트 네비게이터에 반영되며 타입을 클릭하여 그룹으로 실행할 수 있어요.
테스트를 포함하는 이와 같은 유형을 test suite라고 부릅니다.
Suite는 관련 테스트 기능들을 그룹화하는데 사용합니다.
@Test 함수나 @Suites를 포함하는 모든 타입은 암시적으로 @Suite 자체로 간주되지만 @Suite 속성을 사용해 명시적으로 주석을 달 수 있습니다.
Suite엔 저장된 인스턴스 속성이 있을 수 있습니다.
init과 deinit을 사용해 각 테스트 전후에 논리를 수행할 수 있죠.
또한 포함된 모든 인스턴스 @Test 함수에 대해 별도의 @Suite 인스턴스가 생성됩니다.
이는 의도치 않은 상태 공유를 피하기 위함이죠.
위 코드에서, 비디오 인스턴스를 생성하는것이 두 테스트 함수에 공통적으로 들어가서 반복되고 있습니다.
struct VideoTests {
let video = Video(fileName: "Green")
@Test("Check video metadata") func videoMetadata() {
let expectedMetadata = Meatadata(duration: .seconds(90))
#expect(video.metadata == expectedMetadata)
}
@Test func rating() async throws {
#expect(video.contentRating == "G")
}
}
이렇게 공통 프로퍼티로 빼서 반복을 줄일 수 있죠.
일반적인 전역 변수와 같은 기능이지만 다른점은 테스트에선 다르다는겁니다!
각 테스트 함수는 새 인스턴스에서 호출되기에 각 함수에선 자체 해당 video 인스턴스를 갖게 되기에 상태가 공유되진 않는것이 특성입니다.
정리해보면 우리는 Swift Testing의 구성요소들을 살펴봤습니다.
총 크게 4가지가 있죠.
먼저 Test funcions는 Swift Concurrency와 원활하게 통합됩니다.
Expectations는 async/await도 사용할 수 있으며 모든 내장 언어 연산자를 허용합니다.
Traits도 Expectations와 더불어 Swift 매크로를 활용하고 자세한 실패 결과를 확인하고 테스트별 정보를 코드에서 직접 지정할 수 있죠.
Suites는 상태를 분리하기 위한 구조체 사용을 장려합니다.
이제 Swift Testing의 구성요소에 대해 알아봤으면 더 나아가 이걸 통해 테스트의 몇가지 일반적인 문제에 적용하고 이를 해결하기 위한 워크플로에 대해 살펴보겠습니다.
Common workflows
일반적으로 공통된 워크플로는 위와 같습니다.
1️⃣ 조건을 테스트
2️⃣ 공통점이있는 테스트를 연결
3️⃣ 매번 다른 인수로 테스트를 두번 이상 반복
Tests with conditions
먼저 조건을 가지고 테스트를 합니다.
일부 테스트는 특정 디바이스나 환경과 같은 특정 상황에서만 실행되어야 합니다.
이를 위해 .enabled(if: ...)와 같은 조건 특성을 적용할 수 있죠.
즉, 테스트를 실행하기 전 평가할 조건을 전달하고 조건에 부합하지 않으면 테스트를 건너뜁니다.
@Test(.enabled(if: AppFeatures.isCommentingEnabled))
func videoCommenting() {
...
}
아니면 테스트가 실행되지 않게도 할 수 있습니다.
.disabled(...)를 이용하죠.
@Test(.disabled("Due to a known crash"))
func example() {
...
}
테스트를 비활성화 하는것으로 주석 처리하는것보다 나은 방법이라고 보입니다.
왜 비활성화 했는지도 문자열 인자로 추가할 수 있죠.
때론 테스트가 비활성화되는 이유는 버그 추적 시스템에서 추적되는 문제 때문이기도 합니다.
그럴때는 설명 외에도 URL과 관련된 문제를 참조하기 위해 다른 특성과 함께 .bug(...) 특성을 포함할 수도 있습니다.
@Test(.disabled("Due to a known crash"),
.bug("example.org/bugs/1234", "Program crashes at <symbol>"))
func example() {
...
}
그 후, Xcode 16의 테스트 리포트에서 해당 버그 특성을 확인하고 클릭해 해당 URL을 열 수 있어요.
만약 테스트가 특정 OS 버전에서만 실행해야할 경우 우리가 잘아는 @available을 이용할 수 있습니다.
아니면 #available을 활용해 런타임에 확인할 수도 있지만 전자의 방식이 더 효율적입니다.
// ✅
@Test
@available(macOS 15, *)
func usesNewAPIs() {
...
}
// ❌
@Test func hasRuntimeVersionCheck() {
guard #available(macOS 15, *) else { return }
...
}
Tests with common characteristics
다음으로 서로 공통된 특성을 가진 테스트를 연관시킬 수 있는 방법을 보겠습니다 🙋🏻
Swift Testing은 테스트에 사용자 정의 태그 할당을 지원해줍니다.
테스트 네비게이터에서 태그 모드로 전환해 필터해 볼 수도 있어요.
태그 특성을 추가하는건 간단합니다.
@Test(.tags(.formatting)) func rating() async throws {
...
}
tags를 통해 기존 태그에 추가할 수도 새로 태그를 만들 수도 있죠.
해당 동일한 태그들을 하나의 하위 타입으로 그룹화 할 수도 있습니다.
이렇게 나타낼 수도 있지만, 해당 타입에 태그를 붙여 묶을 수도 있죠.
그럼 테스트 함수에서 태그 지정이 필요없습니다.
특정 기능이나 하위 시스템을 검증하는 모든 테스트에 공통 태그를 적용할 수 있죠.
또한 Swift 테스트를 사용할 때 테스트 계획에 테스트를 포함하거나 제외 시 테스트 이름보다는 태그를 사용하는것이 더 선호됩니다.
.enabled(if: ...)보다 .tags(...)를 사용하는것이 더 적절해요.
다만, 모든 경우에 태그 사용이 적절한건 아닙니다.
런타임 조건을 표현하려는 경우엔 .enabled(if: ...)가 더 적절해요.
태그를 사용하는 방법을 더 깊이 보려면 Go further with Swift Testing 세션을 참고해주세요!
Tests with different arguments
워크플로의 마지막으로 매번 다른 인수를 테스트하는 방법입니다.
만약 이와같이 동일한 테스트가 반복되는데 인수만 다를 수 있죠.
그런데 이건 테스트할것이 늘어나면 동일한 보일러 플레이트 테스트 코드의 증가를 높이고 유지 보수가 힘들어집니다.
또한, 해당 패턴을 사용하면 각 테스트에 고유한 함수 이름을 지정해야 되는데 읽기도 어렵고 테스트중인 비디오 이름과 동기화가 되지 않을 수도 있죠.
그래서 parameterized testing라는 기능을 사용해 이 모든걸 단일 테스트로 작성할 수 있습니다 ☺️
@Test(arguments: [
"Green",
"Blue",
"Red",
])
func mentionedContinentCounts(videoName: String) async throws {
let videoLibrary = try await VideoLibrary()
let video = try #require(await videoLibrary.video(name: videoName))
#expect(!video.mentionedContinents.isEmpty)
#expect(video.mentionedContinents.count <= 3)
}
사용을 위해 먼저 테스트 함수에 매개변수를 추가하고 @Testing에 argument 특성을 부여해줍니다.
그 후, 비디오 이름을 전달된 인자로 바꾸는것이죠.
테스트를 돌리면 테스트 네비게이터에 별도의 테스트처럼 개별적으로 테스트가 표시됩니다.
물론 실패한 테스트만 돌려볼 수도 있구요ㅎㅎ
Parameterized testing을 통해 결과에서 각 개별 인자의 세부 정보를 명확히 볼 수 있습니다.
세분화된 디버깅을 위해 인자를 독립적으로 다시 실행할 수도 있죠.
그리고 각 인자를 병렬로 실행하면 더 효율적으로 실행될 수 있기에 더 빠른 결과를 도출해냅니다.
argument를 사용하는것말고 테스트 코드 내부에서 배열로 담고 반복문으로 사용할 수도 있지만 위와 같은 장점들이 배제되기에 argument를 사용하는것이 적절합니다.
이제는 Swift Testing과 XCTest가 어떻게 연관되어 있는지 살펴보겠습니다 🙋🏻
Swift Testing and XCTest
기존에 XCTest로 작성이 되어 있다면, 새로운 이 Swift Test 방식으로 마이그레이션을 생각할 수도 있겠죠?
Swift Test는 XCTest와 일부는 유사하지만 알아야 할 몇가지 중요한 차이가 존재합니다.
요 핵심 3가지를 가지고 비교해보겠습니다.
XCTest의 테스트는 이름이 모두 test로 시작하죠.
그러나 Swift Testing은 명시적으로 나타내기 위해 @Test 속성을 사용하기에 모호하지 않는 장점이 있습니다.
또한, 더 많은 종류의 함수를 지원하고 한 타입의 인스턴스 메서드를 사용할 수 있고 정적 혹은 전역 함수도 사용할 수 있습니다.
그리고, XCTest와 달리 정보를 지정하는 특성을 지원해줍니다.
또한, Swift Testing은 병렬화에 대해 다른 접근 방식을 가집니다.
Swift Concurrency를 사용해 프로세스 내에서 실행되고 아이폰 및 애플워치와 같은 물리적 디바이스를 지원합니다.
XCTest에선 assertions을 통해 기대값을 도출하는 기능을 사용하죠.
그런데 종류가 너무 많아요 🥲
Swift Testing은 두가지의 기본 매크로만 알면 되죠!
즉, 연산자를 자유롭게 사용할 수 있기에 가능하다고 보입니다.
테스트가 발생한 후 실패 시 테스트를 중단하는 방법도 다르게 처리됩니다.
XCTest에선 continueAfterFailure 값을 false로 할당한 다음 실패하는 후속 assertion으로 인해 테스트가 중단되죠.
그러나 Swift Testing에선 try #require면 됩니다.
Suite 타입의 경우엔 XCTest는 클래스만 지원하고 XCTestCase에서 상속해야 합니다.
Swift Testing에선 모든 타입을 사용할 수 있고 구조체에선 의도치 않은 상태 공유로 인한 버그를 방지해줄 수 있습니다.
또한, @Suite 속성을 명시적으로 표시하며 해당 명시된 하위에 모든 유형에 대해서는 암시적으로 따릅니다.
테스트 실행 전이나 후의 로직을 위해 XCTest는 setUp이나 tearDown과 같은 여러 메서드를 사용해야 했지만, Swift Testing은 init과 deinit을 사용하면 되죠.
다만, deinit은 참조 타입에서만 사용할 수 있기에 Suite가 구조체 대신 참조 타입을 사용하는 이유기도 합니다.
마지막으로, Swift Testing에서는 중첩 타입을 통해 테스트를 하위 그룹으로 그룹화할 수 있죠.
둘다 모두 테스트는 단일 대상에 공존할 수 있기에 마이그레이션을 한다면 점진적으로 할 수 있습니다 😄
그렇기에 비슷한 구조를 가진 여러 XCTest 메서드를 마이그레이션 할 때 앞서 설명한것처럼 Parameterized testing으로 통합해보면 좋을것 같아요.
테스트 메서드가 하나만 있는 경우엔 전역 @Test 함수로 마이그레이션을 하면 쉽게 해볼 수 있습니다.
가장 크게 다가오는점은 더 이상 테스트 함수의 이름을 test로 시작하지 않아도 되는것이죠 👍
다만, XCUIApplication과 같은 UI 자동화 API를 사용하거나 XCTMetric과 같은 성능 테스트 API를 사용하는 테스트에서는 Swift Test에서는 지원하지 않기에 XCTest를 계속 사용해야 합니다 🚨
또한, Objective-C로만 작성할 수 있는 테스트에서도 XCTest를 사용해야 하구요!
이건 모두 기본적으로 안하겠지만, XCTest에서 #expect 매크로를 사용하거나 반대로 Swift Test에서 assertion 함수를 호출하면 안됩니다 ❌
마이그레이션에 대해 좀 더 파보려면 Migrating a test from XCTest 문서를 보면 좋습니다!
assertion을 변환하고 컨커런시 족너을 처리하는 방법 등에 대한 더 다양한 세부 정보가 포함되어 있어요.
Open source
Swift Testing은 계속 발전하고 있습니다.
오픈 소스이기에 언제든 누구나 기여할 수 있죠.
원래 apple 레포에 있었는데 최근 발표된 swiftlang으로 들어왔네요ㅎㅎ
Swift Testing은 동시성을 지원하는 모든 Apple의 OS 및 Linux, Windows에서도 동작하니 이런점에서도 XCTest에 비해 정말 크게 개선되었다고 생각됩니다.
마무리
테스트 코드는 늘 복잡하고 어렵다고 생각했는데, 훨씬 더 편리하게 도전해볼 수 있다고 생각되는 중요한 세션이였습니다 😁
이참에 도전?