Swift Testing у 2026: повний посібник від основ до просунутих патернів Swift 6.2

Повний посібник з Swift Testing: макроси @Test та #expect, параметризовані тести, система трейтів і тегів, а також нові можливості Swift 6.2 — exit-тести, вкладення та кастомні трейти з TestScoping.

Swift Testing 2026: Гайд @Test та #expect

Вступ

Якщо ви пишете юніт-тести у Swift, то напевно добре знайомі з XCTest — фреймворком, який вірно служив iOS-розробникам понад десятиліття. Він працює, спору немає. Але чесно кажучи — писати тести з ним буває втомливо. Десятки варіантів XCTAssert, обов'язкове успадкування від XCTestCase, префікс test у кожному методі, ручне управління setUp і tearDown… Все це додає когнітивного навантаження, яке відволікає від головного — перевірки, чи код працює правильно.

Саме тому Apple на WWDC24 представила Swift Testing — сучасний фреймворк для тестування, побудований на макросах Swift і нативно інтегрований з async/await. Він іде разом зі Swift 6 та Xcode 16, жодних додаткових залежностей не треба. А з виходом Swift 6.2 і Xcode 26 фреймворк отримав три серйозні нові можливості: exit-тести, вкладення (attachments) та кастомні трейти з підтримкою скоупінгу.

У цьому посібнику ми пройдемо весь шлях — від написання першого тесту з @Test до просунутих патернів, які реально змінять ваш підхід до тестування. Кожен розділ містить робочі приклади, які можна використовувати у проєктах вже сьогодні.

Чому Swift Testing: переваги над XCTest

Перш ніж зануритися в код, давайте розберемося, чому взагалі варто переходити на Swift Testing. Ось ключові переваги:

  • Мінімальний синтаксис — замість десятків функцій XCTAssert* використовуються лише два макроси: #expect і #require.
  • Гнучка структура — тести можна писати у struct, class або actor, а не лише у підкласах XCTestCase.
  • Параметризовані тести — вбудована підтримка запуску одного тесту з різними наборами даних.
  • Паралельне виконання — тести запускаються паралельно за замовчуванням через Swift Concurrency, і це працює навіть на фізичних пристроях.
  • Система трейтів — теги, умови, обмеження часу, прив'язка до багів — все налаштовується через декларативний API.
  • Кращі повідомлення про помилки — макрос #expect автоматично захоплює значення виразів, тому ви бачите не просто "assertion failed", а конкретні значення, які не збіглися.

Останній пункт, до речі, мабуть найбільше впливає на щоденну роботу. Коли тест падає і ти одразу бачиш, що result був 9.99, а очікувалось 10.0 — це економить купу часу на дебагінг.

При цьому XCTest не є застарілим. Він залишається необхідним для UI-тестування (XCUIApplication), тестування продуктивності (XCTMetric) та Objective-C тестів. Swift Testing і XCTest цілком мирно співіснують в одному тестовому таргеті.

Перший тест: макроси @Test, #expect та #require

Отже, почнемо з основ. Щоб створити тест у Swift Testing, достатньо імпортувати модуль Testing і позначити функцію макросом @Test:

import Testing

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

Ніякого класу, ніякого префікса test у назві. Макрос @Test явно позначає функцію як тест, і компілятор точно знає, що це тестова функція. Просто і зрозуміло.

Макрос #expect

#expect — це основний інструмент для перевірок. Він приймає будь-який булевий вираз і, якщо результат false, тест вважається невдалим. Фреймворк при цьому автоматично розбирає вираз і показує конкретні значення:

@Test func stringComparison() {
    let greeting = "Hello"
    let expected = "World"
    #expect(greeting == expected)
    // Помилка покаже: greeting == expected
    // "Hello" == "World" → false
}

@Test func numericComparison() {
    let price = 9.99
    #expect(price > 10.0)
    // Помилка покаже: price > 10.0
    // 9.99 > 10.0 → false
}

Це радикально краще за XCTest, де для різних типів перевірок потрібно було обирати між XCTAssertEqual, XCTAssertGreaterThan, XCTAssertNil і так далі. Один макрос замість цілого зоопарку функцій.

