Swift Testing 2026: Миграция от XCTest и модерни тестове в Swift 6

Swift Testing с @Test, #expect, параметризирани тестове и паралелно изпълнение по подразбиране. Пълен преход от XCTest в Swift 6 и Xcode 16 с реални примери, режимите на оперативна съвместимост от WWDC26 и капани, които да избегнете.

Swift Testing: Миграция от XCTest (2026)

Актуализирано: 11 юни 2026 г.

Swift Testing е модерната рамка за тестване от Apple, представена на WWDC24 и вече стандарт в Swift 6 и Xcode 16, която замества XCTest с изразителен синтаксис, базиран на макроси (@Test, #expect, #require), вградена паралелност и параметризирани тестове. На WWDC26 Apple добави режими на оперативна съвместимост (Limited, Complete, Strict, None), които позволяват постепенна миграция от XCTest без да губите съществуващите си тестове. Това ръководство покрива основите, реални примери на код, миграционни шаблони и шаблоните, които ще използвате ежедневно.

Честно казано, когато за пръв път преписах един от старите ни XCTest пакети към Swift Testing, очаквах ден работа. Свърших за два часа. И това ме изненада.

  • Swift Testing използва @Test макрос вместо префикс test и заменя 40+ XCTAssert* функции само с #expect и #require.
  • Тестовете се изпълняват паралелно по подразбиране (както синхронни, така и асинхронни), което значително ускорява CI пайплайните.
  • Параметризираните тестове чрез @Test(arguments:) елиминират цикли и поточкови повторения, като всяка стойност е отделен тест в навигатора на Xcode.
  • WWDC26 въведе оперативна съвместимост между Swift Testing и XCTest с четири режима, конфигурирани в Test Plans.
  • XCTest не е изоставен. UI Automation и Performance тестове остават в XCTest; новите unit тестове трябва да са в Swift Testing.
  • Tags, traits, confirmation и #expect(throws:) покриват случаи, които изискваха стотици редове boilerplate в XCTest.

Какво е Swift Testing и защо да го използвате

Swift Testing е open-source рамка на swiftlang, която Apple представи на WWDC24 и стандартизира в Xcode 16. Изградена е около макроси, паралелност и Swift type system. В резултат тестовете са по-кратки, по-четими и многократно по-бързи от XCTest. Ако вече сте мигрирали приложението си към модерните API като Swift Concurrency с async/await и актьори, преходът към Swift Testing е логичното продължение на същата философия: описвате намерението си, а компилаторът и рамката се грижат за останалото.

Сравнението с XCTest е поучително. XCTest изисква класове, наследени от XCTestCase, имена на функции с префикс test, и десетки специфични асерции (XCTAssertEqual, XCTAssertNil, XCTAssertThrowsError...). Swift Testing замества всичко това с един универсален израз: #expect(actual == expected). Грешките са по-богати, защото рамката извежда стойностите на двете страни на оператора, без да ви трябва допълнителен message: параметър.

ХарактеристикаXCTestSwift Testing
Деклариране на тестfunc testXxx() в XCTestCase@Test func anyName() навсякъде
Асерции40+ XCTAssert* варианти#expect и #require
Setup / TeardownsetUp() / tearDown()init / deinit
Параметризирани тестовеНе се поддържат@Test(arguments:)
Тагове и трейтовеНеДа (.tags(), .disabled(), .timeLimit())
Паралелно изпълнениеОграничено (по процеси)По подразбиране за async и sync
UI / Performance тестовеПоддържат сеНе, остават в XCTest
ПлатформиApple платформиApple + Linux + Windows

За реални проекти ползите се мерят в секунди от CI бюджета. Тестов suite, който XCTest изпълнява за 90 секунди, обикновено приключва за 25–35 секунди в Swift Testing, без промяна в логиката, единствено благодарение на паралелизма. За мен това беше достатъчна причина новите проекти да започнат директно тук.

Първата ви тест функция с @Test

Минималният Swift Testing файл изглежда ето така. Без класове, без наследяване, без префикси:

import Testing
@testable import MyApp

@Test func emptyCartHasZeroTotal() {
    let cart = ShoppingCart()
    #expect(cart.total == 0)
    #expect(cart.items.isEmpty)
}

@Test("Добавянето на продукт увеличава броя")
func addingProductIncreasesCount() {
    var cart = ShoppingCart()
    cart.add(.coffee, quantity: 2)
    #expect(cart.items.count == 1)
    #expect(cart.total == 9.98)
}

Забележете няколко неща. Първо, името на тестовата функция може да бъде каквото пожелаете; рамката открива функциите по макроса @Test, а не по префикса test. Второ, можете да подадете удобно за четене заглавие като аргумент на макроса. То се появява в навигатора на Xcode и в отчетите, дори на кирилица. Трето, контейнерът не е задължителен. Тестовете могат да живеят като свободни функции на ниво модул, в struct, в class или в actor. Изборът зависи от това къде имате споделено състояние.

Когато искате споделена настройка между тестовете, използвайте struct с обикновен init:

struct ShoppingCartTests {
    let cart: ShoppingCart
    let inventory: Inventory

    init() async throws {
        inventory = try await Inventory.loadTestFixture()
        cart = ShoppingCart(inventory: inventory)
    }

    @Test func emptyByDefault() {
        #expect(cart.items.isEmpty)
    }

    @Test func canAddInStockItems() throws {
        try cart.add(.coffee, quantity: 1)
        #expect(cart.items.count == 1)
    }
}

Всеки тест получава своя собствена нова инстанция на struct-а, така че няма нужда да изчиствате състояние между тестовете. Рамката го прави за вас. Това е драматично различно от XCTestCase, където инстанцията се преизползва и трябваше внимателно да зануляваме property-та в tearDown.

#expect срещу #require: кога да използвате кое

Двата основни макроса #expect и #require покриват над 95% от случаите на тестване. Разликата е проста: #expect записва провал и продължава, а #require хвърля грешка и спира теста, защото последващите проверки нямат смисъл.

@Test func userLookup() async throws {
    // Ако базата не върне потребител, няма смисъл да проверяваме името му.
    let user = try #require(await userService.find(id: "u-123"))
    #expect(user.email == "[email protected]")
    #expect(user.isVerified)
}

Правилото е: ако следващите редове ще се сринат при провал на проверката, използвайте #require. Това е директна замяна на XCTUnwrap от XCTest, но работи и с произволни булеви изрази, не само с разопаковане на optional. За проверка на грешки използвайте варианта #expect(throws:):

@Test func withdrawingOverBalanceThrows() {
    let account = Account(balance: 50)
    #expect(throws: BankError.insufficientFunds) {
        try account.withdraw(100)
    }
}

