Swift Testing Framework 면접 2026: #expect와 #require 매크로 vs XCTest

iOS 면접을 위한 새로운 Swift Testing Framework를 마스터합니다: #expect와 #require 매크로, XCTest 마이그레이션, 고급 패턴 및 흔한 함정.

iOS 면접을 위한 #expect와 #require 매크로가 포함된 Swift Testing Framework

WWDC 2024에서 발표되고 Swift 6 및 Xcode 16과 함께 출시된 Swift Testing은 Swift에서 테스트가 작동하는 방식을 완전히 재구상한 것입니다. 이 프레임워크는 XCTest의 40개가 넘는 어서션을 단 두 개의 매크로 #expect#require로 대체합니다. 면접관들은 이제 iOS 기술 면접에서 이 지식을 정기적으로 평가합니다.

가이드 형식

각 섹션은 자세한 답변과 작동하는 코드와 함께 기술 면접 질문을 반영합니다. 진행은 기본 개념에서 고급 패턴으로 이동합니다.

Swift Testing 기초

질문 1: Swift Testing과 XCTest의 주요 차이점은 무엇입니까?

Swift Testing은 XCTest와 비교하여 다섯 가지 근본적인 변화를 가져옵니다:

  1. 선언적 구문: test 접두사 대신 @Test 속성
  2. 두 개의 범용 매크로: #expect#require가 40개 이상의 어서션을 대체합니다
  3. 기본 병렬 실행: 모든 테스트가 동시에 실행됩니다
  4. 네이티브 async 지원: Swift Concurrency와의 완전한 통합
  5. 크로스 플랫폼: Apple 플랫폼, Linux 및 Windows에서 작동합니다
TestComparison.swiftswift
import XCTest
import Testing

// ❌ Legacy XCTest pattern
class UserServiceXCTests: XCTestCase {
    // Must start with "test"
    func testUserCreation() {
        let user = User(name: "Alice", age: 25)

        // Multiple verbose assertions
        XCTAssertNotNil(user)
        XCTAssertEqual(user.name, "Alice")
        XCTAssertGreaterThan(user.age, 18)
        XCTAssertTrue(user.isValid)
    }
}

// ✅ Modern Swift Testing pattern
@Test("User creation with valid data")
func userCreation() {
    let user = User(name: "Alice", age: 25)

    // Single macro for all verifications
    #expect(user.name == "Alice")
    #expect(user.age > 18)
    #expect(user.isValid)
}

핵심적인 차이는 표현력에 있습니다: Swift Testing은 특수화된 어서션 대신 표준 Swift 표현식을 사용하여 테스트를 더 읽기 쉽게 만들고 오류 메시지를 더 풍부하게 만듭니다.

질문 2: #expect 매크로는 어떻게 작동합니까?

#expect 매크로는 부울 표현식이 참인지 검증합니다. 자세한 오류 메시지를 제공하기 위해 평가된 값을 자동으로 캡처합니다. XCTAssert와 달리 네이티브 Swift 구문을 사용합니다.

ExpectMacroBasics.swiftswift
import Testing

@Test func basicExpectations() {
    let numbers = [1, 2, 3, 4, 5]
    let user = User(name: "Bob", email: "bob@example.com")

    // Simple comparisons - standard Swift expression
    #expect(numbers.count == 5)
    #expect(user.name == "Bob")

    // Comparisons with operators
    #expect(numbers.first! < numbers.last!)
    #expect(user.email.contains("@"))

    // Nil checking
    #expect(numbers.first != nil)

    // Collection verification
    #expect(numbers.contains(3))
    #expect(!numbers.isEmpty)
}

@Test func expectWithCustomMessage() {
    let balance = 150.0
    let withdrawAmount = 200.0

    // Custom message to clarify intent
    #expect(
        balance >= withdrawAmount,
        "Insufficient funds: balance \(balance) < withdrawal \(withdrawAmount)"
    )
}

#expect가 실패해도 테스트는 계속 실행됩니다. 이 특성은 한 번의 실행에서 여러 실패를 수집할 수 있게 하여 진단을 용이하게 만듭니다.