Макрос #require

Якщо невдала перевірка має зупинити виконання тесту (наприклад, при розгортанні optional), використовуйте #require:

@Test func userParsing() throws {
    let json = """
    {"name": "Тарас", "age": 28}
    """
    let data = json.data(using: .utf8)
    let user = try #require(JSONDecoder().decode(User.self, from: data!))
    #expect(user.name == "Тарас")
    #expect(user.age == 28)
}

Якщо #require отримує nil або кидає помилку — тест одразу зупиняється з інформативним повідомленням. По суті, це заміна комбінації XCTUnwrap + guard із XCTest, тільки набагато елегантніша.

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

Для перевірки, що код кидає очікувану помилку, #expect має спеціальний синтаксис:

enum ValidationError: Error {
    case tooShort
    case invalidFormat
}

@Test func passwordValidation() {
    #expect(throws: ValidationError.tooShort) {
        try validatePassword("ab")
    }
}

Організація тестів: набори та описові назви

Swift Testing дозволяє групувати тести за допомогою макросу @Suite. На відміну від XCTest, де кожен набір — це клас, тут можна використовувати звичайну структуру:

@Suite("Менеджер кошика")
struct CartManagerTests {
    let cart = CartManager()

    @Test("Додавання товару збільшує кількість")
    func addItem() {
        cart.add(item: .mock)
        #expect(cart.items.count == 1)
    }

    @Test("Видалення товару зменшує загальну суму")
    func removeItem() {
        cart.add(item: .mock)
        cart.remove(item: .mock)
        #expect(cart.total == 0)
    }
}

Рядкові параметри макросів @Suite і @Test задають описові назви, які відображаються в Xcode Test Navigator. Це зручніше, ніж намагатися вмістити всю суть тесту в назву функції (ми ж всі знаємо ці назви на кшталт testThatUserCanLoginWithValidCredentialsAndGetRedirected).

Набори можна вкладати один в одний:

@Suite("Автентифікація")
struct AuthTests {
    @Suite("Вхід")
    struct LoginTests {
        @Test func validCredentials() { /* ... */ }
        @Test func invalidPassword() { /* ... */ }
    }

    @Suite("Реєстрація")
    struct RegistrationTests {
        @Test func newUser() { /* ... */ }
        @Test func duplicateEmail() { /* ... */ }
    }
}

Якщо набір використовує struct, для кожного тесту створюється новий екземпляр структури. Це гарантує ізоляцію стану між тестами — аналогічно до setUp у XCTest, але без зайвого бойлерплейту. Для ініціалізації використовується звичайний init():

@Suite struct DatabaseTests {
    let database: TestDatabase

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

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

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

Це, мабуть, одна з найкрутіших можливостей Swift Testing. Замість написання окремого тесту для кожного набору вхідних даних, ви створюєте один тест і передаєте аргументи через макрос @Test:

@Test("Валідація email", arguments: [
    "[email protected]",
    "[email protected]",
    "[email protected]"
])
func validEmails(email: String) {
    #expect(EmailValidator.isValid(email))
}

Swift Testing автоматично запускає тест для кожного значення масиву. У Test Navigator кожен параметр відображається окремо, тому ви одразу бачите, який саме email не пройшов перевірку. Ніякого копіпасту однакових тестів з різними даними.

Кілька параметрів і zip

За замовчуванням при передачі двох колекцій аргументів фреймворк створює декартів добуток — тобто тестує всі можливі комбінації. Це не завжди те, що потрібно. Якщо вам потрібне попарне зіставлення, використовуйте zip:

@Test("Конвертація валют", arguments: zip(
    [100.0, 200.0, 50.0],
    [41.5, 83.0, 20.75]
))
func currencyConversion(usd: Double, expectedUah: Double) {
    let result = CurrencyConverter.convert(usd, from: .usd, to: .uah)
    #expect(abs(result - expectedUah) < 0.01)
}

Серіалізоване виконання

За замовчуванням параметризовані тести виконуються паралельно. Якщо ваші тести залежать від спільного стану, додайте трейт .serialized:

@Test("Послідовна обробка замовлень", .serialized, arguments: [
    Order.pending, Order.processing, Order.completed
])
func orderProcessing(order: Order) async throws {
    try await orderService.process(order)
    #expect(order.status == .completed)
}

Система трейтів і тегів

Трейти — це модифікатори, які налаштовують поведінку тестів. І тут Swift Testing дає справді багатий інструментарій.

Теги для категоризації

Теги дозволяють групувати тести за довільними критеріями — модулем, типом функціональності, пріоритетом, чим завгодно:

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

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

В Xcode Test Navigator є спеціальна вкладка для тегів, яка дозволяє запускати тести за тегом і бачити загальний результат для групи. Якщо тег застосовується до @Suite, усі тести всередині набору автоматично успадковують цей тег — не треба додавати його до кожного тесту окремо.

Умовне виконання

@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] == nil))
func localOnlyTest() {
    // Цей тест запускається лише локально, не на CI
}

