Swift Testing: Пълно ръководство за модерно тестване в Swift

Пълно ръководство за Swift Testing — от @Test и #expect макросите, през параметризирани тестове и тагове, до Attachments и Exit Tests от WWDC 2025. Научете как да мигрирате от XCTest и да пишете по-чисти тестове.

Ако пишете Swift код и все още не пишете тестове — честно казано, време е да преосмислите подхода си. А ако вече пишете тестове с XCTest и се чудите дали има нещо по-добро — определено има. На WWDC 2024 Apple представи Swift Testing — изцяло нов фреймуърк за тестване, създаден от нулата с модерния Swift на ум. А на WWDC 2025 го надградиха с още по-мощни функции като attachments и exit tests.

В тази статия ще разгледаме всичко — от основите до напредналите техники. Ако вече сте чели нашите статии за Swift Concurrency и SwiftData, ще видите как Swift Testing се вписва перфектно в екосистемата на модерния Swift. Хайде да се гмурнем.

Защо тестването има значение и откъде идва Swift Testing

Нека си признаем — повечето от нас сме пропускали писането на тестове в даден момент. „Ще ги напиша после", „проектът е малък", „нямам време". Познато, нали?

Но реалността е, че тестовете не са лукс — те са инвестиция. Всяка минута, прекарана в писане на тестове, ви спестява часове дебъгване по-късно. Лично аз съм се убедил в това по трудния начин — след като изгубих цяла следобед в дебъгване на бъг, който един прост unit тест щеше да хване за секунди.

XCTest ни служеше вярно от 2013 година. Базиран на Objective-C наследство (XCTestCase, setUp/tearDown, XCTAssert...), той свърши страхотна работа. Но с годините Swift еволюира драстично — получихме value types, async/await, макроси — а XCTest остана горе-долу същият. Писането на тестове започна да изглежда малко анахронично в сравнение с останалата част от кодбазата.

Apple забелязаха това и на WWDC 2024 представиха Swift Testing — фреймуърк, проектиран специално за Swift. Ето какво го прави толкова различен:

  • Макро-базиран синтаксис — вместо наследяване от клас, използвате @Test и @Suite макроси
  • Структури вместо класове — тестовите суити могат да бъдат struct, което е по-естествено за Swift
  • Мощни assertion макроси#expect и #require заменят дузината XCTAssert варианти
  • Параметризирани тестове — един тест, много входни данни, без дупликация
  • Нативна async/await поддръжка — тестовете са async по подразбиране
  • Тагове и трейтове — гъвкава организация и конфигурация на тестовете

На WWDC 2025 Apple добавиха още две мощни функции: Attachments (прикачване на файлове и данни към тестови резултати) и Exit Tests (тестване на код, който прекратява процеса). Нека разгледаме всичко подробно.

Настройка на Swift Testing

Ако използвате Xcode 16 или по-нова версия, Swift Testing идва вграден. Не трябва да инсталирате нищо допълнително — буквално нищо. Когато създавате нов проект, Xcode автоматично ви дава опция да добавите Swift Testing target.

За съществуващ проект, просто добавете нов Test target: File → New → Target → Unit Testing Bundle. Изберете „Swift Testing" като testing system и Xcode ще създаде файл с базова структура.

Импортирането е елементарно:

import Testing

// Готови сте! Без наследяване, без setUp, без ceremony.

Ако работите с Swift Package Manager, добавете тестовия таргет в Package.swift:

// Package.swift
let package = Package(
    name: "MyLibrary",
    targets: [
        .target(name: "MyLibrary"),
        .testTarget(
            name: "MyLibraryTests",
            dependencies: ["MyLibrary"]
        )
    ]
)

Swift Testing се открива автоматично от Swift 6.0+ toolchain. Няма нужда от допълнителни зависимости.

Важно за съвместимостта

И тук идва наистина хубавата новина — Swift Testing и XCTest могат да съществуват заедно в един и същ таргет. Това е критично важно за миграцията — не трябва да пренаписвате всичко наведнъж. Можете да имате XCTestCase класове и @Test функции в едни и същи файлове. Xcode ще разпознае и двата типа тестове и ще ги покаже в Test Navigator.

