Swift Testing: Hướng Dẫn Toàn Diện Từ Cơ Bản Đến Nâng Cao Cho iOS Developer

Hướng dẫn toàn diện Swift Testing framework cho iOS developer — từ @Test, #expect, #require đến parameterized test, tag, trait, migration từ XCTest, và tính năng mới trong Xcode 26.

Giới Thiệu: Tại Sao Bạn Nên Chuyển Sang Swift Testing?

Nếu bạn là iOS developer, chắc chắn bạn đã từng viết test với XCTest — framework kiểm thử mặc định của Apple, đã tồn tại từ thời Objective-C. Nó hoạt động, tất nhiên rồi. Nhưng thành thật mà nói: hơn 40 hàm assertion khác nhau, bắt buộc kế thừa class, không hỗ trợ parameterized test, và kiến trúc thiết kế từ trước khi Swift ra đời — XCTest đã không còn phù hợp lắm với Swift hiện đại.

Đã đến lúc cần thứ gì đó tốt hơn.

Swift Testing chính là câu trả lời. Được giới thiệu tại WWDC 2024 và phát hành cùng Swift 6 / Xcode 16, đây là framework kiểm thử được xây dựng lại từ đầu cho Swift. Dựa trên macro, thiết kế cho concurrency, cú pháp tối giản mà đầy biểu cảm — nói thật, đây là testing framework mà Swift xứng đáng có từ lâu rồi. Và giờ với Swift 6.2 cùng Xcode 26 mang đến exit test, ranged confirmation, test scoping trait, framework này đã trưởng thành thành một công cụ thực sự mạnh mẽ.

Trong bài viết này, mình sẽ đưa bạn từ test đầu tiên cho đến các kỹ thuật nâng cao giúp thay đổi hoàn toàn cách bạn viết và tổ chức test. Dù bạn bắt đầu dự án mới hay đang migrate từ XCTest, hy vọng đây sẽ là tài liệu tham khảo hữu ích cho bạn.

Bắt Đầu: Viết Test Đầu Tiên Với Swift Testing

Cách tốt nhất để hiểu Swift Testing? Bắt tay viết test luôn.

Nếu bạn đang dùng Xcode 16 trở lên với toolchain Swift 6+, Swift Testing đã có sẵn — không cần thêm package dependency nào cả. Chỉ riêng điều này đã là một cải tiến đáng kể so với việc phải tự thêm thư viện testing bên ngoài.

Giả sử bạn có một struct Calculator đơn giản:

struct Calculator {
    func add(_ a: Int, _ b: Int) -> Int {
        a + b
    }

    func divide(_ a: Double, _ b: Double) throws -> Double {
        guard b != 0 else {
            throw CalculatorError.divisionByZero
        }
        return a / b
    }
}

enum CalculatorError: Error {
    case divisionByZero
}

Đây là cách bạn test nó với Swift Testing:

import Testing

@Test func additionProducesCorrectResult() {
    let calculator = Calculator()
    #expect(calculator.add(2, 3) == 5)
}

Vậy đó. Không cần kế thừa class. Không cần XCTestCase. Không cần tiền tố test. Chỉ cần một hàm tự do gắn @Test và macro #expect cho assertion. Framework sẽ tự tìm và chạy test này cho bạn.

So sánh với phiên bản XCTest tương đương:

import XCTest

class CalculatorTests: XCTestCase {
    func testAdditionProducesCorrectResult() {
        let calculator = Calculator()
        XCTAssertEqual(calculator.add(2, 3), 5)
    }
}

Phiên bản Swift Testing ngắn gọn hơn, biểu cảm hơn, và không ép bạn vào class hierarchy nào cả. Nhưng sức mạnh thực sự còn sâu hơn nhiều so với chỉ cú pháp — hãy cùng khám phá tiếp.

Hai Macro Cốt Lõi: #expect và #require

XCTest đi kèm hơn 40 hàm assertion: XCTAssertEqual, XCTAssertNil, XCTAssertGreaterThan, XCTAssertTrue, XCTAssertThrowsError... danh sách cứ kéo dài mãi. Swift Testing thay thế tất cả chỉ bằng hai macro.

Đúng vậy, chỉ hai thôi. Mình cũng không tin lúc đầu.

#expect — Assertion Mềm (Soft Assertion)