@Test(.disabled("Очікується виправлення серверної частини"))
func brokenEndpoint() async throws {
    // Тест пропускається, але залишається видимим
}

Зверніть увагу на .disabled — тест не видаляється, а позначається з причиною. Набагато краще, ніж закоментувати тест і забути про нього на місяці.

Обмеження часу і прив'язка до багів

@Test(.timeLimit(.minutes(2)))
func longRunningOperation() async throws {
    try await heavyComputation()
}

@Test(.bug("https://github.com/myproject/issues/42", "Невірний формат дати"))
func dateFormatting() {
    let formatted = DateHelper.format(Date())
    #expect(formatted.contains("2026"))
}

Трейт .timeLimit при застосуванні до параметризованого тесту діє окремо для кожного параметра. Тобто якщо один параметр перевищує ліміт — інші не позначаються як невдалі. Приємна деталь.

Нове у Swift 6.2: exit-тести

Ось це справді цікаве нововведення. До Swift 6.2 ні XCTest, ні Swift Testing не могли тестувати код, який призводить до краху процесу — fatalError, preconditionFailure або вихід за межі масиву. Якщо такий код виконувався під час тесту, падав увесь тестовий раннер. Просто без варіантів.

Exit-тести вирішують цю проблему елегантно: код запускається в окремому дочірньому процесі. Якщо він падає — падає лише цей ізольований процес, а тестовий набір продовжує працювати як ні в чому не бувало.

Базове використання

@Test func outOfBoundsAccessCrashes() async {
    await #expect(processExitsWith: .failure) {
        let array = [1, 2, 3]
        _ = array[10] // Краш — але лише в дочірньому процесі
    }
}

Тестування precondition і fatalError

@Test func engineRequiresInitialization() async {
    await #expect(processExitsWith: .failure) {
        let engine = GameEngine()
        engine.state = .uninitialized
        engine.start() // preconditionFailure("Engine must be initialized")
    }
}

@Test func invalidConfigurationIsFatal() async {
    await #expect(processExitsWith: .failure) {
        let config = AppConfig(apiKey: "")
        config.validate() // fatalError("API key cannot be empty")
    }
}

Перевірка конкретного коду виходу

@Test func cliToolReportsUsageError() async {
    await #expect(processExitsWith: .exitCode(64)) {
        CLITool.run(arguments: ["--invalid-flag"])
    }
}

@Test func successfulExit() async {
    await #expect(processExitsWith: .success) {
        CLITool.run(arguments: ["--version"])
    }
}

Exit-тести підтримують .success, .failure та .exitCode(_) для перевірки конкретного коду завершення. На практиці найчастіше використовується .failure — щоб переконатися, що захисні перевірки у коді дійсно спрацьовують, коли мають.

Нове у Swift 6.2: вкладення (Attachments)

Вкладення дозволяють прикріплювати довільні дані до результатів тестів. Коли тест провалюється — особливо на CI, де не можна просто натиснути «Debug» — ви отримуєте повний контекст того, що саме пішло не так.

Для створення вкладення потрібен тип, який відповідає протоколу Attachable. Swift Testing надає вбудовані відповідності для стандартних типів.

import Testing