@Test макрото и основни тестове

В XCTest трябваше да наследите от XCTestCase и да наименувате методите с префикс test. В Swift Testing просто слагате @Test пред функцията:

import Testing

@Test func additionWorks() {
    let result = 2 + 2
    #expect(result == 4)
}

Толкова просто е. Сериозно. Няма клас, няма наследяване, няма test префикс (макар че можете да го добавите, ако желаете). Функцията може да е на top level, в struct, в enum — където пожелаете.

Display names

Едно от приятните неща е, че можете да дадете човешко четимо име на теста:

@Test("Събирането на две положителни числа връща правилен резултат")
func additionOfPositiveNumbers() {
    let calculator = Calculator()
    #expect(calculator.add(3, 5) == 8)
}

Това display name се показва в Xcode Test Navigator и в резултатите. Изключително полезно, когато имате десетки тестове и искате бързо да разберете какво прави всеки от тях.

Async тестове

Ако сте чели статията ни за Swift Concurrency, ще оцените колко естествено се интегрира async/await в тестовете:

@Test("Зареждането на потребителски профил връща валидни данни")
func loadUserProfile() async throws {
    let service = UserService()
    let profile = try await service.fetchProfile(id: "user-123")

    #expect(profile.name == "Иван Петров")
    #expect(profile.email.contains("@"))
}

Няма нужда от expectations или waiters както в XCTest. Просто маркирате функцията като async throws и всичко работи. Толкова е просто, че почти изглежда подозрително.

@Suite за организиране на тестови суити

Когато тестовете ви растат, трябва да ги организирате логически. Макрото @Suite ви позволява да групирате свързани тестове:

@Suite("Calculator тестове")
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 divisionByZero() throws {
        #expect(throws: CalculatorError.divisionByZero) {
            try calculator.divide(10, by: 0)
        }
    }
}

Struct vs Class

За разлика от XCTest, където бяхте принудени да използвате класове, тук struct е препоръчителният избор. Всеки тест получава свежа инстанция на struct-а, което означава пълна изолация без споделено състояние. Нещо, за което в XCTest трябваше внимателно да следите с setUp/tearDown.

@Suite struct UserRepositoryTests {
    // Това се създава наново за ВСЕКИ тест
    let repository: UserRepository
    let mockStorage: MockStorage

    init() {
        // Еквивалент на setUp — извиква се преди всеки тест
        mockStorage = MockStorage()
        repository = UserRepository(storage: mockStorage)
    }

    // deinit не е наличен за struct, но за class може
    // да послужи като tearDown
}

Ако наистина имате нужда от tearDown логика (например затваряне на връзки), използвайте class:

@Suite("Database тестове")
class DatabaseTests {
    var connection: DatabaseConnection

    init() throws {
        connection = try DatabaseConnection(url: "sqlite::memory:")
    }

    deinit {
        connection.close()
    }

    @Test func insertRecord() async throws {
        try await connection.insert(User(name: "Петър"))
        let count = try await connection.count(User.self)
        #expect(count == 1)
    }
}

Вложени суити

Можете да влагате суити за по-добра организация — нещо, което е особено полезно в по-големи проекти:

@Suite("Networking")
struct NetworkingTests {

    @Suite("Authentication")
    struct AuthTests {
        @Test func loginWithValidCredentials() async throws {
            // ...
        }

        @Test func loginWithInvalidPassword() async throws {
            // ...
        }
    }

    @Suite("API Requests")
    struct APITests {
        @Test func fetchPosts() async throws {
            // ...
        }
    }
}

Макрото #expect — заместникът на XCTAssert

Ако има нещо, което ще ви накара да се влюбите в Swift Testing, вероятно е #expect. В XCTest имахте XCTAssertEqual, XCTAssertTrue, XCTAssertNil, XCTAssertThrowsError и още поне десет варианта. В Swift Testing всичко е един макрос:

// Булеви проверки (заменя XCTAssertTrue/False)
#expect(user.isActive)
#expect(!list.isEmpty)