#expect đánh giá một biểu thức Boolean và ghi nhận lỗi nếu kết quả là false, nhưng test vẫn tiếp tục chạy. Đây là assertion bạn sẽ dùng nhiều nhất:

@Test func userValidation() {
    let user = User(name: "Alice", age: 30, email: "[email protected]")

    // Tất cả đều được kiểm tra, kể cả khi assertion trước đó fail
    #expect(user.name == "Alice")
    #expect(user.age >= 18)
    #expect(user.email.contains("@"))
    #expect(user.isActive)
}

#expect không dừng test, bạn sẽ thấy tất cả lỗi trong một lần chạy — không chỉ lỗi đầu tiên. Ai đã từng trải qua cái vòng lặp "sửa một assertion → chạy lại → gặp lỗi tiếp → sửa tiếp → chạy lại" thì chắc hiểu tại sao điều này quan trọng đến thế nào.

Macro này còn tự động capture giá trị thực tế trong output chẩn đoán. Khi #expect(user.age >= 18) fail, output sẽ hiển thị giá trị thực của user.age. Không cần đoán mò xem cái gì sai nữa — tiện lắm.

#require — Assertion Cứng (Hard Assertion)

#require là "người gác cổng". Nếu điều kiện fail, nó throw error và dừng test ngay lập tức. Sử dụng khi việc tiếp tục sau lỗi không có ý nghĩa gì (hoặc sẽ gây crash):

@Test func parseUserFromJSON() throws {
    let json = """
    {"name": "Bob", "age": 25}
    """
    let data = json.data(using: .utf8)

    // Nếu dòng này fail, không cần chạy tiếp
    let unwrappedData = try #require(data)

    let user = try JSONDecoder().decode(User.self, from: unwrappedData)
    #expect(user.name == "Bob")
    #expect(user.age == 25)
}

Lưu ý cách #require kiêm luôn chức năng safe unwrapper — nó thay thế trực tiếp cho XCTUnwrap. Nếu datanil, test sẽ fail ngay với thông báo rõ ràng thay vì crash ở dòng tiếp theo. Khá gọn gàng phải không?

Kiểm Tra Error (Throws)

Cả hai macro đều hỗ trợ kiểm tra biểu thức throw error một cách tự nhiên:

@Test func divisionByZeroThrows() {
    let calculator = Calculator()

    // Kiểm tra throw đúng loại error cụ thể
    #expect(throws: CalculatorError.divisionByZero) {
        try calculator.divide(10, by: 0)
    }
}

// Kiểm tra throw bất kỳ error nào thuộc một type
@Test func invalidInputThrowsError() {
    #expect(throws: ValidationError.self) {
        try validateInput("")
    }
}

// Xác nhận không có error nào được throw
@Test func validInputSucceeds() {
    #expect(throws: Never.self) {
        try validateInput("hello")
    }
}

Cú pháp này dễ đọc hơn rất nhiều so với XCTAssertThrowsError của XCTest với pattern closure lồng nhau phức tạp. Mình nghĩ đây là một trong những cải tiến "nhỏ mà đau" nhất.

Tổ Chức Test Với @Suite

Hàm test độc lập hoạt động tốt cho dự án nhỏ, nhưng codebase thực tế cần tổ chức rõ ràng hơn. Macro @Suite cho phép bạn nhóm các test liên quan — và khác với XCTest, suite có thể là struct, class, hoặc thậm chí actor:

@Suite("Calculator Operations")
struct CalculatorTests {
    let calculator = Calculator()

    @Test func addition() {
        #expect(calculator.add(2, 3) == 5)
    }

    @Test func subtraction() {
        #expect(calculator.subtract(10, 4) == 6)
    }

    @Test func multiplication() {
        #expect(calculator.multiply(3, 7) == 21)
    }
}

Apple khuyên nên bắt đầu với struct trừ khi bạn cụ thể cần reference semantics hoặc deinit để cleanup. Mỗi test nhận được instance mới của suite struct, nên có sự cách ly tự nhiên giữa các test — không có trạng thái dư thừa từ lần chạy trước ảnh hưởng gì cả.

Suite Lồng Nhau (Nested Suites)

Suite có thể lồng trong suite khác, tạo ra hierarchy rõ ràng:

@Suite("User Management")
struct UserManagementTests {