@Test func userDeletion() async throws {
    let users = try await database.fetchAllUsers()
    Attachment.record(users, named: "Користувачі до видалення")

    try await userService.deleteUser(targetId)

    let remainingUsers = try await database.fetchAllUsers()
    Attachment.record(remainingUsers, named: "Користувачі після видалення")

    #expect(!remainingUsers.contains { $0.id == targetId })
}

Кастомний тип з Attachable

Хочете прикріпити власний тип? Реалізуйте протокол Attachable:

struct NetworkSnapshot: Attachable, Codable {
    let url: String
    let statusCode: Int
    let responseBody: String
    let timestamp: Date

    func withAttachment(
        _ attachment: borrowing Attachment<Self>,
        perform: (borrowing Attachment<Self>) throws -> Void
    ) throws {
        try perform(attachment)
    }
}

@Test func apiReturnsExpectedData() async throws {
    let response = try await apiClient.fetch("/users")
    let snapshot = NetworkSnapshot(
        url: "/users",
        statusCode: response.statusCode,
        responseBody: String(data: response.body, encoding: .utf8) ?? "",
        timestamp: Date()
    )
    Attachment.record(snapshot, named: "API Response Snapshot")
    #expect(response.statusCode == 200)
}

Вкладення включаються у тестові звіти в Xcode або записуються на диск при запуску з командного рядка. Для CI/CD пайплайнів це просто незамінна річ — замість «тест впав, розбирайтесь самі» ви отримуєте всю діагностичну інформацію.

Нове у Swift 6.1+: кастомні трейти з TestScoping

Починаючи зі Swift 6.1, можна створювати власні трейти, які виконують логіку до та після тестів. Якщо ви колись писали один і той самий setUp/tearDown у кількох тестових класах XCTest — ви зрозумієте, наскільки це зручно.

Для створення кастомного трейту потрібно реалізувати два протоколи: TestTrait і TestScoping:

struct MockAPICredentialsTrait: TestTrait, TestScoping {
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: @Sendable () async throws -> Void
    ) async throws {
        let mockCredentials = APICredentials(apiKey: "test-key-12345")
        try await APICredentials.$current.withValue(mockCredentials) {
            try await function()
        }
    }
}

extension Trait where Self == MockAPICredentialsTrait {
    static var mockAPICredentials: Self { Self() }
}

Тепер будь-який тест може використовувати цей трейт одним рядком:

@Test(.mockAPICredentials)
func fetchUserProfile() async throws {
    let profile = try await ProfileService.fetchCurrent()
    #expect(profile.name == "Test User")
}

Трейт для тестової бази даних

Ось більш практичний приклад — трейт, який створює in-memory базу даних для кожного тесту і прибирає за собою:

struct InMemoryDatabaseTrait: TestTrait, TestScoping {
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: @Sendable () async throws -> Void
    ) async throws {
        let db = try await TestDatabase.createInMemory()
        try await TestDatabase.$current.withValue(db) {
            try await function()
        }
        try await db.destroy()
    }
}

extension Trait where Self == InMemoryDatabaseTrait {
    static var inMemoryDatabase: Self { Self() }
}

@Test(.inMemoryDatabase)
func insertAndFetch() async throws {
    let db = TestDatabase.current
    try await db.insert(User(name: "Оксана"))
    let users = try await db.fetchAll()
    #expect(users.count == 1)
}

Цей підхід добре працює з паралельним виконанням тестів, оскільки використовує TaskLocal — кожен тест отримує власний ізольований контекст. Ніяких конфліктів між паралельними тестами.

Міграція з XCTest: покрокова стратегія

Переходити на Swift Testing можна (і варто) поступово. Обидва фреймворки чудово співіснують в одному тестовому таргеті і навіть в одному файлі. Ось практична стратегія:

Крок 1: Нові тести пишіть на Swift Testing

Не переписуйте старі тести. Просто починайте писати нові, використовуючи import Testing замість import XCTest. Це найпростіший спосіб почати.

Крок 2: Конвертуйте прості тести

Почніть з найпростіших тестів — без складних setUp/tearDown і без залежності від стану класу:

// Було (XCTest):
class MathTests: XCTestCase {
    func testAddition() {
        XCTAssertEqual(Calculator.add(2, 3), 5)
    }