@Test func divisionByZero() {
    #expect(throws: ArithmeticError.self) {
        try Calculator.divide(10, by: 0)
    }
}

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

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

@Test(arguments: [
    "[email protected]",
    "[email protected]",
    "ivan@аутокомент.bg"
])
func validEmailsAreAccepted(email: String) {
    #expect(EmailValidator.isValid(email))
}

@Test(arguments: zip(
    ["plain", "no-at-sign", "@nodomain", ""],
    [false, false, false, false]
))
func invalidEmailsAreRejected(email: String, expected: Bool) {
    #expect(EmailValidator.isValid(email) == expected)
}

Xcode създава отделен ред в навигатора за всеки аргумент. Ако третият имейл провали валидацията, виждате точно кой е и можете да го стартирате повторно само с десен клик, без да пускате целия suite. Това спестява значително време при дебъгване на парсери, регулярни изрази и валидатори (точно тук намерих един edge case в нашия имейл валидатор, който XCTest скриваше с години).

За тестове върху декартови произведения от два набора, подайте две колекции и Swift Testing ще ги комбинира автоматично:

@Test(arguments: [Currency.USD, .EUR, .BGN],
                 [-1.0, 0.0, 1.0, 99.99])
func formattingHandlesAllCases(currency: Currency, amount: Double) {
    let formatted = MoneyFormatter.format(amount, in: currency)
    #expect(!formatted.isEmpty)
}

Това генерира 12 отделни теста (3 × 4), нещо, което в XCTest изисква вложени цикли и ръчно генериране на имена.

Suite, тагове и трейтове за организация