질문 3: #expect와 #require의 차이점은 무엇입니까?

근본적인 차이는 실패 후 동작에 관한 것입니다:

  • #expect: 실패를 기록하고 실행을 계속합니다
  • #require: 실패를 기록하고 테스트를 즉시 중지합니다

#require는 오류를 던질 수 있기 때문에 항상 try와 함께 사용해야 합니다.

ExpectVsRequire.swiftswift
import Testing

struct ApiResponse {
    let data: Data?
    let items: [Item]?
}

@Test func demonstrateExpectContinues() {
    let values = [1, 2, 3]

    // First #expect fails but test continues
    #expect(values.count == 10)  // ❌ Failure recorded

    // These verifications still execute
    #expect(values.first == 1)   // ✅ Success
    #expect(values.last == 3)    // ✅ Success

    // Result: 1 failure, 2 successes in the same test
}

@Test func demonstrateRequireStops() throws {
    let response = ApiResponse(data: nil, items: nil)

    // #require stops immediately if condition fails
    let data = try #require(response.data)  // ❌ Failure and STOP

    // This code NEVER executes if data is nil
    let json = try JSONDecoder().decode(User.self, from: data)
    #expect(json.name == "Alice")
}

실제로 #require는 옵셔널의 안전한 언래핑을 위해 XCTUnwrap을 완벽하게 대체합니다.

황금률

후속 단계가 결과에 의존할 때 (언래핑, 전제 조건) #require를 사용하십시오. 테스트의 나머지 부분을 차단하지 않고 실패할 수 있는 독립적인 검증에는 #expect를 사용하십시오.

#require를 사용한 고급 패턴

질문 4: 옵셔널 언래핑에 #require를 어떻게 사용합니까?

#require는 옵셔널 언래핑에서 빛을 발합니다. 존재하면 옵셔널이 아닌 값을 반환하고, nil이면 즉시 테스트를 실패시킵니다.

RequireUnwrapping.swiftswift
import Testing

struct UserProfile {
    let id: Int
    let name: String
    let settings: Settings?
}

struct Settings {
    let theme: String
    let notifications: Bool
}

@Test func unwrapOptionalChain() throws {
    let profile = UserProfile(
        id: 1,
        name: "Alice",
        settings: Settings(theme: "dark", notifications: true)
    )

    // Safe unwrap - stops if nil
    let settings = try #require(profile.settings)

    // Now settings is no longer optional
    #expect(settings.theme == "dark")
    #expect(settings.notifications == true)
}

@Test func unwrapArrayElement() throws {
    let users = ["Alice", "Bob", "Charlie"]

    // Unwrap first element
    let firstUser = try #require(users.first)
    #expect(firstUser == "Alice")

    // Unwrap with safe index
    let secondUser = try #require(users[safe: 1])
    #expect(secondUser == "Bob")
}

@Test func unwrapDictionaryValue() throws {
    let config: [String: Any] = [
        "apiUrl": "https://api.example.com",
        "timeout": 30,
        "retryCount": 3
    ]

    // Unwrap and cast in a single operation
    let apiUrl = try #require(config["apiUrl"] as? String)
    let timeout = try #require(config["timeout"] as? Int)

    #expect(apiUrl.contains("https"))
    #expect(timeout > 0)
}

이 접근 방식은 guard let 피라미드를 제거하고 테스트 코드를 선형적이고 읽기 쉽게 만듭니다.

질문 5: 함수가 오류를 던지는 것을 어떻게 테스트합니까?

Swift Testing은 함수가 특정 오류를 던지는 것을 검증하기 위해 #expect(throws:)를 제공합니다.

ErrorTesting.swiftswift
import Testing

enum ValidationError: Error, Equatable {
    case emptyName
    case invalidEmail
    case underAge(minimum: Int)
}

struct Validator {
    static func validateUser(name: String, email: String, age: Int) throws {
        guard !name.isEmpty else {
            throw ValidationError.emptyName
        }
        guard email.contains("@") else {
            throw ValidationError.invalidEmail
        }
        guard age >= 18 else {
            throw ValidationError.underAge(minimum: 18)
        }
    }
}

