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

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

Вступ

Якщо ви пишете юніт-тести у 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 повністю інтегрований з обома інструментами, додаткові налаштування не потрібні.

Про Автора Editorial Team

Our team of expert writers and editors.