    @Suite("Registration")
    struct RegistrationTests {
        @Test func validEmailAccepted() {
            let result = UserValidator.validateEmail("[email protected]")
            #expect(result == .valid)
        }

        @Test func invalidEmailRejected() {
            let result = UserValidator.validateEmail("not-an-email")
            #expect(result == .invalid)
        }
    }

    @Suite("Authentication")
    struct AuthenticationTests {
        @Test func correctPasswordSucceeds() throws {
            let auth = Authenticator()
            let result = try auth.login(username: "admin", password: "correct")
            #expect(result.isAuthenticated)
        }
    }
}

Xcode test navigator hiển thị hierarchy này rất trực quan, giúp bạn dễ dàng chạy từng nhóm test cụ thể. Khi bạn có vài trăm test và cần tập trung vào một khu vực nhất định thì khả năng này vô cùng hữu ích (mình hay dùng khi debug một module cụ thể).

Khởi Tạo Suite (Setup Logic)

Cần logic thiết lập trước mỗi test? Đơn giản — dùng init() của suite thay cho setUp() của XCTest:

@Suite("Database Operations")
struct DatabaseTests {
    let database: TestDatabase

    init() throws {
        database = try TestDatabase.createInMemory()
        try database.runMigrations()
    }

    @Test func insertRecord() throws {
        try database.insert(Record(name: "Test"))
        #expect(database.count == 1)
    }

    @Test func deleteRecord() throws {
        try database.insert(Record(name: "Test"))
        try database.delete(where: .name("Test"))
        #expect(database.count == 0)
    }
}

Vì mỗi test tạo instance struct mới, init() chạy trước mỗi test — giống hệt setUp() trong XCTest. Để cleanup, bạn dùng deinit (yêu cầu chuyển sang class suite).

Parameterized Test: Viết Một Lần, Test Nhiều Đầu Vào

Đây có lẽ là tính năng "sát thủ" nhất của Swift Testing.

XCTest chưa bao giờ hỗ trợ native cái này. Parameterized test cho phép bạn chạy cùng một logic test trên nhiều bộ dữ liệu đầu vào, và mỗi đầu vào trở thành một test case riêng biệt có thể báo cáo độc lập. Nghe đơn giản nhưng nó thay đổi cách bạn viết test khá nhiều.

Tham Số Đơn

@Test(arguments: [1, 2, 3, 4, 5])
func squareIsAlwaysPositive(_ value: Int) {
    #expect(value * value > 0)
}

Đoạn code trên tạo ra 5 test case riêng biệt trong test navigator. Mỗi case có thể pass hoặc fail độc lập, giúp bạn ngay lập tức biết đầu vào nào gây ra lỗi.

Nhiều Tham Số (Combinatorial)

Khi bạn cung cấp nhiều collection tham số, Swift Testing sẽ test mọi tổ hợp:

@Test(arguments: ["USD", "EUR", "VND"], [100.0, 0.0, -50.0])
func currencyFormattingHandlesAllCases(currency: String, amount: Double) {
    let formatter = CurrencyFormatter(currency: currency)
    let result = formatter.format(amount)
    #expect(!result.isEmpty)
    #expect(result.contains(currency))
}

Đoạn code trên tạo ra 9 test case (3 loại tiền × 3 số tiền). Mỗi tổ hợp hiển thị riêng biệt trong báo cáo test. Tính năng này cực kỳ mạnh cho logic validation, parser, formatter — nói chung bất cứ thứ gì cần xử lý đa dạng đầu vào.

Ghép Cặp Tham Số Với zip

Đôi khi bạn không muốn mọi tổ hợp — bạn chỉ muốn các cặp cụ thể. Đó là lúc zip phát huy tác dụng:

@Test(arguments: zip(
    ["[email protected]", "bob@test", "invalid", "[email protected]"],
    [true, false, false, true]
))
func emailValidation(email: String, shouldBeValid: Bool) {
    let result = EmailValidator.validate(email)
    #expect(result.isValid == shouldBeValid)
}

Test này chạy đúng 4 case, mỗi case ghép một email với kết quả mong đợi tương ứng. Không lo bùng nổ tổ hợp.

Sử Dụng Enum Làm Tham Số

Bất kỳ enum nào conform CaseIterable đều hoạt động hoàn hảo:

enum Theme: String, CaseIterable {
    case light, dark, system
}

