Swift Testing 프레임워크: XCTest를 대체하는 현대적 테스팅

모바일 개발

Swift TestingiOS 테스팅XCTestSwift 6단위 테스트

이 글은 누구를 위한 것인가

  • XCTest의 장황함에 지친 iOS 개발자
  • Swift Testing을 아직 사용해보지 않은 팀
  • 파라미터화 테스트로 반복 코드를 줄이고 싶은 개발자

들어가며

XCTest는 Objective-C 시대부터 이어온 테스팅 프레임워크다. Swift가 발전하면서 func testSomething(), XCTAssertEqual, setUp() 패턴의 한계가 드러났다. Swift Testing은 Swift 5.9(Xcode 15)에서 소개되어 Swift 6에서 안정화된 현대적 대안이다.

이 글은 bluefoxdev.kr의 Swift 테스팅 전략 가이드 를 참고하고, Swift Testing 실전 마이그레이션 관점에서 확장하여 작성했습니다.


1. XCTest vs Swift Testing 비교

// XCTest (기존 방식)
import XCTest

class CartTests: XCTestCase {
    var cart: ShoppingCart!
    
    override func setUp() {
        cart = ShoppingCart()
    }
    
    func testAddItem() {
        cart.add(item: Item(id: "1", price: 10000))
        XCTAssertEqual(cart.itemCount, 1)
    }
    
    func testTotalPrice() {
        cart.add(item: Item(id: "1", price: 10000))
        cart.add(item: Item(id: "2", price: 20000))
        XCTAssertEqual(cart.totalPrice, 30000)
    }
}

// Swift Testing (현대적 방식)
import Testing

@Suite("장바구니 테스트")
struct CartTests {
    var cart = ShoppingCart()  // setUp 불필요 — 각 테스트마다 새 인스턴스
    
    @Test("아이템 추가")
    mutating func addItem() {
        cart.add(item: Item(id: "1", price: 10000))
        #expect(cart.itemCount == 1)
    }
    
    @Test("총 가격 계산")
    mutating func totalPrice() {
        cart.add(item: Item(id: "1", price: 10000))
        cart.add(item: Item(id: "2", price: 20000))
        #expect(cart.totalPrice == 30000)
    }
}

2. Swift Testing 핵심 기능

import Testing

// 파라미터화 테스트 — 여러 케이스를 하나로
@Test("가격 포맷 변환", arguments: [
    (10000, "10,000원"),
    (1000000, "1,000,000원"),
    (0, "0원"),
    (99, "99원"),
])
func priceFormatting(amount: Int, expected: String) {
    #expect(formatPrice(amount) == expected)
}

// 비동기 테스트 — async/await 네이티브 지원
@Test("API 상품 로드")
func loadProductsAsync() async throws {
    let service = ProductService(api: MockAPI())
    let products = try await service.fetchProducts()
    
    #expect(products.count > 0)
    #expect(products.first?.id != nil)
}

// 에러 던지기 검증
@Test("재고 없을 때 오류")
func outOfStockError() throws {
    let product = Product(id: "1", stock: 0)
    
    #expect(throws: CartError.outOfStock) {
        try cart.add(product: product, quantity: 1)
    }
}

// 조건부 테스트
@Test(
    "iOS 17 이상에서만 실행",
    .enabled(if: {
        if #available(iOS 17, *) { return true }
        return false
    }())
)
func iOS17Feature() {
    // iOS 17+ 전용 기능 테스트
}

// 비활성화
@Test("작업 중인 테스트", .disabled("PROJ-1234 해결 후 활성화"))
func workInProgress() {
    // 임시 비활성화
}

// 태그 기반 필터링
extension Tag {
    @Tag static var networking: Self
    @Tag static var ui: Self
    @Tag static var core: Self
}

@Test("결제 API 호출", .tags(.networking))
func paymentAPICall() async throws { ... }

3. Suite 구성과 공유 컨텍스트

import Testing

// 중첩 Suite로 테스트 구조화
@Suite("사용자 인증")
struct AuthTests {
    
    @Suite("로그인")
    struct LoginTests {
        
        @Test("올바른 자격증명")
        func validCredentials() async throws {
            let auth = AuthService(api: MockAuthAPI())
            let token = try await auth.login(email: "test@test.com", password: "correct")
            #expect(token != nil)
            #expect(token!.isValid)
        }
        
        @Test("잘못된 비밀번호")
        func wrongPassword() async throws {
            let auth = AuthService(api: MockAuthAPI())
            
            await #expect(throws: AuthError.invalidCredentials) {
                _ = try await auth.login(email: "test@test.com", password: "wrong")
            }
        }
        
        @Test("이메일 형식 오류")
        func invalidEmail() throws {
            let auth = AuthService(api: MockAuthAPI())
            
            #expect(throws: AuthError.invalidEmail) {
                try auth.validateEmail("not-an-email")
            }
        }
    }
    
    @Suite("토큰 관리")
    struct TokenTests {
        
        @Test("만료 토큰 갱신")
        func refreshExpiredToken() async throws { ... }
        
        @Test("로그아웃 시 토큰 삭제")
        func clearTokenOnLogout() async throws { ... }
    }
}

4. XCTest에서 마이그레이션

// 마이그레이션 전략: 공존 → 점진적 교체

// 기존 XCTest 파일은 그대로 두고,
// 새 파일은 Swift Testing으로 작성 (두 프레임워크 공존 가능)

// XCTest 코드를 Swift Testing으로 변환하는 패턴

// 변환 전 (XCTest)
func testUserCreation() throws {
    let user = try User(name: "홍길동", email: "hong@test.com")
    XCTAssertEqual(user.name, "홍길동")
    XCTAssertFalse(user.isVerified)
    XCTAssertNil(user.profileImage)
}

// 변환 후 (Swift Testing)
@Test("사용자 생성")
func userCreation() throws {
    let user = try User(name: "홍길동", email: "hong@test.com")
    #expect(user.name == "홍길동")
    #expect(!user.isVerified)
    #expect(user.profileImage == nil)
}

/*
변환 대응표:
XCTAssertEqual(a, b)          → #expect(a == b)
XCTAssertTrue(x)              → #expect(x)
XCTAssertFalse(x)             → #expect(!x)
XCTAssertNil(x)               → #expect(x == nil)
XCTAssertNotNil(x)            → #expect(x != nil)
XCTAssertThrowsError(try f()) → #expect(throws: Error.self) { try f() }
XCTFail("메시지")             → Issue.record("메시지")
*/

마무리

Swift Testing은 XCTest보다 훨씬 표현력이 좋고, 파라미터화 테스트로 중복 코드를 대폭 줄일 수 있다. #expect는 실패 시 실제 값을 자동으로 보여줘 디버깅도 편하다.

기존 XCTest를 모두 교체할 필요는 없다. 새로운 테스트는 Swift Testing으로 작성하고, 리팩터링 시 점진적으로 전환하는 전략이 현실적이다.