Ако пишете 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. Тук няма изненада.
Стратегия за миграция
- Започнете с нови тестове — пишете ги директно със Swift Testing
- Мигрирайте прости unit тестове първо — те обикновено са 1:1 преобразуване
- Мигрирайте async тестове — те стават значително по-чисти
- Оставете UI и performance тестовете в XCTest
- Премахнете 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, писането им най-накрая е толкова приятно, колкото и писането на самия продуктов код. Успех и нека тестовете ви винаги са зелени!