@Test func testThrowsSpecificError() {
    // Verify a specific error is thrown
    #expect(throws: ValidationError.emptyName) {
        try Validator.validateUser(name: "", email: "test@mail.com", age: 25)
    }

    #expect(throws: ValidationError.invalidEmail) {
        try Validator.validateUser(name: "Alice", email: "invalid", age: 25)
    }
}

@Test func testThrowsErrorType() {
    // Verify error type without specific value
    #expect(throws: ValidationError.self) {
        try Validator.validateUser(name: "Bob", email: "bob@mail.com", age: 15)
    }
}

@Test func testThrowsWithInspection() throws {
    // Capture error for detailed inspection
    let error = try #require(
        throws: ValidationError.self
    ) {
        try Validator.validateUser(name: "Charlie", email: "charlie@mail.com", age: 16)
    }

    // Verify error details
    if case .underAge(let minimum) = error {
        #expect(minimum == 18)
    }
}

이 구문은 XCTAssertThrowsError를 더 명확하고 타입 안전한 API로 대체합니다.

iOS 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

@Test와 @Suite를 사용한 테스트 구성

질문 6: @Suite로 테스트를 어떻게 구성합니까?

@Suite는 관련 테스트를 논리적으로 그룹화합니다. XCTestCase와 달리 클래스 상속을 요구하지 않습니다.

TestSuiteOrganization.swiftswift
import Testing

// Suite for authentication tests
@Suite("Authentication Tests")
struct AuthenticationTests {

    // Shared property for all tests in the suite
    let authService = AuthService()

    @Test("Login with valid credentials succeeds")
    func loginWithValidCredentials() async throws {
        let result = try await authService.login(
            email: "user@example.com",
            password: "validPass123"
        )

        #expect(result.isSuccess)
        let token = try #require(result.token)
        #expect(!token.isEmpty)
    }

    @Test("Login with invalid password fails")
    func loginWithInvalidPassword() async {
        let result = await authService.login(
            email: "user@example.com",
            password: "wrong"
        )

        #expect(!result.isSuccess)
        #expect(result.error == .invalidCredentials)
    }
}

// Nested suites for hierarchical organization
@Suite("User Management")
struct UserManagementTests {

    @Suite("Creation")
    struct CreationTests {
        @Test func createUserWithValidData() {
            // Creation test
        }

        @Test func createUserWithDuplicateEmail() {
            // Duplicate error test
        }
    }

    @Suite("Deletion")
    struct DeletionTests {
        @Test func deleteExistingUser() {
            // Deletion test
        }

        @Test func deleteNonExistentUser() {
            // Error test
        }
    }
}

스위트는 테스트 하위 집합을 실행하고 보고서를 읽기 쉬운 방식으로 구성할 수 있게 합니다.

질문 7: 트레이트를 사용해 테스트를 어떻게 구성합니까?

트레이트는 테스트 동작을 수정합니다: 실행 조건, 태그, 타임아웃 등.

TestTraits.swiftswift
import Testing

@Suite("API Integration Tests")
struct APITests {

    // Temporarily disabled test
    @Test(.disabled("Backend under maintenance"))
    func fetchUserProfile() async {
        // Does not execute
    }

    // Conditional test based on platform
    @Test
    @available(iOS 17, *)
    func useNewAPIFeature() {
        // Executes only on iOS 17+
    }

    // Test with tags for filtering
    @Test(.tags(.critical, .network))
    func criticalNetworkOperation() async throws {
        // Tagged test for filtering
    }

    // Test with custom timeout
    @Test(.timeLimit(.minutes(2)))
    func longRunningOperation() async {
        // Must complete within 2 minutes
    }

    // Trait combination
    @Test(
        "Complex data sync",
        .tags(.slow),
        .timeLimit(.minutes(5)),
        .bug("JIRA-1234", "Flaky on CI")
    )
    func complexDataSync() async throws {
        // Test documented with known bug
    }
}

// Custom tag definitions
extension Tag {
    @Tag static var critical: Self
    @Tag static var network: Self
    @Tag static var slow: Self
}