@Test(arguments: Theme.allCases)
func themeCanBeApplied(_ theme: Theme) {
    let manager = ThemeManager()
    manager.apply(theme)
    #expect(manager.currentTheme == theme)
}

Mỗi case của enum tự động trở thành một test case. Thêm case mới vào enum? Bạn tự động có thêm test — không cần sửa code test gì cả. Đây là kiểu "set up rồi quên" mà mình rất thích.

Tags: Phân Loại Test Xuyên Suốt Các Suite

Tag cho phép bạn phân loại test vượt qua ranh giới suite. Khác với suite (cung cấp hierarchy cứng nhắc), tag là nhãn linh hoạt mà bạn có thể gắn và kết hợp tùy ý.

Định Nghĩa Custom Tag

extension Tag {
    @Tag static var networking: Self
    @Tag static var persistence: Self
    @Tag static var ui: Self
    @Tag static var slow: Self
    @Tag static var critical: Self
}

Gắn Tag Vào Test

@Test(.tags(.networking, .critical))
func apiEndpointReturnsValidJSON() async throws {
    let response = try await APIClient.shared.fetchUsers()
    #expect(!response.isEmpty)
}

@Test(.tags(.persistence))
func coreDataSavePersistsCorrectly() throws {
    let context = TestCoreDataStack().viewContext
    let entity = Item(context: context)
    entity.name = "Test"
    try context.save()

    let fetched = try context.fetch(Item.fetchRequest())
    #expect(fetched.count == 1)
}

Tag Cấp Suite

Khi bạn gắn tag vào suite, mọi test bên trong đều kế thừa tag đó:

@Suite(.tags(.networking))
struct APIClientTests {
    // Tất cả test ở đây tự động có tag .networking

    @Test(.tags(.critical))
    func authenticationEndpoint() async throws {
        // Test này có cả tag .networking VÀ .critical
    }

    @Test
    func profileEndpoint() async throws {
        // Test này chỉ có tag .networking từ suite
    }
}

Trong Xcode, bạn có thể lọc và chạy test theo tag. Cái này cực kỳ hữu ích trong CI/CD pipeline — ví dụ chỉ chạy test .critical trên mỗi push, chạy toàn bộ suite hàng đêm, hoặc bỏ qua test .slow khi đang phát triển local. Mình hay dùng pattern này cho các dự án có test suite lớn.

Traits: Tùy Biến Hành Vi Test

Trait là các modifier thay đổi cách test chạy. Tag thực ra cũng là một loại trait, nhưng hệ thống còn đi xa hơn nhiều.

Vô Hiệu Hóa Test

@Test(.disabled("Server đang migration — bật lại sau 28/02"))
func integrationTestAgainstStagingServer() async throws {
    // Không chạy, nhưng vẫn hiển thị trong test navigator kèm lý do
}

Khác với cách "hack" phổ biến của XCTest là thêm underscore vào tên method (hoặc tệ hơn — comment out toàn bộ), test bị disable trong Swift Testing vẫn hiển thị và được ghi chú rõ ràng. Chuỗi lý do xuất hiện trong báo cáo test nên không ai cần đào git blame để tìm hiểu nữa.

Thực Thi Có Điều Kiện

@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil))
func onlyRunsOnCI() async throws {
    // Bỏ qua khi chạy local, chỉ chạy trên CI
}

@Test(.disabled("Cần watchOS 11 simulator"))
func watchOSSpecificTest() {
    // Được vô hiệu hóa kèm lý do rõ ràng
}

Giới Hạn Thời Gian

@Test(.timeLimit(.minutes(2)))
func longRunningOperation() async throws {
    let result = try await processor.processLargeDataset()
    #expect(result.recordCount > 0)
}

Nếu test vượt quá giới hạn thời gian, nó tự động fail. Tính năng này tuyệt vời để phát hiện performance regression hoặc async operation bị treo — đặc biệt trong CI nơi bạn không muốn một test bị kẹt làm tắc nghẽn toàn bộ pipeline suốt mấy tiếng đồng hồ.

Tham Chiếu Bug

@Test(.bug("https://github.com/myorg/myrepo/issues/42", "Login lỗi với ký tự đặc biệt"))
func loginWithSpecialCharacters() throws {
    let result = try Authenticator().login(username: "user@name", password: "p@$$w0rd!")
    #expect(result.isAuthenticated)
}