    func testDivisionByZero() {
        XCTAssertNil(Calculator.divide(10, 0))
    }
}

// Стало (Swift Testing):
@Suite("Калькулятор")
struct MathTests {
    @Test("Додавання") func addition() {
        #expect(Calculator.add(2, 3) == 5)
    }

    @Test("Ділення на нуль") func divisionByZero() {
        #expect(Calculator.divide(10, 0) == nil)
    }
}

Крок 3: Перетворіть однотипні тести на параметризовані

Якщо у вас є кілька тестів, які роблять одне й те саме з різними даними — об'єднайте їх. Менше коду, більше покриття:

// Було: 5 окремих тестів
func testParseValidInteger() { XCTAssertNotNil(Int("42")) }
func testParseNegativeInteger() { XCTAssertNotNil(Int("-7")) }
func testParseZero() { XCTAssertNotNil(Int("0")) }

// Стало: один параметризований тест
@Test("Парсинг цілих чисел", arguments: ["42", "-7", "0", "999", "-100"])
func parseInteger(input: String) {
    #expect(Int(input) != nil)
}

Крок 4: Замініть setUp/tearDown на init або кастомні трейти

Використовуйте init() для ініціалізації стану або кастомні трейти для спільної логіки налаштування (ми розглянули це вище).

Важливі обмеження при міграції

  • Не змішуйте макроси — не викликайте XCTAssert у Swift Testing тестах і навпаки. Це просто не працюватиме коректно.
  • UI-тести залишаються на XCTestXCUIApplication не підтримується в Swift Testing.
  • Тести продуктивності залишаються на XCTestXCTMetric поки не має аналогу.
  • Objective-C тести залишаються на XCTest — Swift Testing працює лише зі Swift.

Автоматизація міграції

Якщо у вас великий проєкт, ручна міграція може бути довгою. Існують інструменти для автоматичної конвертації: Testpiler — macOS-додаток для конвертації XCTest у Swift Testing, та swift-testing-revolutionary — CLI-інструмент і плагін Xcode для масової конвертації тестових файлів.

Поширені запитання (FAQ)

Чи є Swift Testing заміною XCTest?

Не повністю. XCTest залишається необхідним для UI-тестування, тестування продуктивності та Objective-C тестів. Але для юніт-тестів і інтеграційних тестів Swift Testing — значно кращий вибір завдяки сучасному синтаксису, параметризованим тестам і нативній підтримці async/await.

Чи можна використовувати Swift Testing і XCTest одночасно?

Так, без проблем. Обидва фреймворки співіснують в одному тестовому таргеті і навіть в одному файлі. Єдине правило — не змішуйте макроси між ними.

Яка мінімальна версія Xcode та Swift потрібна?

Базовий Swift Testing працює з Xcode 16 та Swift 6. Для кастомних трейтів потрібен Swift 6.1. Для exit-тестів та вкладень — Swift 6.2 і Xcode 26.

Чи підтримує Swift Testing тести на фізичних пристроях?

Так, і це одна з суттєвих переваг. XCTest використовує кілька екземплярів симулятора для паралелізації, а Swift Testing використовує Swift Concurrency і запускає тести паралельно в одному процесі. Це працює і на реальних девайсах.

Як запускати тести з командного рядка?

Для Swift-пакетів — swift test. Для Xcode-проєктів — xcodebuild test. Swift Testing повністю інтегрований з обома інструментами, додаткові налаштування не потрібні.

Про Автора Priya Raghavan

Priya spent six years at Instacart building the iOS shopper app, where she led the migration from UIKit to SwiftUI across 80+ screens and cut crash-free sessions from 99.2% to 99.87%. Before that, she was a contractor at a Bay Area design studio shipping App Store apps for two Fortune 500 retail clients. She focuses on practical SwiftUI architecture - what holds up when you have 12 engineers committing to the same codebase, not just toy MVVM examples. Her recent work involves The Composable Architecture, Swift concurrency migration audits, and reducing main-thread hangs on older devices like the iPhone XR that enterprise fleets still ship. Priya runs a small consultancy in Oakland and occasionally speaks at try! Swift NYC. She has been writing Swift since the Objective-C bridging days of 2015.