트레이트는 테스트를 자체 문서화하고 명령줄을 통한 선택적 실행을 가능하게 합니다.

매개변수화된 테스트

질문 8: 매개변수화된 테스트를 어떻게 만듭니까?

Swift Testing은 매개변수를 통해 동일한 테스트를 다른 입력으로 실행할 수 있게 합니다.

ParameterizedTests.swiftswift
import Testing

struct EmailValidator {
    static func isValid(_ email: String) -> Bool {
        let pattern = #"^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"#
        return email.range(of: pattern, options: .regularExpression) != nil
    }
}

// Parameterized test with a collection
@Test(arguments: [
    "user@example.com",
    "test.name@domain.org",
    "contact@company.co.uk"
])
func validEmailsAreAccepted(email: String) {
    #expect(EmailValidator.isValid(email))
}

@Test(arguments: [
    "invalid",
    "@nodomain.com",
    "no@tld",
    "spaces in@email.com"
])
func invalidEmailsAreRejected(email: String) {
    #expect(!EmailValidator.isValid(email))
}

// Test with tuples for input/output cases
@Test(arguments: [
    ("hello", "HELLO"),
    ("World", "WORLD"),
    ("Swift", "SWIFT")
])
func uppercaseConversion(input: String, expected: String) {
    #expect(input.uppercased() == expected)
}

// Test with Cartesian product of two collections
@Test(arguments: [1, 2, 3], ["a", "b"])
func combinationTest(number: Int, letter: String) {
    // Runs for (1,"a"), (1,"b"), (2,"a"), (2,"b"), (3,"a"), (3,"b")
    let combined = "\(number)\(letter)"
    #expect(combined.count == 2)
}

각 매개변수 조합은 독립된 테스트를 생성하여 실패하는 케이스를 쉽게 식별할 수 있게 합니다.

질문 9: 비동기 코드를 어떻게 테스트합니까?

Swift Testing은 async/await를 네이티브로 통합하여 비동기 테스트를 극적으로 간소화합니다.

AsyncTesting.swiftswift
import Testing

actor DataStore {
    private var items: [String] = []

    func add(_ item: String) {
        items.append(item)
    }

    func getAll() -> [String] {
        items
    }

    func clear() {
        items.removeAll()
    }
}

@Suite("Async Operations")
struct AsyncTests {

    @Test func basicAsyncOperation() async {
        // No need for expectation or wait
        let result = await fetchData()
        #expect(!result.isEmpty)
    }

    @Test func asyncWithTimeout() async throws {
        // Use Task.sleep to simulate delay
        try await Task.sleep(for: .milliseconds(100))

        let data = await loadConfiguration()
        let config = try #require(data)
        #expect(config.isValid)
    }

    @Test func testActorIsolation() async {
        let store = DataStore()

        // Operations on actor sequentially
        await store.add("Item 1")
        await store.add("Item 2")

        let items = await store.getAll()
        #expect(items.count == 2)
        #expect(items.contains("Item 1"))
    }

    @Test func testConcurrentOperations() async {
        let store = DataStore()

        // Concurrent execution with TaskGroup
        await withTaskGroup(of: Void.self) { group in
            for i in 1...10 {
                group.addTask {
                    await store.add("Item \(i)")
                }
            }
        }

        let items = await store.getAll()
        #expect(items.count == 10)
    }
}

// Async helpers for tests
func fetchData() async -> [String] {
    try? await Task.sleep(for: .milliseconds(50))
    return ["data1", "data2"]
}

func loadConfiguration() async -> Configuration? {
    Configuration(isValid: true)
}

struct Configuration {
    let isValid: Bool
}
기본 병렬성

Swift Testing은 기본적으로 테스트를 병렬로 실행합니다. 공유 상태를 수정하는 테스트의 경우 스위트에 .serialized를 사용하거나 액터로 상태를 격리하십시오.

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

질문 10: XCTest에서 점진적으로 어떻게 마이그레이션합니까?

두 프레임워크는 동일한 프로젝트에서 공존합니다. 점진적인 마이그레이션이 권장됩니다.

MigrationStrategy.swiftswift
import XCTest
import Testing

