Xcode 16.0부터 Swift Testing
이라는 새로운 오픈소스 패키지를 통해 쉽고 빠르게 테스트를 할 수 있게 되었다.
테스트 타겟을 새로 추가할 때 Swift Testing
을 선택할 수 있는 것도 볼 수 있다.
우선 비슷한 역할을 수행하는 개념끼리 번호를 매겨 기존 XCTest
와 새로운 Swift Testing
을 비교하며 기본적인 내용들을 알아보자.
🧐
XCTest
와Swift Testing
동시 사용?한 파일 내에서
XCTest
와Swift Testing
을 동시에 사용할 수 있다.
예를 들어,XCUIApplication
,XCTMetric
을 사용하는 테스트의 경우Swift Testing
에서는 지원하지 않기 때문에 이런 경우에는XCTest
를 사용해야만 한다.
두 패키지를 동시에 사용할 때 주의할 점으로Swift Testing
코드에서XCTAssert(...)
를 사용하거나, 그 반대로XCTest
코드에서#expect(...)
를 사용해서는 안된다.
XCTest
1️⃣ import XCTest
2️⃣ XCTestCase
를 상속하는 class 내에서 테스트 함수 작성
3️⃣ setUp(), tearDown() 메소드로 테스트 전후 로직 실행
4️⃣ 테스트 함수 작성 시 test를 함수 이름의 접두사로 사용
5️⃣ XCTAssert(...)
메소드로 테스트 결과 예측
import XCTest // 1️⃣
final class MyTest: XCTestCase { // 2️⃣ 'XCTestCase'를 상속하는 클래스
override func setUpWithError() throws { ... } // 3️⃣
override func tearDownWithError() throws { ... } // 3️⃣
func testSomething() async throws { // 4️⃣ 'test' 접두사
XCTAssert(...) // 5️⃣
}
}
Swift Testing
1️⃣ import Testing
2️⃣ struct, class 내에서 뿐만 아니라 글로벌 함수, 타입의 static 또는 class함수 등의 형태로 테스트 함수 작성 가능 (단, 동시성 안전성을 위해 class보다 struct 또는 actor를 사용하는 편이 좋다)
3️⃣ init(), deinit 으로 테스트 전후 로직 실행 (단, deinit을 하기 위해서는 class나 actor를 사용)
4️⃣ 테스트 함수 이름에 제한이 없고 @Test
어트리뷰트를 사용하여 테스트 함수 작성
5️⃣ #expect
와 같은 매크로를 사용하여 테스트 결과 예측
import Testing // 1️⃣
@Test func globalTest() async throws { ... } // 2️⃣, 4️⃣ 'class' 또는 'struct'등 타입 내부가 아닌 전역 함수로도 가능
struct MyTest { // 2️⃣ 굳이 'class'가 아니어도 됨
init() async throws { ... } // 3️⃣
@Test func myTest() { // 4️⃣
#expect(...) // 5️⃣
}
}
final class FooTest { // 3️⃣ deinit을 하기 위해서는 'class' 또는 'actor'
init() async throws { ... }
deinit { ... }
}
💡
Testing
모듈은 테스트 타겟에서만 사용!
Testing
모듈을 import하는 것은 테스트 타겟에서만 해야한다. 어플리케이션 타겟이나 바이너리 타겟에서Testing
모듈을 import 하는 것은 지원되지 않거나 권유되지 않는다. 테스트 함수의 코드들이 release를 위한 빌드 시 없어지지 않기 때문에 나중에 프로덕트를 통해 로직 등의 코드레벨이 보여질 수 있기 때문이다.
전체적인 개념은 알아봤으니 Swift Testing
에서 사용하는 용어들과 함께 좀 더 자세히 알아보자.
테스트 함수는 @Test
어노테이션을 통해 간편하게 선언할 수 있다고 했다.
테스트 함수를 선언할 때, @available
, @MainActor
와 같은 어노테이션을 사용하여 함수를 실행시킬지, 격리시킬지 등에 대한 설정을 할 수 있다.
@available(iOS 18.0, *)
@available(swift, introduced: 8.0, message: "Requires Swift
8.0 features to run")
@MainActor
@Test
func myTest() async throws { ... }
참고로 테스트 함수의 실행 조건을 설정하기 위해서는 다음과 같이 함수 내에서 #available
매크로를 이용하지 말고, @available
어노테이션을 함수 선언과 함께 작성하여 보다 정확한 코드를 작성하는 편이 좋다.
import Testing
@Test
func myTestFunction() async throws {
guard #avaiable(macOS 15.0, *) else { return } // ❌ 런타임에 체크하지 말기
...
}
@available(macOS 15.0, *) // ✅ 대신 테스트 라이브러리가 조건을 검사해서 보다 정확하게 하기
@Test
func myTestFunction() async throws { ... }
🧵
Swift Testing
에서는 기본적으로 테스트 함수가 독립, 병렬적으로 실행!
`XCTest`에서는 기본적으로 테스트 함수가 `MainActor` 에 격리되어 순차적으로 실행된다. `Swift Testing` 에서는 테스트 함수가 임의의 task로 병렬적으로 실행되기 때문에 `@MainActor` 또는 `MainActor.run(resultType: body: )`와 같은 메소드를 적절히 사용해야 한다.
여러 케이스에 대해 테스트를 하기 위해서 다음과 같이 반복문을 사용할 수 있긴 하다.
import Testing
@Test func foo() {
let cases = ["A", "B", "C", "D"]
for case in cases {
#expect(...)
}
}
그러나 반복문으로 케이스를 돌리게 되면 자연스럽게 순차적으로 테스트를 할 수 밖에 없고, 실패를 한다면 어느 케이스에서 실패했는지 알 수 없고 그 뒤의 케이스에 대해서는 테스트조차 할 수가 없어 원하는 테스트 커버리지를 얻을 수 없다.
따라서 Swift Testing
에서는 매개변수를 가지는 테스트 함수를 지원한다.
import Testing
@Test(arguments: ["A", "B", "C", "D"])
func foo(alphabet: String) {
#expect(...)
}
매개변수를 가지는 테스트 함수는 각 변수에 대해 독립/병렬로 동작하게 된다.
만약 각 인자를 순차적으로 테스트하고 싶다면 아래에서 살펴볼 .serialized
를 사용하면 된다.
또한 테스트 결과 리포트도 각 변수에 대해서 자세히 볼 수 있고, 해당 인자에 대해서만 테스트를 다시 진행할 수도 있다.
2개의 매개변수 조합에 대해서도 테스트를 진행할 수 있다.
enum Alphabet: CaseIterable {
case a
case b
case c
}
enum 한글: CaseIterable {
case 가
case 나
case 다
case 라
}
@Test(arguments: Alphabet.allCases, 한글.allCases)
func myTest(alphabet: Alphabet, hangul: 한글) { ... }
이렇게 2개의 매개변수를 사용하게 되면 가능한 모든 매개변수의 조합(카테시안 곱)에 대해 테스트를 진행하게 된다. 따라서 위와 같은 상황에서는 3 x 4 = 12 번의 테스트를 진행하게 된다.
만약 모든 조합이 아닌 2개의 매개변수 쌍으로 테스트를 진행하고 싶다면 zip()
메소드를 사용하면 된다.
@Test(arguments: zip(Alphabet.allCases, 한글.allCases))
func myTest(alphabet: Alphabet, hangul: 한글) { ... }
zip()
을 이용하면 12번의 테스트가 아닌
(a, 가), (b, 나), (c, 다)
3개의 케이스에 대해 테스트를 진행하게 된다.
테스트 함수에서 성공적인 시나리오에 대해서 테스트를 할 수도 있지만, 실패하여 에러가 나는 상황도 테스트 할 수 있다.
이럴 때 #expect(throws: )
를 사용하여 에러가 정상적으로 나오는지 테스트 가능하다.
@Test func errorTest() {
#expect(throws: (any Error).self) { ... } // 모든 에러
#expect(throws: MyError.self) { ... } // 특정 에러 타입
#expect(throws: MyError.foo) { ... } // 특정 에러
}
위와 같이 에러 타입 및 값을 설정하여 에러 테스트를 진행할 수 있고,
테스트 시 함수에서 설정한 에러를 던지면 테스트 통과, 그렇지 않으면 실패하게 된다.
Xcode 16부터 제공하는 스니펫을 통해 쉽게 테스트 함수 블럭을 생성할 수도 있다.
기본적으로 제공하는 스니펫은 다음과 같이 사용할 수 있다.
Suite(테스트 모음)는 비슷한 성격의 테스트 함수 또는 테스트 모음들을 묶어놓은 단위를 말한다.
쉽게 말하면 테스트 함수를 가지고 있는 struct, class와 같은 것으로 생각하면 된다.
테스트 모음은 내부적으로 테스트 함수를 가지거나 다른 테스트 모음을 가지면 자동으로 테스트 모음이 되지만, @Suite
매크로를 사용해서 명시적으로도 선언하여 뒤에서 살펴 볼 traits들을 설정할 수 있다.
또 테스트 모음은 인스턴스 프로퍼티를 가질 수 있고, 내부적으로 또 다른 테스트 모음을 가질 수도 있다.
import Testing
@Suite("Custom Display Name")
struct OuterSuite {
var number: Int
struct InnerSuite {
@Test func foo() { ... }
}
@Test func foo() { ... }
@Test func bar() { ... }
}
@available
불가능테스트 함수를 선언할 때에는 @available
을 사용해 런타임에 실행 여부를 설정할 수 있었던 반면, 테스트 모음 타입은 항상 사용 가능해야 하기 때문에 선언할 때 @available
을 사용할 수 없다.
import Testing
@avaiable(iOS 15.0, *) // ❗️ suite에는 @available 사용 불가
@Suite
struct MyTest { ... }
테스트 모음에서 함수가 실행되면, XCTest
와 마찬가지로 타입 함수 (class or static 함수)가 아닌 인스턴스 함수라 할지라도 함수 하나 당 하나의 인스턴스가 생성되어 독립적으로 테스트 함수가 실행된다.
또, 테스트 모음끼리 포함관계를 가져도 모두 독립적으로 실행된다.
즉, 아래 두 코드는 같은 코드이다.
import Testing
struct MyTest {
@Test func myTest() { ... }
}
import Testing
struct MyTest {
func myTest() { ... }
@Test static func staticMyTest() {
let instance = MyTest()
instance.myTest()
}
}
추가로, 테스트 함수와 마찬가지로 Xcode에서 제공하는 스니펫을 통해 쉽게 테스트 모음 블럭을 생성할 수 있다.
테스트 함수나 테스트 모음을 선언할 때, Traits
를 사용하여 description을 작성하거나 테스트 실행 여부를 설정하는 등의 여러가지 속성을 설정할 수 있다.
앞에서 @available
어트리뷰트를 사용하여 런타임에 OS 환경에 따라 테스트 함수가 실행될지를 설정할 수 있었다.
다른 조건들을 통해 테스트 함수를 실행할지 결정하고 싶으면 .enable()
, .disable()
를 사용할 수 있다.
@Test(.enabled(if: /* some condition */))
func myTest() async throws { ... }
@Test(.disabled()) func disabledTest() async throws { ... }
@Test(.disabled("This test is disabled")) func disabledTest() { ... }
또한 두 개를 동시에 사용할 수도 있고, 여러 조건을 사용할 수도 있다.
실행 조건이 여러 가지인 경우 모든 조건이 충족되어야 테스트 함수가 실행된다.
또, 조건이 비교적 복잡하다면 가독성을 위해 메소드로 분리하는 것도 좋다.
@Test(.enabled(if: /* some condition */),
.disabled("..."))
func myTest() async throws { ... }
func complexCondition() -> Bool { ... }
@Test(.enabled(if: /* some condition */),
.enabled(if: complexCondition()))
func complexConditionTest() async throws { ... }
.timeLimit()
을 통해 테스트 함수의 제한 시간을 정할 수도 있다.
@Test(.timeLimit(.minutes(60))
func limitTimeTest() { ... }
만약 설정한 시간보다 오래 실행되면 테스트 함수의 task는 취소되고 테스트는 실패하며 시간 초과 리포트를 확인할 수 있다.
테스트 함수가 아닌 테스트 모음에 timeLimit()
을 사용하면 해당 모음 내에 있는 함수들, 모음들이 모두 제한된다.
또, 매개변수를 가진 테스트 함수에 실행 시간을 제한하면, 각 매개변수에 대한 테스트에 독립적으로 시간이 제한되기 때문에 만약 A 매개변수에 대한 테스트는 시간 초과가 되었다 하더라도 B 매개변수는 이에 영향을 받지 않는다.
기본적으로 Swift Testing
에서는 각 테스트 함수가 독립적으로 병렬로 실행된다.
만약 테스트를 직렬로 실행하고 싶다면 .serialized
를 사용하면 된다.
매개변수가 없는 테스트 함수에 .serialized
를 사용하면 해당 함수 하나만 실행되는 것이기 때문에 아무런 영향이 없다.
다만, 테스트 모음에 .serialized
를 사용하면 테스트 모음의 테스트 함수, 테스트 모음들이 순차적으로 직렬화되어 실행된다.
또한 매개변수가 있는 테스트 함수에서도 각 매개변수에 대해서 병렬적으로 실행되지 않고, 직렬화되어 테스트를 실행하게 된다.
@Test(.seialized) // 아무런 영향 없음
func myTest() { ... }
@Suite(.serialized) struct MyTests {
@Test(.serialized, arguments: Color.allCases)
func foo(color: Color) {
// 매개변수를 가진 테스트 함수는 직렬화됨
}
@Test func bar() async throws {
// foo 테스트가 종료되면 이 테스트가 실행됨
}
}
테스트 함수가 아주 많아지면 테스트끼리 어느 정도 관리가 필요해진다.
물론 비슷한 성격의 테스트는 테스트 모음으로 묶어서 작성을 하겠지만, 테스트 모음은 코드 레벨에서 하나의 파일 내의 비슷한 테스트를 묶을 수 있다는 한계가 있다.
태그는 문맥적으로 테스트가 어떤 의미를 가지는지 설정할 수 있고, 이는 파일 내의 테스트 함수와 테스트 모음들 뿐만 아니라 다른 파일에서도, 심지어 다른 테스트 타겟에서도 사용할 수 있다.
태그를 사용하면 네비게이터 또는 테스트 결과 리포트에서 태그 별로 분류해서 확인할 수 있고, 태그 별로 분리하여 테스트 실행도 가능해진다.
태그를 선언할 때 Tag
의 extension
이 아니거나 직접 선언하지 않으면 에러가 발생하므로 다음과 같이 반드시 Tag
의 extension
내에서 직접 선언해야 한다.
extension Tag {
@Tag static var customTag: Tag // ✅
static var myCustomTag: Self { customTag } // ❌
}
@Tag let customTag: Self // ❌
struct Foo {
@Tag var customTag: Self // ❌
}
@Test(.tag(.customTag))
func taggedTest() async throws { ... }
만약 같은 이름의 태그가 다른 파일이나 모듈에 존재하더라도, Testing
라이브러리는 이들을 하나의 태그로 간주하게 된다.
따라서 다른 곳에서 선언된 태그와 비슷한 문맥이지만 구분되어야 한다면 다음과 같이 reverse_DNS 네이밍을 사용하는 것이 좋다.
extension Tag {
enum com_example_foo {}
}
extension Tag.com_example_foo {
@Tag static var bar: Tag
}
@Test(.tags(.com_example_foo.bar))
func myTest() { ... }
CustomTestStringConvertible
매개변수로 사용할 데이터가 많은 프로퍼티를 가지면 테스트를 할 때 유효한 프로퍼티가 아닌 다른 내용들 때문에 결과를 한 눈에 확인하기 어려울 수 있다.
이럴 때 데이터가 CustomTestStringConvertible
프로토콜을 채택하여 testDescription
을 구현하면 테스트의 리포트를 보다 간결하게 볼 수 있도록 설정할 수 있다.
아래와 같이 Icecream
데이터는 여러 프로퍼티를 가지고, 테스트 코드에서는 그 중 Flavor
와 관련한 테스트를 진행한다고 하자.
enum Flavor {
case apple
case banana
}
enum Container {
case cone
case cup
}
enum Topping {
case sprinkles
case whippedCream
}
struct Icecream {
let flavor: Flavor
let container: Container
let toppings: [Topping]
}
@Test(arguments: [
Icecream(flavor: .apple, container: .cone, toppings: [.sprinkles]),
Icecream(flavor: .banana, container: .cone, toppings: [.sprinkles, .whippedCream]),
Icecream(flavor: .banana, container: .cup, toppings: [.whippedCream])
])
func myTest(icecream: Icecream) {
#expect(icecream.flavor == .apple)
}
해당 테스트의 리포트를 보면 Flavor
외 다른 프로퍼티들 모두 보여주기 때문에 결과를 확인하기 어렵다.
이럴 때 CustomTestStringConvertible
프로토콜을 채택하여 testDescription
을 구현하면
struct Icecream: CustomTestStringConvertible {
let flavor: Flavor
let container: Container
let toppings: [Topping]
var testDescription: String {
"This ice cream tastes like \(flavor)"
}
}
이렇게 리포트를 쉽게 확인할 수 있다.
Apple Developer Documentation - Swift Testing
WWDC24 - Meet Swift Testing
WWDC24 - Go further with Swift Testing
애플도 K-분식을 좋아하나보군요