Tham chiếu bug tạo link có thể click trong báo cáo test của Xcode, kết nối test trực tiếp với issue mà nó validate. Chi tiết nhỏ nhưng tạo khác biệt lớn khi bạn phân loại test failure trong một dự án có nhiều người.

Chạy Tuần Tự (Serial Execution)

Mặc định, Swift Testing chạy tất cả test song song. Nếu bạn có test chia sẻ trạng thái hoặc cần phải chạy tuần tự, dùng trait .serialized trên suite:

@Suite(.serialized)
struct SharedDatabaseTests {
    @Test func step1CreateTable() throws {
        try SharedDatabase.shared.createTable("users")
    }

    @Test func step2InsertRow() throws {
        try SharedDatabase.shared.insert(into: "users", values: ["Alice"])
    }
}

Tuy nhiên, hãy dùng .serialized một cách tiết kiệm. Apple khuyên nên refactor test để chạy song song khi có thể — test tuần tự chậm hơn và có thể che giấu bug concurrency trong code của bạn. Mình cũng đồng ý với quan điểm này.

Xử Lý Lỗi Đã Biết Với withKnownIssue

Đôi khi bạn biết trước một test sẽ fail — có thể bug đã được track nhưng chưa fix. Thay vì disable test hoàn toàn (rồi quên luôn trong nhiều tháng — ai cũng từng vậy), bạn có thể đánh dấu nó là known issue:

@Test func parseMalformedDateString() {
    withKnownIssue("Parser chưa xử lý timezone offset — issue #87") {
        let date = DateParser.parse("2025-12-01T10:00:00+05:30")
        #expect(date != nil)
    }
}

Test vẫn chạy, failure được ghi nhận, nhưng không tính là test failure. Phần hay nhất: khi bug được fix và test bắt đầu pass, Swift Testing cảnh báo đây là success không mong đợi — nhắc bạn gỡ block withKnownIssue. Không còn tình trạng marker known-issue âm thầm che giấu test đã thực sự pass nữa.

Nói thật, mình ước XCTest có tính năng này từ lâu.

Còn có tham số isIntermittent cho test "chập chờn" (flaky):

@Test func networkDependentOperation() async throws {
    withKnownIssue("Flaky trên CI do network throttling", isIntermittent: true) {
        let result = try await fetchFromStaging()
        #expect(result.statusCode == 200)
    }
}

Khi isIntermittenttrue, test pass bất kể kết quả — nhưng failure vẫn được ghi nhận như known issue để theo dõi.

Test Bất Đồng Bộ Và Swift Concurrency

Swift Testing được thiết kế song hành với mô hình concurrency của Swift. Test async hoạt động cực kỳ tự nhiên:

@Test func fetchUserProfile() async throws {
    let client = APIClient()
    let profile = try await client.fetchProfile(userId: "123")

    #expect(profile.name == "Alice")
    #expect(profile.email.contains("@"))
}

Không completion handler. Không expectation cần fulfill. Không timeout cần set. Chỉ async/await thuần túy.

Nếu bạn đã từng vật lộn với XCTestExpectationwaitForExpectations(timeout:) thì bạn hiểu cảm giác nhẹ nhõm khi nhìn đoạn code trên.

Test Code Cách Ly Theo Actor

Nếu bạn đang test code cách ly theo actor cụ thể, Swift Testing xử lý rất sạch sẽ:

actor ShoppingCart {
    private var items: [CartItem] = []

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

    func total() -> Double {
        items.reduce(0) { $0 + $1.price }
    }

    var itemCount: Int { items.count }
}

@Test func shoppingCartAccumulatesItems() async {
    let cart = ShoppingCart()

    await cart.add(CartItem(name: "Widget", price: 9.99))
    await cart.add(CartItem(name: "Gadget", price: 24.99))

    let count = await cart.itemCount
    let total = await cart.total()

    #expect(count == 2)
    #expect(total == 34.98)
}

Confirmation API — Thay Thế XCTestExpectation

Đối với API bất đồng bộ dựa trên callback chưa được migrate sang async/await, Swift Testing cung cấp confirmation API — phiên bản hiện đại thay thế XCTest expectation:

@Test func notificationIsPosted() async {
    await confirmation("Notification received") { confirm in
        let observer = NotificationCenter.default.addObserver(
            forName: .userDidLogin,
            object: nil,
            queue: .main
        ) { _ in
            confirm()
        }

        AuthService.shared.login(username: "test", password: "pass")

        NotificationCenter.default.removeObserver(observer)
    }
}

Với Swift 6.2, Ranged Confirmation cho phép bạn chỉ định khoảng số lần gọi confirm() thay vì số chính xác — lý tưởng cho các hành vi hơi dao động hoặc test UI nơi số lần callback có thể khác nhau giữa các lần chạy.

Tính Năng Mới Trong Swift 6.2 / Xcode 26

Swift Testing tiếp tục phát triển nhanh. Dưới đây là những cải tiến đáng chú ý nhất trong phiên bản mới nhất:

Exit Test

Trước đây, không có cách nào để xác minh rằng code crash hoặc exit đúng cách bằng Swift tooling — cả XCTest lẫn Swift Testing đều bó tay. Exit Test trong Swift 6.2 giải quyết điều này, cho phép bạn kiểm tra các failure case nơi bạn mong đợi code crash hoặc gọi exit(). Đây là một bổ sung khá quan trọng cho những ai viết low-level code hoặc CLI tool.

Test Scoping Trait

Scoping trait cho phép bạn tùy biến hành vi test bằng Swift concurrency, mà không cần dựa vào global state. Đây là một bước tiến quan trọng hướng tới testing có tính composable, chính xác, và an toàn hơn.

Tích Hợp Với Approachable Concurrency

Nếu bạn đã kích hoạt default MainActor isolation trong module (tính năng mới của Swift 6.2), test hoạt động liền mạch:

// Với defaultIsolation(MainActor.self) trong test target
@Suite struct ViewModelTests {
    // Tự động @MainActor — hoàn hảo để test @MainActor view models
    let viewModel = ContentViewModel()

    @Test func loadDataUpdatesPublishedProperty() async throws {
        await viewModel.loadData()
        #expect(!viewModel.items.isEmpty)
    }
}

// Test cần chạy ngoài main actor
@Test @concurrent
func heavyComputationDoesNotBlockMainActor() async {
    let result = await DataProcessor.crunchNumbers(input: largeDataset)
    #expect(result.isValid)
}

Annotation @concurrent giúp bạn opt-out khỏi MainActor isolation khi cần — khá tiện khi test heavy computation mà không muốn block main thread.

Migration Từ XCTest: Hướng Dẫn Thực Tế

Nếu bạn đã có test suite viết bằng XCTest, tin vui là cả hai framework cùng tồn tại hoàn hảo trong cùng một test target. Bạn có thể migrate dần dần — không cần viết lại toàn bộ cùng một lúc. Thở phào nhé.

Quy Tắc Cùng Tồn Tại

Có một quy tắc quan trọng cần nhớ: tuyệt đối không trộn lẫn framework trong cùng một test. Không gọi XCTAssert từ Swift Testing test, và không dùng #expect từ phương thức XCTestCase. Mỗi test phải dùng một framework duy nhất.

Tuy nhiên, cả hai loại test có thể nằm trong cùng file và cùng test target mà không có vấn đề gì.

Bảng Chuyển Đổi Nhanh

// XCTest                              →  Swift Testing
// ─────────────────────────────────────────────────────
// class FooTests: XCTestCase          →  @Suite struct FooTests
// func testSomething()                →  @Test func something()
// XCTAssertEqual(a, b)                →  #expect(a == b)
// XCTAssertTrue(condition)            →  #expect(condition)
// XCTAssertNil(value)                 →  #expect(value == nil)
// XCTAssertNotNil(value)              →  #expect(value != nil)
// XCTAssertThrowsError(expr)          →  #expect(throws: Error.self) { expr }
// try XCTUnwrap(optional)             →  try #require(optional)
// setUp() / setUpWithError()          →  init() / init() throws
// tearDown()                          →  deinit (class suite)
// XCTSkip("reason")                   →  .disabled("reason") trait
// measure { }                         →  Chưa hỗ trợ
// XCUIApplication                     →  Chưa hỗ trợ

Bảng này mình hay bookmark lại khi migrate — khá tiện để tra cứu nhanh.

Những Gì Chưa Thể Migrate