// ⚠️ CRITICAL RULE: Never mix frameworks in the same test

// ❌ INCORRECT - Mixing forbidden
class BadMixedTest: XCTestCase {
    func testMixed() {
        #expect(true)  // Does not work in XCTestCase
    }
}

// ✅ CORRECT - Pure XCTest for existing tests
class LegacyUserTests: XCTestCase {
    func testUserCreation() {
        let user = User(name: "Test")
        XCTAssertNotNil(user)
        XCTAssertEqual(user.name, "Test")
    }
}

// ✅ CORRECT - Swift Testing for new tests
@Suite("User Tests - Modern")
struct ModernUserTests {
    @Test func userCreation() {
        let user = User(name: "Test")
        #expect(user.name == "Test")
    }
}

// Migration strategy by file
// 1. Identify tests to migrate (start with simplest)
// 2. Create new file with @Suite
// 3. Rewrite tests one by one
// 4. Delete old XCTest file once validated

| XCTest | Swift Testing | |--------|---------------| | XCTAssertTrue(x) | #expect(x) | | XCTAssertFalse(x) | #expect(!x) | | XCTAssertEqual(a, b) | #expect(a == b) | | XCTAssertNil(x) | #expect(x == nil) | | XCTAssertNotNil(x) | #expect(x != nil) | | XCTUnwrap(x) | try #require(x) | | XCTAssertThrowsError | #expect(throws:) |

질문 11: XCTest의 어떤 기능이 아직 Swift Testing에 없습니까?

Swift Testing(Swift 6)은 아직 XCTest의 모든 사용 사례를 다루지 않습니다.

MissingFeatures.swiftswift
// ❌ NOT SUPPORTED: Performance tests
// Stick with XCTest for measuring performance
class PerformanceTests: XCTestCase {
    func testPerformance() {
        measure {
            // Code to measure
            _ = (0..<1000).map { $0 * 2 }
        }
    }
}

// ❌ NOT SUPPORTED: UI tests (XCUITest)
// Continue using XCUITest for interface tests
class UITests: XCTestCase {
    func testLoginFlow() {
        let app = XCUIApplication()
        app.launch()
        // UI tests...
    }
}

// ✅ SUPPORTED: Async integration tests
@Test func integrationTest() async throws {
    let api = APIClient()
    let response = try await api.fetchUsers()
    #expect(!response.isEmpty)
}

// ✅ SUPPORTED: Mocking with protocols
@Test func mockingWithProtocols() async {
    let mockService = MockUserService()
    let viewModel = UserViewModel(service: mockService)

    await viewModel.loadUser(id: 1)

    #expect(viewModel.user?.name == "Mock User")
}

UI 또는 성능 테스트가 있는 프로젝트의 경우, 이러한 특정 케이스에는 XCTest를 유지하십시오.

iOS 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

까다로운 면접 질문

질문 12: 왜 #require는 try가 필요하지만 #expect는 필요하지 않습니까?

#require는 조건이 실패하면 테스트를 중단해야 하므로 오류를 던질 수 있습니다. #expect는 실패를 기록하고 계속 진행하기만 하므로 아무것도 던지지 않습니다.

RequireTryExplanation.swiftswift
import Testing

@Test func explainTryRequirement() throws {
    let optionalValue: String? = nil

    // #expect returns Void - no error thrown
    // Test continues even if it fails
    #expect(optionalValue != nil)  // No try

    // #require can throw ExpectationFailedError
    // Test stops if it fails
    // Must be marked with try
    let value = try #require(optionalValue)  // Requires try

    // If we reach here, optionalValue was not nil
    #expect(!value.isEmpty)
}

// The internal signature resembles:
// func #expect(_ condition: Bool) -> Void
// func #require<T>(_ value: T?) throws -> T

이 아키텍처적 구분은 컴파일러가 올바른 오류 처리를 보장할 수 있게 합니다.

질문 13: Swift Testing은 병렬성을 어떻게 관리합니까?

기본적으로 모든 테스트는 병렬로 실행되어 스위트를 가속화하지만 적절한 상태 격리가 필요합니다.

ParallelismHandling.swiftswift
import Testing