// Равенство (заменя XCTAssertEqual)
#expect(result == 42)
#expect(name == "Swift")

// Сравнения (заменя XCTAssertGreaterThan и др.)
#expect(array.count > 0)
#expect(score <= 100)

// Nil проверки (заменя XCTAssertNil/NotNil)
#expect(error == nil)
#expect(optionalValue != nil)

Проверка за хвърляне на грешки

Особено елегантно е проверяването на throws:

// Проверка, че кодът хвърля конкретна грешка
#expect(throws: NetworkError.timeout) {
    try await networkClient.fetch(url: badURL)
}

// Проверка, че кодът хвърля грешка от определен тип
#expect(throws: NetworkError.self) {
    try await networkClient.fetch(url: badURL)
}

// Проверка, че кодът НЕ хвърля грешка
#expect(throws: Never.self) {
    try safeOperation()
}

По-добри съобщения за грешки

Едно от най-големите подобрения спрямо XCTest е качеството на съобщенията при провал. Когато #expect(a == b) се провали, Swift Testing показва точните стойности на двете страни на израза. Не просто „assertion failed" — а нещо от рода на: "Expectation failed: (leftValue → 42) == (rightValue → 43)". Огромна разлика при дебъгване.

Можете да добавите и собствено съобщение:

@Test func priceCalculation() {
    let order = Order(items: [item1, item2])
    let total = order.calculateTotal()

    #expect(
        total == 29.99,
        "Общата цена трябва да е 29.99, но получихме \(total)"
    )
}

Сравнение с XCTest

Ето бърза таблица за ориентация:

  • XCTAssertTrue(x)#expect(x)
  • XCTAssertFalse(x)#expect(!x)
  • XCTAssertEqual(a, b)#expect(a == b)
  • XCTAssertNotEqual(a, b)#expect(a != b)
  • XCTAssertNil(x)#expect(x == nil)
  • XCTAssertNotNil(x)#expect(x != nil)
  • XCTAssertGreaterThan(a, b)#expect(a > b)
  • XCTAssertThrowsError(expr)#expect(throws: SomeError.self) { expr }

По-чисто, по-интуитивно и по-малко когнитивно натоварване. Какво повече можете да искате?

Макрото #require — когато провалът означава спиране

Добре, а каква е разликата между #expect и #require? Съвсем просто: #expect записва провала, но тестът продължава. #require записва провала и спира теста веднага (хвърля грешка).

Кога е полезно? Когато следващите assertion-и зависят от предишния резултат:

@Test func parseUserFromJSON() throws {
    let json = """
    {"name": "Мария", "age": 28, "email": "[email protected]"}
    """

    // Ако декодирането се провали, няма смисъл да проверяваме полетата
    let user = try #require(JSONDecoder().decode(User.self, from: Data(json.utf8)))

    // Тези редове се изпълняват само ако decode е успешен
    #expect(user.name == "Мария")
    #expect(user.age == 28)
    #expect(user.email == "[email protected]")
}

Unwrapping на optionals

Особено полезно е за работа с optionals — нещо, което в XCTest беше доста неудобно (ако сте го правили, знаете):

@Test func findFirstAdmin() throws {
    let users = [
        User(name: "Петър", role: .user),
        User(name: "Ана", role: .admin),
        User(name: "Георги", role: .user)
    ]

    // #require unwrap-ва optional-а, или fail-ва теста
    let admin = try #require(users.first(where: { $0.role == .admin }))

    // Тук admin вече НЕ е optional
    #expect(admin.name == "Ана")
}

В XCTest трябваше да правите XCTUnwrap — което беше отделна функция. Тук #require обслужва и unwrapping, и assertion в едно. Ако подаденият израз е nil, тестът се маркира като провален и спира.

@Test func databaseConnection() throws {
    let config = AppConfig.load()

    // Спираме веднага ако няма database URL
    let dbURL = try #require(config.databaseURL, "Database URL липсва в конфигурацията")

    // Продължаваме само с валиден URL
    let connection = try DatabaseConnection(url: dbURL)
    #expect(connection.isConnected)
}

Параметризирани тестове