Hai loại test nên giữ lại trong XCTest hiện tại:

  • Performance test: Đo lường hiệu năng dựa trên XCTMetric chưa có tương đương trong Swift Testing.
  • UI test: XCUIApplication và UI automation vẫn chỉ thuộc XCTest. Tuy nhiên, Xcode 26 đã giới thiệu khả năng recording mới bổ trợ cho UI testing.

Ví Dụ Migration Thực Tế

Hãy cùng migrate từng bước một class XCTest điển hình:

// TRƯỚC: XCTest
class UserServiceTests: XCTestCase {
    var sut: UserService!
    var mockNetwork: MockNetworkClient!

    override func setUp() {
        super.setUp()
        mockNetwork = MockNetworkClient()
        sut = UserService(network: mockNetwork)
    }

    override func tearDown() {
        sut = nil
        mockNetwork = nil
        super.tearDown()
    }

    func testFetchUserReturnsCorrectName() async throws {
        mockNetwork.stubResponse(User(name: "Alice", age: 30))
        let user = try await sut.fetchUser(id: "123")
        XCTAssertEqual(user.name, "Alice")
    }

    func testFetchUserThrowsOnNetworkError() async {
        mockNetwork.stubError(NetworkError.timeout)
        do {
            _ = try await sut.fetchUser(id: "123")
            XCTFail("Expected error")
        } catch {
            XCTAssertTrue(error is NetworkError)
        }
    }
}
// SAU: Swift Testing
@Suite("User Service")
struct UserServiceTests {
    let sut: UserService
    let mockNetwork: MockNetworkClient

    init() {
        mockNetwork = MockNetworkClient()
        sut = UserService(network: mockNetwork)
    }

    @Test func fetchUserReturnsCorrectData() async throws {
        mockNetwork.stubResponse(User(name: "Alice", age: 30))
        let user = try await sut.fetchUser(id: "123")

        #expect(user.name == "Alice")
        #expect(user.age == 30)
    }

    @Test func fetchUserThrowsOnNetworkError() async {
        mockNetwork.stubError(NetworkError.timeout)

        #expect(throws: NetworkError.self) {
            try await sut.fetchUser(id: "123")
        }
    }
}

Hãy để ý các cải tiến: không cần forced unwrap sut, không cần tearDown (struct value semantics tự xử lý cleanup), hai assertion name/age được gộp vào một test vì #expect tiếp tục khi fail, và test error được đơn giản hóa đáng kể.

Ít code hơn, ít "bẫy" hơn, ý định rõ ràng hơn. Thắng trên mọi mặt trận.

Cấu Trúc Test Suite Cho Dự Án Thực Tế

Nếu bạn đang tự hỏi nên tổ chức test như thế nào trong dự án production, đây là cấu trúc mình thấy hoạt động khá ổn:

Tests/
├── MyAppTests/
│   ├── Tags.swift                  // Định nghĩa custom tag
│   ├── Traits/
│   │   ├── MockNetworkTrait.swift
│   │   └── DatabaseTrait.swift
│   ├── Models/
│   │   ├── UserTests.swift
│   │   └── ProductTests.swift
│   ├── Services/
│   │   ├── AuthServiceTests.swift
│   │   └── APIClientTests.swift
│   ├── ViewModels/
│   │   ├── HomeViewModelTests.swift
│   │   └── ProfileViewModelTests.swift
│   └── Helpers/
│       ├── MockNetworkClient.swift
│       └── TestFixtures.swift

Một số nguyên tắc quan trọng:

  • Phản chiếu cấu trúc source — nếu source có Services/AuthService.swift, test đặt ở Services/AuthServiceTests.swift. Đơn giản mà hiệu quả.
  • Tập trung tag — định nghĩa tất cả custom tag trong một file để dễ tìm và tránh trùng lặp.
  • Tách trait tái sử dụng — scoping trait đặt riêng trong thư mục, sẵn sàng compose xuyên suốt mọi suite.
  • Tách helper — mock, stub, và fixture có không gian riêng để không làm rối file test chính.

Lỗi Thường Gặp Và Cách Tránh

Sau khi dùng Swift Testing một thời gian, mình ghi lại vài "bẫy" hay gặp nhất:

1. Lạm Dụng .serialized

Rất dễ bị cám dỗ gắn .serialized vào suite khi test fail khi chạy song song. Hãy kiềm chế.