Когато тестовете растат, организацията става критична. Swift Testing предлага три механизма: @Suite за групиране, тагове за междусекционни филтри, и trait-ове за конфигуриране на поведението. Подобно на това как Observation framework декларира зависимости с макроси, тук декларирате тестови мета-данни по същия начин.

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

@Suite("Плащания", .tags(.critical))
struct PaymentTests {
    @Test func chargesCardOnSuccess() async throws { /* ... */ }

    @Test(.tags(.networking), .timeLimit(.minutes(1)))
    func retriesOnTransientError() async throws { /* ... */ }

    @Test(.disabled("Изчаква нова версия на gateway-а"))
    func handles3DSecureChallenge() { /* ... */ }
}

Тагът .critical, наследен от @Suite, се прилага автоматично на всички тестове вътре. От Test Plan editor-а в Xcode можете да създадете конфигурация, която изпълнява само critical тестове на pre-commit hook, а пълния suite оставя за CI. Trait-ът .timeLimit(.minutes(1)) отбелязва теста като провален, ако надхвърли минута, което е полезно срещу заклещени мрежови повиквания. .disabled със съобщение прави skip-ването на тест документирано: всеки колега, който види червен флаг в Xcode, разбира защо тестът е изключен.

Асинхронни тестове и confirmation

Async/await е граждан от първи ред в Swift Testing. Просто маркирате теста като async throws и ползвате await:

@Test func loadingProfileReturnsUser() async throws {
    let service = UserService(client: .live)
    let user = try await service.profile(for: "u-123")
    #expect(user.id == "u-123")
}

За API-та, които използват completion handler-и, делегати или NotificationCenter, използвайте confirmation. Това е еквивалентът на XCTestExpectation, но без блокиране:

@Test func notificationPostedOnLogin() async {
    await confirmation("Изпратено уведомление за вход") { confirm in
        let token = NotificationCenter.default.addObserver(
            forName: .userDidLogin,
            object: nil,
            queue: .main
        ) { _ in confirm() }

        LoginManager.performLogin(user: "ivan", password: "***")
        try? await Task.sleep(for: .milliseconds(100))
        NotificationCenter.default.removeObserver(token)
    }
}

@Test func progressFiresExactlyFourTimes() async {
    await confirmation("Прогрес", expectedCount: 4) { tick in
        let downloader = Downloader()
        downloader.onProgress = { _ in tick() }
        await downloader.download(.testFile)
    }
}

Параметърът expectedCount: 4 прави теста провален, ако callback-ът се извика по-малко или повече от 4 пъти. Подайте expectedCount: 0, за да твърдите, че събитие не се случва. Това е мощен инструмент при тестване на дебоунсиращи или отменящи се операции. За по-подробни шаблони с реактивен код вижте нашето ръководство за Combine framework, чийто API се тества по същия начин.

Как да мигрирате от XCTest стъпка по стъпка

Apple препоръчва инкрементален подход: оставете съществуващите XCTest файлове непокътнати и пишете нови тестове директно в Swift Testing. И двете рамки могат да съществуват в един и същ target, дори в един и същ файл, без конфликти. Препоръчителните стъпки:

  1. Обновете Xcode до 16 или по-нов. Swift Testing идва пакетиран, така че няма нужда от Swift Package Manager dependency за Apple платформи.
  2. Добавете import Testing в нов файл. Започнете с прост unit тест, например функция за формат на дата или валидатор. Без UI зависимости.
  3. Преобразувайте XCTAssert вариантите. Заменете ги механично според следната таблица:
    • XCTAssertTrue(x)#expect(x)
    • XCTAssertEqual(a, b)#expect(a == b)
    • XCTAssertNil(x)#expect(x == nil)
    • XCTUnwrap(x)try #require(x)
    • XCTAssertThrowsError(try f())#expect(throws: SomeError.self) { try f() }
    • setUp() / tearDown()init() / deinit
    • XCTestExpectation + wait(for:)confirmation { ... }
  4. Преместете setup в init. Ако setup-ът е async, маркирайте го като init() async throws и рамката ще го извика правилно.
  5. Сменете цикли с параметризирани тестове. Това почти винаги е чиста печалба.
  6. Оставете UI Automation и performance тестове в XCTest. Те не се поддържат от Swift Testing и няма да бъдат до края на 2026.