Ето я една от функциите, които наистина ще ви спестят тонове код. Честно казано, след като започнете да ги използвате, няма връщане назад.

Представете си, че искате да тествате една функция с различни входни данни. В XCTest бихте написали много отделни метода или един метод с for цикъл (и двете — не особено елегантни). С Swift Testing имате много по-добро решение:

@Test("Валидиране на имейл", arguments: [
    "[email protected]",
    "[email protected]",
    "[email protected]"
])
func validEmails(email: String) {
    let validator = EmailValidator()
    #expect(validator.isValid(email))
}

Всеки аргумент генерира отделен тест в Xcode. Ако един имейл провали валидацията, виждате точно кой — без да се налага да разследвате цял for цикъл.

Множество параметри

Можете да подадете два набора от аргументи и Swift Testing ще ги комбинира (декартово произведение):

@Test("Математически операции", arguments: [2, 5, 10], [3, 7, 1])
func multiplicationIsCommutative(a: Int, b: Int) {
    #expect(a * b == b * a)
}
// Генерира 9 отделни теста: (2,3), (2,7), (2,1), (5,3), (5,7), ...

Zip за свързани параметри

Ако не искате декартово произведение, а двойки вход-очакван резултат, използвайте zip:

@Test("Форматиране на валута", arguments: zip(
    [1000, 2500, 99],
    ["1 000,00 лв.", "2 500,00 лв.", "99,00 лв."]
))
func currencyFormatting(amount: Int, expected: String) {
    let formatter = BulgarianCurrencyFormatter()
    #expect(formatter.format(amount) == expected)
}
// Генерира 3 теста: (1000, "1 000,00 лв."), (2500, "2 500,00 лв."), (99, "99,00 лв.")

Използване на enum

За по-сложни сценарии можете да използвате enum, който имплементира CaseIterable:

enum Locale: String, CaseIterable {
    case bg = "bg_BG"
    case en = "en_US"
    case de = "de_DE"
}

@Test("Приложението поддържа всички локали", arguments: Locale.allCases)
func localeSupport(locale: Locale) {
    let bundle = Bundle.main
    let path = bundle.path(forResource: locale.rawValue, ofType: "lproj")
    #expect(path != nil, "Липсва локализация за \(locale.rawValue)")
}

Трейтове (Traits)

Трейтовете са метаданни, които можете да прикачите към тестове и суити. Мислете за тях като „настройки" за вашите тестове — те контролират как и кога се изпълнява даден тест.

.disabled — временно деактивиране

@Test("Синхронизация с облака", .disabled("Чакаме нов API endpoint от бекенд екипа"))
func cloudSync() async throws {
    // Този тест няма да се изпълнява, но ще се вижда в Test Navigator
    // със съобщението защо е деактивиран
}

Много по-добре от просто да закоментирате теста, нали?

.enabled(if:) — условно изпълнение

@Test(
    "Функционалност налична само на iOS 18+",
    .enabled(if: ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 18)
)
func iOS18Feature() {
    // Изпълнява се само на iOS 18+
}

.bug — свързване с бъг

@Test(
    "Парсване на дати с нестандартен формат",
    .bug("https://github.com/myproject/issues/142", "Фикс за crash при парсване")
)
func parseDateWithCustomFormat() {
    // Тестът е свързан с конкретен бъг
    let date = DateParser.parse("2025-13-01") // Невалиден месец
    #expect(date == nil)
}

.timeLimit — ограничение на времето за изпълнение

@Test("Търсенето трябва да завършва бързо", .timeLimit(.seconds(2)))
func searchPerformance() async throws {
    let results = try await searchEngine.search("Swift Testing")
    #expect(!results.isEmpty)
}
// Тестът fail-ва, ако не приключи за 2 секунди

Комбиниране на трейтове

Трейтовете могат да се комбинират свободно — и тук наистина става интересно:

@Test(
    "Сложна бизнес логика",
    .bug("https://jira.company.com/PROJ-456"),
    .timeLimit(.seconds(5)),
    .tags(.critical, .businessLogic)
)
func complexBusinessRule() async throws {
    // ...
}