// ❌ PROBLEM: Mutable shared state
var sharedCounter = 0  // Dangerous in parallel!

@Suite("Problematic Parallel Tests")
struct ProblematicTests {
    @Test func incrementCounter1() {
        sharedCounter += 1  // Race condition!
    }

    @Test func incrementCounter2() {
        sharedCounter += 1  // Race condition!
    }
}

// ✅ SOLUTION 1: Force sequential execution
@Suite("Sequential Tests", .serialized)
struct SequentialTests {
    static var counter = 0

    @Test func first() {
        Self.counter += 1
        #expect(Self.counter == 1)
    }

    @Test func second() {
        Self.counter += 1
        #expect(Self.counter == 2)
    }
}

// ✅ SOLUTION 2: Isolate state per test
@Suite("Isolated Tests")
struct IsolatedTests {
    @Test func independentTest1() {
        var localCounter = 0
        localCounter += 1
        #expect(localCounter == 1)
    }

    @Test func independentTest2() {
        var localCounter = 0
        localCounter += 1
        #expect(localCounter == 1)
    }
}

// ✅ SOLUTION 3: Use actor for shared state
actor TestState {
    var value = 0

    func increment() -> Int {
        value += 1
        return value
    }
}

@Suite("Actor-based Tests")
struct ActorTests {
    let state = TestState()

    @Test func safeIncrement() async {
        let result = await state.increment()
        #expect(result > 0)
    }
}

.serialized 트레이트는 전체 스위트의 순차 실행을 보장합니다.

질문 14: 콜백에 confirmation을 어떻게 사용합니까?

콜백이 있는 (비async) API의 경우, Swift Testing은 confirmation을 제공합니다.

ConfirmationPattern.swiftswift
import Testing

// Legacy service with callback
class LegacyService {
    func fetchData(completion: @escaping (Result<String, Error>) -> Void) {
        DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
            completion(.success("Data loaded"))
        }
    }
}

@Test func testCallbackWithConfirmation() async {
    let service = LegacyService()

    // confirmation waits for it to be called
    await confirmation("Data callback received") { confirm in
        service.fetchData { result in
            if case .success(let data) = result {
                #expect(data == "Data loaded")
                confirm()  // Signals callback was executed
            }
        }
    }
}

// For callbacks called multiple times
@Test func testMultipleCallbacks() async {
    let publisher = EventPublisher()

    // expectedCount specifies expected call count
    await confirmation("Events received", expectedCount: 3) { confirm in
        publisher.onEvent = { event in
            #expect(!event.isEmpty)
            confirm()  // Called 3 times
        }

        publisher.emit("Event 1")
        publisher.emit("Event 2")
        publisher.emit("Event 3")
    }
}

class EventPublisher {
    var onEvent: ((String) -> Void)?

    func emit(_ event: String) {
        DispatchQueue.global().async {
            self.onEvent?(event)
        }
    }
}

confirmationXCTestExpectationwait(for:timeout:)를 우아하게 대체합니다.

결론

Swift Testing은 Apple 플랫폼에서 테스트의 미래를 대표합니다. 두 매크로 #expect#require는 테스트 작성을 극적으로 간소화하면서 동시에 오류 메시지의 품질을 향상시킵니다.

면접에서 기억해야 할 핵심 사항:

  • #expect는 실패 후에도 계속 진행하고, #require는 즉시 중지합니다
  • #require는 오류를 던질 수 있기 때문에 try가 필요합니다
  • ✅ Swift Testing은 기본적으로 병렬로 실행됩니다
  • ✅ 두 프레임워크는 공존하지만 동일한 테스트에서 혼합되어서는 안 됩니다
  • ✅ XCTest는 UI 및 성능 테스트에 여전히 필요합니다
  • ✅ 트레이트는 테스트 동작을 세밀하게 구성할 수 있게 합니다
  • ✅ 매개변수화된 테스트는 코드 중복을 피합니다

Swift Testing으로의 마이그레이션은 가장 단순한 테스트부터 시작하여 파일별로 점진적으로 수행할 수 있습니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#swift
#ios
#testing
#xctest
#interview

공유

관련 기사