Failure khi chạy song song gần như luôn chỉ ra shared mutable state — hãy sửa cách ly state thay vì ép chạy tuần tự. Dùng database instance riêng, mock object riêng, hoặc dependency injection. Ép tuần tự chỉ là giấu bụi dưới thảm thôi.

2. Quên Rằng Struct Suite Tạo Instance Mới Cho Mỗi Test

Mỗi test nhận instance struct suite riêng. Điều này thường là điều tốt, nhưng có nghĩa bạn không thể chia sẻ state giữa các test trong cùng suite. Nếu bạn quen với class-based test suite của XCTest nơi state tồn tại xuyên suốt, cần thay đổi tư duy một chút.

3. Trộn Lẫn Framework Trong Một Test

Đơn giản là đừng:

// KHÔNG LÀM THẾ NÀY
@Test func broken() {
    XCTAssertEqual(1, 1)  // Sẽ không hoạt động đúng
    #expect(2 == 2)
}

Mỗi test phải dùng duy nhất một framework. Không ngoại lệ.

4. Không Tận Dụng Parameterized Test

Nếu bạn thấy mình đang copy-paste hàm test với đầu vào hơi khác nhau, đó là dấu hiệu rõ ràng rằng parameterized test đang chờ được viết. Trước khi tạo bản copy thứ ba, hãy dừng lại và refactor sang @Test(arguments:).

5. Không Phân Biệt #require và #expect

Dùng #require cho precondition — nơi việc tiếp tục sau failure không có ý nghĩa. Dùng #expect cho mọi thứ khác. Pattern dưới đây hoạt động rất tốt trong thực tế:

@Test func parseAndValidate() throws {
    // Precondition: parsing phải thành công
    let user = try #require(User.parse(from: jsonData))

    // Assertion: kiểm tra giá trị parsed
    #expect(user.name == "Alice")
    #expect(user.age == 30)
    #expect(user.email.contains("@"))
}

Câu Hỏi Thường Gặp (FAQ)

Swift Testing có thay thế hoàn toàn XCTest không?

Chưa hoàn toàn. XCTest chưa bị deprecated và vẫn là lựa chọn duy nhất cho UI test (XCUIApplication) và performance test (XCTMetric). Tuy nhiên, đối với unit test mới, Swift Testing là lựa chọn tốt hơn nhờ cú pháp hiện đại, parameterized test, và hỗ trợ concurrency native. Hai framework có thể chạy song song trong cùng test target, nên bạn không phải chọn "một trong hai".

Tôi cần phiên bản Xcode nào để dùng Swift Testing?

Swift Testing có sẵn từ Xcode 16 trở lên với toolchain Swift 6+. Để sử dụng các tính năng mới nhất như exit test và ranged confirmation, bạn cần Xcode 26 (Swift 6.2). Không cần thêm package dependency — framework đã tích hợp sẵn.

Migrate từ XCTest sang Swift Testing có khó không?

Không khó đâu. Bạn có thể migrate dần dần — viết test mới bằng Swift Testing trong khi giữ nguyên test cũ bằng XCTest. Hai framework cùng tồn tại trong một test target. Quy tắc duy nhất cần nhớ: không trộn framework trong cùng một test function. Ngoài ra, có thể dùng công cụ như Testpiler để tự động chuyển đổi nếu bạn muốn nhanh hơn.

Swift Testing có hỗ trợ chạy trên Linux và Windows không?

Có. Swift Testing hỗ trợ tất cả nền tảng Apple (iOS, macOS, watchOS, tvOS, visionOS), cùng với Linux và Windows. Đây là điểm khác biệt quan trọng so với XCTest — đặc biệt hữu ích nếu bạn đang làm dự án Swift server-side hoặc cross-platform.

Tại sao test của tôi fail khi chuyển sang Swift Testing?

Nguyên nhân phổ biến nhất là Swift Testing chạy test song song mặc định (khác XCTest chạy tuần tự). Nếu test cũ chia sẻ mutable state, chúng sẽ bị race condition khi chạy song song. Giải pháp đúng là sửa isolation state. Giải pháp tạm thời là gắn .serialized vào suite — nhưng hãy coi đó là bước đệm để refactor chứ không phải giải pháp lâu dài nhé.

Về Tác Giả Editorial Team

Our team of expert writers and editors.