Трейтове на ниво Suite

Можете да приложите трейт към цял suite и той ще важи за всички тестове в него:

@Suite("Бавни интеграционни тестове", .timeLimit(.seconds(30)))
struct IntegrationTests {
    // Всички тестове тук имат timeLimit от 30 секунди

    @Test func databaseMigration() async throws { /* ... */ }
    @Test func fullSyncFlow() async throws { /* ... */ }
}

Тагове (Tags)

Таговете са мощен инструмент за категоризиране на тестове. За разлика от суитите (които са йерархични), таговете са плоски и cross-cutting — един тест може да има множество тагове. Мислете за тях като за етикети, които лепите на тестовете си.

// Дефиниране на тагове (обикновено в отделен файл)
extension Tag {
    @Tag static var networking: Self
    @Tag static var database: Self
    @Tag static var critical: Self
    @Tag static var slow: Self
    @Tag static var uiRelated: Self
}

Използване в тестовете:

@Test("Fetch потребители от API", .tags(.networking, .critical))
func fetchUsers() async throws {
    let users = try await api.getUsers()
    #expect(!users.isEmpty)
}

@Test("Запис в база данни", .tags(.database, .critical))
func saveToDatabase() throws {
    let user = User(name: "Тест")
    try database.save(user)
    #expect(database.count(User.self) == 1)
}

@Test("Кеширане на мрежови заявки", .tags(.networking, .database))
func cacheNetworkResponse() async throws {
    let response = try await api.getUsers()
    try cache.store(response)
    let cached = try cache.retrieve(key: "users")
    #expect(cached != nil)
}

Филтриране по тагове в Xcode

В Xcode Test Navigator можете да филтрирате тестовете по тагове. Това е изключително полезно в големи проекти — например, можете да пуснете само .critical тестовете преди всеки commit, а пълния набор — в CI/CD.

От командния ред с swift test също можете да филтрирате:

// Изпълнение само на тестове с таг .networking
// swift test --filter tag:networking

Таговете на ниво Suite се наследяват от всички тестове вътре:

@Suite("Мрежови тестове", .tags(.networking))
struct NetworkTests {
    // Всички тестове тук автоматично имат таг .networking

    @Test(.tags(.critical))  // Има и .networking, и .critical
    func criticalAPICall() async throws { /* ... */ }

    @Test  // Има само .networking
    func optionalAPICall() async throws { /* ... */ }
}

Attachments (ново от WWDC 2025)

Когато тест се провали, понякога самото съобщение за грешка не е достатъчно. Искате да видите скрийншот, JSON отговор, лог файл или друг артефакт. Тук идват Attachments — нова функционалност от WWDC 2025, която честно казано трябваше да дойде по-рано.

Attachments ви позволяват да прикачите произволни данни към тестовия резултат. Те се запазват дори когато тестът премине успешно, но са най-полезни при провал.

import Testing

@Test("Декодиране на комплексен JSON отговор")
func decodeComplexJSON() throws {
    let jsonData = try loadTestFixture("complex_response.json")

    // Прикачваме суровия JSON за диагностика при провал
    Attachment(data: jsonData, named: "raw_response.json")
        .attach()

    let response = try JSONDecoder().decode(APIResponse.self, from: jsonData)
    #expect(response.status == "success")
    #expect(response.items.count > 0)
}

Прикачване на различни типове данни

@Test("Генериране на отчет")
func generateReport() async throws {
    let report = try await reportGenerator.generate(for: .lastMonth)

    // Прикачване на текстови данни
    Attachment(
        string: report.debugDescription,
        named: "report_debug.txt"
    ).attach()

    // Прикачване на binary данни (например PNG изображение)
    if let chartImage = report.chartImageData {
        Attachment(data: chartImage, named: "chart.png").attach()
    }

    #expect(report.totalRevenue > 0)
    #expect(report.items.count == 12) // 12 месеца
}

Attachments с Encodable типове

Ако имате тип, който е Encodable, можете да го прикачите директно:

struct TestSnapshot: Encodable {
    let timestamp: Date
    let state: String
    let metrics: [String: Double]
}