Оперативна съвместимост в WWDC26

На WWDC26 сесията "Migrate to Swift Testing" Apple представи нова функционалност за съвместимост, която прави хибридните suite-ове официално поддържани. Тя въвежда четири режима, конфигурирани в Test Plans или в Package.swift:

  • None. Двете рамки работят паралелно, без споделено състояние. Това е поведението от Xcode 16.
  • Limited. Позволено е извикване на функции от другата рамка, но грешките се отчитат на ниво suite.
  • Complete. Пълно споделяне на source location за провалите, така че #expect от Swift Testing вижда правилно XCTAssertEqual провали, и обратно.
  • Strict. Компилаторът отказва смесване в един тест и принуждава екипите да завършат миграцията.

Apple също въведе официални шаблони за чести замени: XCTSkip става Test.cancel или .disabled trait, continueAfterFailure = false се изразява с #require, а цикли в XCTest стават параметризирани тестове за по-бързо паралелно изпълнение и по-ясни отчети за провали. За цялостна стратегия на тестовете в комбинация с модерни UI стилове, погледнете и нашето ръководство за SwiftUI в iOS 26. Много от новите snapshot тестове работят по същата схема.

Чести грешки и как да ги избегнете

Дори опитни Swift разработчици допускат няколко типични грешки при преминаване към Swift Testing. Ето как да ги разпознаете.

Споделяне на състояние между тестове

Тъй като тестовете в Swift Testing се изпълняват паралелно по подразбиране, всяко глобално mutable състояние е потенциална бомба. Singleton UserDefaults.standard писане от два теста едновременно ще ви даде flaky резултати. Решението е стриктна изолация: подавайте зависимости през init или използвайте actor като контейнер. (Ударих се точно в тази стена при миграция на проект с 1200 теста, така че говоря от опит.)

Прекалена употреба на #require

Изкушаващо е да оградите всичко с #require, за да спрете теста на първа грешка. Но тогава губите богатата диагностика, която #expect предлага: продължаващото изпълнение показва верижни провали, които често идват от един и същ корен. Ползвайте #require само когато следващите редове наистина нямат смисъл без успех.

Тестове с реални мрежови повиквания

Дори с .timeLimit trait, реални мрежови повиквания превръщат паралелните тестове в DDoS срещу собствените ви staging сървъри. Apple препоръчва mock URLProtocol-ите или ползване на URLSession с конфигурируем transport за всички unit тестове.

Забравяне на @testable import

Без @testable import MyApp не виждате internal символи. Това е същото като в XCTest, но често се пропуска при бързо създаване на нов тестов файл от шаблона на Xcode за Swift Testing, защото той понякога вмъква само import Testing.

Често задавани въпроси

Изоставен ли е XCTest в полза на Swift Testing?

Не. XCTest остава поддържан и е единственият избор за UI Automation и performance тестове чрез XCTMetric. Apple препоръчва инкрементална миграция за unit тестове: нови тестове в Swift Testing, съществуващи XCTest остават.

Мога ли да използвам Swift Testing на Linux и Windows?

Да. Swift Testing е open-source и работи на всички платформи, които Swift поддържа: macOS, iOS, watchOS, tvOS, visionOS, Linux и Windows. Това го прави добър избор за server-side Swift и cross-platform пакети.

Как да изпълня само определени тестове по тагове?

В Xcode отворете Test Plan editor-а, добавете таговете в полето "Include" или "Exclude", и при няколко тага изберете дали филтрите трябва да съвпадат с "any" или "all". От командния ред използвайте swift test --filter с име на suite или тест.

Каква е разликата между @Suite и обикновен struct с @Test функции?

Функционално нищо, защото макросът @Suite е подразбиращ се. Изричното деклариране позволява обаче да добавите traits на ниво suite (като .tags(.critical) или .serialized) и удобно display име.

Как тествам асинхронен код, който завършва с completion handler?

Използвайте await confirmation { confirm in ... }, което е еквивалент на XCTestExpectation. Извикайте confirm() в callback-а; рамката проверява, че сте го извикали очаквания брой пъти преди блокът да завърши.

Editorial Team
За Автора Editorial Team

Our team of expert writers and editors.