Вступ
Якщо ви пишете юніт-тести у 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-тести залишаються на XCTest —
XCUIApplicationне підтримується в Swift Testing. - Тести продуктивності залишаються на XCTest —
XCTMetricпоки не має аналогу. - 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 повністю інтегрований з обома інструментами, додаткові налаштування не потрібні.