@Test("Мониторинг на перформанс метрики")
func performanceMetrics() async throws {
    let metrics = try await monitor.collectMetrics()

    let snapshot = TestSnapshot(
        timestamp: Date(),
        state: "after_load_test",
        metrics: metrics
    )

    // Прикачваме структурирани данни
    Attachment(snapshot, named: "metrics_snapshot.json").attach()

    #expect(metrics["responseTime"]! < 200) // ms
    #expect(metrics["memoryUsage"]! < 100)  // MB
}

Attachments се визуализират в Xcode Test Report. Можете да ги отворите, сравните и анализирате директно от IDE-то — което драстично намалява времето за дебъгване на провалени тестове, особено в CI среда.

Exit Tests (ново от WWDC 2025)

Ето и друга дългоочаквана функционалност. Понякога трябва да тествате код, който прекратява процеса — например fatalError(), preconditionFailure() или exit(). В XCTest това беше буквално невъзможно — нямаше начин да хванете crash на процеса в тест.

Swift Testing 2025 въвежда #expect(exitsWith:):

@Test("fatalError при невалиден индекс")
func invalidIndexCausesFatalError() async {
    await #expect(exitsWith: .failure) {
        let array = [1, 2, 3]
        // Достъп до невалиден индекс — fatalError
        _ = array[10]
    }
}

Как работи? Swift Testing изпълнява closure-а в отделен процес. Ако процесът завърши с очаквания exit status, тестът преминава. Доста хитро решение.

Различни exit кодове

@Test("Graceful shutdown с exit код 0")
func gracefulShutdown() async {
    await #expect(exitsWith: .success) {
        // Код, който извиква exit(0)
        AppLifecycle.shutdown(gracefully: true)
    }
}

@Test("Crash при критична грешка")
func criticalErrorCrash() async {
    await #expect(exitsWith: .failure) {
        // Код, който извиква fatalError()
        CriticalSystem.handleUnrecoverableError()
    }
}

Тестване на preconditions

struct SafeArray {
    private var storage: [Element]

    func element(at index: Int) -> Element {
        precondition(index >= 0 && index < storage.count,
                     "Индексът \(index) е извън обхвата")
        return storage[index]
    }
}

@Test("precondition fail при отрицателен индекс")
func negativePrecondition() async {
    let array = SafeArray(storage: [1, 2, 3])

    await #expect(exitsWith: .failure) {
        _ = array.element(at: -1)
    }
}

Exit tests са мощен инструмент, но използвайте ги разумно. Те са по-бавни от обикновените тестове (заради създаването на нов процес) и трябва да се прилагат само за код, който наистина прекратява изпълнението.

Важно: exit tests работят само на macOS, Linux, FreeBSD, OpenBSD и Windows. Те не са налични на iOS, watchOS или tvOS, тъй като тези платформи не поддържат създаване на подпроцеси.

Миграция от XCTest

Ако имате съществуваща кодбаза с XCTest тестове — без паника. Миграцията е постепенна и безболезнена.

Стъпка 1: Двата фреймуърка работят заедно

XCTest и Swift Testing могат да съществуват в един и същ test target. Започнете с писане на нови тестове със Swift Testing, а старите мигрирайте постепенно.

// Стар XCTest — оставяте го засега
import XCTest

class OldUserTests: XCTestCase {
    func testUserCreation() {
        let user = User(name: "Тест")
        XCTAssertEqual(user.name, "Тест")
    }
}

// Нов Swift Testing тест — в същия файл или различен
import Testing

@Test func userCreation() {
    let user = User(name: "Тест")
    #expect(user.name == "Тест")
}

Стъпка 2: Преобразуване на assertion-и

Ето типичен пример за миграция — преди и след:

// ПРЕДИ (XCTest)
class PaymentTests: XCTestCase {
    var processor: PaymentProcessor!

    override func setUp() {
        processor = PaymentProcessor(mode: .test)
    }

    override func tearDown() {
        processor = nil
    }

    func testSuccessfulPayment() throws {
        let result = try processor.charge(amount: 100, currency: "BGN")
        XCTAssertTrue(result.isSuccessful)
        XCTAssertEqual(result.amount, 100)
        XCTAssertNotNil(result.transactionId)
    }

    func testInsufficientFunds() {
        XCTAssertThrowsError(try processor.charge(amount: 1_000_000, currency: "BGN")) { error in
            XCTAssertEqual(error as? PaymentError, .insufficientFunds)
        }
    }
}

// СЛЕД (Swift Testing)
@Suite("Плащания")
struct PaymentTests {
    let processor: PaymentProcessor

    init() {
        processor = PaymentProcessor(mode: .test)
    }
    // Няма нужда от tearDown — struct се деалокира автоматично

    @Test("Успешно плащане")
    func successfulPayment() throws {
        let result = try processor.charge(amount: 100, currency: "BGN")
        #expect(result.isSuccessful)
        #expect(result.amount == 100)
        #expect(result.transactionId != nil)
    }

    @Test("Недостатъчна наличност")
    func insufficientFunds() {
        #expect(throws: PaymentError.insufficientFunds) {
            try processor.charge(amount: 1_000_000, currency: "BGN")
        }
    }
}

Какво НЕ може да бъде мигрирано

Важно е да знаете, че Swift Testing не заменя напълно XCTest. Ето какво остава в XCTest засега:

  • UI тестове — XCUITest все още е единственият начин за UI автоматизирано тестване от Apple. Swift Testing не поддържа UI тестове.
  • Performance тестовеmeasure { } блоковете от XCTest нямат директен еквивалент в Swift Testing. Използвайте .timeLimit за основни проверки, но за детайлни performance метрики оставете XCTest.
  • Objective-C тестове — Swift Testing работи само с Swift. Тук няма изненада.

Стратегия за миграция

  1. Започнете с нови тестове — пишете ги директно със Swift Testing
  2. Мигрирайте прости unit тестове първо — те обикновено са 1:1 преобразуване
  3. Мигрирайте async тестове — те стават значително по-чисти
  4. Оставете UI и performance тестовете в XCTest
  5. Премахнете XCTest import-а от файлове, които вече не го използват

Добри практики и шаблони

След като вече знаете инструментите, нека поговорим за това как да ги използвате ефективно в реални проекти.

1. Давайте описателни имена на тестовете

// Лошо — какво тестваме?
@Test func test1() { /* ... */ }

// По-добре — но все още неясно
@Test func testUser() { /* ... */ }

// Добре — описва поведението
@Test("Потребител с невалиден имейл не може да бъде създаден")
func userWithInvalidEmailFailsValidation() {
    let result = User.validate(email: "not-an-email")
    #expect(result == .invalid)
}

2. Един тест — едно поведение

// Лошо — тестваме твърде много неща
@Test func userOperations() throws {
    let user = User(name: "Тест")
    #expect(user.name == "Тест")
    user.updateEmail("[email protected]")
    #expect(user.email == "[email protected]")
    try user.save()
    #expect(user.isPersisted)
}

// Добре — отделни тестове за отделни поведения
@Suite struct UserTests {
    @Test func initialization() {
        let user = User(name: "Тест")
        #expect(user.name == "Тест")
    }

    @Test func emailUpdate() {
        var user = User(name: "Тест")
        user.updateEmail("[email protected]")
        #expect(user.email == "[email protected]")
    }

    @Test func persistence() throws {
        let user = User(name: "Тест")
        try user.save()
        #expect(user.isPersisted)
    }
}

3. Използвайте #require за предусловия

@Test func processOrder() throws {
    let orders = try loadTestOrders()

    // Спираме рано ако няма поръчки за обработка
    let firstOrder = try #require(orders.first)

    // Тук сме сигурни, че firstOrder не е nil
    let result = try OrderProcessor.process(firstOrder)
    #expect(result.status == .completed)
}

4. Параметризирани тестове вместо copy-paste

// Лошо — дублиран код
@Test func validateBGPhone1() {
    #expect(PhoneValidator.isValid("+359888123456"))
}
@Test func validateBGPhone2() {
    #expect(PhoneValidator.isValid("+359877654321"))
}
@Test func validateBGPhone3() {
    #expect(PhoneValidator.isValid("+359898111222"))
}

// Добре — параметризиран тест
@Test("Валидация на български телефонни номера", arguments: [
    "+359888123456",
    "+359877654321",
    "+359898111222",
    "0888123456"
])
func validateBulgarianPhoneNumber(phone: String) {
    #expect(PhoneValidator.isValid(phone))
}

5. Организирайте с тагове за CI/CD

extension Tag {
    @Tag static var smoke: Self       // Бързи тестове за всеки commit
    @Tag static var integration: Self  // По-бавни, пускат се на PR
    @Tag static var nightly: Self      // Пълен набор, пуска се нощно
}

@Test("Основна функционалност", .tags(.smoke))
func coreFeature() { /* ... */ }

@Test("Интеграция с API", .tags(.integration))
func apiIntegration() async throws { /* ... */ }

@Test("Full regression", .tags(.nightly))
func fullRegression() async throws { /* ... */ }

6. Тестване на SwiftData модели

Ако използвате SwiftData (както описахме в нашата статия за SwiftData), ето как да го тествате със Swift Testing:

@Suite("SwiftData Model тестове")
struct ModelTests {
    let container: ModelContainer

    init() throws {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        container = try ModelContainer(
            for: Article.self, Author.self,
            configurations: config
        )
    }

    @Test("Създаване на статия")
    @MainActor
    func createArticle() throws {
        let context = container.mainContext
        let article = Article(title: "Swift Testing", content: "...")
        context.insert(article)
        try context.save()

        let descriptor = FetchDescriptor
() let articles = try context.fetch(descriptor) #expect(articles.count == 1) #expect(articles.first?.title == "Swift Testing") } @Test("Каскадно изтриване на автор изтрива статиите му") @MainActor func cascadeDelete() throws { let context = container.mainContext let author = Author(name: "Иван") let article = Article(title: "Тест", content: "...") article.author = author context.insert(author) try context.save() context.delete(author) try context.save() let articles = try context.fetch(FetchDescriptor
()) #expect(articles.isEmpty) } }

7. Тестване на async код с actors

За тези от вас, които са прочели статията ни за Swift Concurrency — ето как изглежда тестването на actors:

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

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

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

    var itemCount: Int { items.count }
}

@Suite("ShoppingCart actor тестове")
struct ShoppingCartTests {
    @Test("Добавяне на артикули")
    func addItems() async {
        let cart = ShoppingCart()
        await cart.add(CartItem(name: "Swift книга", price: 49.99))
        await cart.add(CartItem(name: "Стикери", price: 5.99))

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

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

Заключение

Swift Testing е голяма крачка напред за екосистемата на Swift. Нека обобщим какво научихме:

  • @Test и @Suite заменят наследяването от XCTestCase с чист, декларативен синтаксис
  • #expect и #require обединяват всички XCTAssert варианти в два интуитивни макроса
  • Параметризираните тестове елиминират дублирането на код
  • Трейтовете и таговете дават гъвкава организация и контрол
  • Attachments (WWDC 2025) подобряват диагностиката при провал
  • Exit tests (WWDC 2025) позволяват тестване на код, който прекратява процеса
  • Миграцията от XCTest е постепенна — двата фреймуърка работят заедно

Swift Testing се интегрира перфектно с останалите модерни Swift технологии. Async тестовете са нативни (сбогом, XCTestExpectation). Тестването на SwiftData модели е чисто и предсказуемо с in-memory контейнери. Actor-базираният код се тества естествено с async/await.

Препоръката ни е проста: започнете да използвате Swift Testing днес. Пишете новите тестове с него. Мигрирайте старите постепенно. Не след дълго ще се чудите как изобщо сте живели без параметризирани тестове и #expect.

Тестовете не са допълнение към кода — те са част от него. А с Swift Testing, писането им най-накрая е толкова приятно, колкото и писането на самия продуктов код. Успех и нека тестовете ви винаги са зелени!

За Автора Editorial Team

Our team of expert writers and editors.