Введение: почему Swift Testing — это будущее тестирования
Apple представила фреймворк Swift Testing на WWDC 2024, и, честно говоря, он сразу изменил правила игры. В отличие от XCTest, который десятилетиями служил основным инструментом тестирования, Swift Testing спроектирован с нуля специально для языка Swift — макросы, конкурентность, современные паттерны. Всё то, чего нам так давно не хватало.
Начиная с Xcode 16 и Swift 6, фреймворк входит в стандартную поставку и не требует никаких дополнительных зависимостей.
В этом руководстве мы разберём все ключевые возможности Swift Testing: от базовых утверждений до параметризованных тестов, трейтов, тегов и миграции с XCTest. К концу статьи вы будете готовы перейти на новый фреймворк в своих проектах. Итак, давайте разбираться.
Основы: @Test и @Suite
Объявление тестовых функций с @Test
В XCTest для создания теста нужно было наследоваться от XCTestCase и именовать методы с префиксом test. Честно? Это всегда раздражало. Swift Testing кардинально упрощает этот процесс — достаточно пометить любую функцию атрибутом @Test:
import Testing
@Test func проверкаСложения() {
let результат = 2 + 2
#expect(результат == 4)
}
Вот и всё. Никаких классов, никакого наследования.
Тестовая функция может быть объявлена на верхнем уровне файла, внутри структуры, класса или даже актора. Макрос @Test также позволяет задать человекочитаемое имя теста:
@Test("Проверка корректности сложения двух чисел")
func проверкаСложения() {
#expect(2 + 2 == 4)
}
Это имя будет отображаться в Test Navigator в Xcode и в отчётах о тестировании — что, поверьте, значительно упрощает чтение результатов.
Организация тестов с @Suite
Любой тип, содержащий функции с @Test, автоматически становится тестовым набором (suite). Но для явного обозначения и настройки лучше использовать атрибут @Suite:
@Suite("Тесты калькулятора")
struct CalculatorTests {
let calculator = Calculator()
@Test("Сложение положительных чисел")
func сложение() {
#expect(calculator.add(3, 5) == 8)
}
@Test("Вычитание с отрицательным результатом")
func вычитание() {
#expect(calculator.subtract(3, 5) == -2)
}
}
Важная особенность, которую стоит запомнить: Swift Testing создаёт новый экземпляр тестового набора для каждой тестовой функции. Это гарантирует полную изоляцию между тестами — изменение состояния в одном тесте не повлияет на другие. Наконец-то можно забыть про случайные «flaky»-тесты из-за общего состояния.
Вложенные тестовые наборы
Swift Testing поддерживает иерархическую организацию тестов через вложенные типы:
@Suite("Тесты пользователя")
struct UserTests {
@Suite("Валидация имени")
struct NameValidation {
@Test func пустоеИмя() {
#expect(!User.isValidName(""))
}
@Test func слишкомДлинноеИмя() {
let longName = String(repeating: "а", count: 256)
#expect(!User.isValidName(longName))
}
}
@Suite("Валидация email")
struct EmailValidation {
@Test func корректныйEmail() {
#expect(User.isValidEmail("[email protected]"))
}
@Test func emailБезСобаки() {
#expect(!User.isValidEmail("testexample.com"))
}
}
}
Такая структура отображается в виде дерева в Xcode Test Navigator, и навигация по тестам становится по-настоящему интуитивной.
Утверждения: #expect и #require
Макрос #expect — универсальная проверка
Это, пожалуй, одно из самых приятных изменений. Вместо 40+ различных функций утверждений XCTest (всех этих XCTAssertEqual, XCTAssertNil, XCTAssertGreaterThan...), Swift Testing использует один-единственный макрос #expect, принимающий обычное Swift-выражение:
// Вместо XCTAssertEqual(a, b)
#expect(a == b)
// Вместо XCTAssertNil(value)
#expect(value == nil)
// Вместо XCTAssertGreaterThan(a, b)
#expect(a > b)
// Вместо XCTAssertTrue(array.contains(element))
#expect(array.contains(element))
Красота, правда? При неудачной проверке макрос автоматически показывает фактические значения переменных, что делает отладку куда проще. Поддерживаются и пользовательские сообщения:
#expect(age > 18, "Возраст пользователя должен быть больше 18, получено: \(age)")
Проверка ошибок с #expect
Swift Testing предоставляет элегантный синтаксис для проверки выбрасываемых ошибок:
// Проверка, что выбрасывается конкретная ошибка
#expect(throws: NetworkError.timeout) {
try networkService.fetchData(timeout: 0)
}
// Проверка типа ошибки
#expect(throws: NetworkError.self) {
try networkService.fetchData(from: invalidURL)
}
// Проверка, что ошибка НЕ выбрасывается
#expect(throws: Never.self) {
try calculator.divide(10, by: 2)
}
// Проверка с условной валидацией ошибки
#expect(performing: {
try userService.register(email: "invalid")
}, throws: { error in
guard let validationError = error as? ValidationError else {
return false
}
return validationError.field == "email"
})
Макрос #require — обязательные условия
Макрос #require используется для условий, без выполнения которых продолжение теста попросту не имеет смысла. Если проверка не проходит — тест немедленно прекращается:
@Test func загрузкаПрофиля() async throws {
// Если пользователь не найден, дальнейшее тестирование бессмысленно
let user = try #require(await userService.findUser(id: 42))
// Эти проверки выполнятся только если пользователь найден
#expect(user.name == "Иван")
#expect(user.isActive)
}
Особенно полезен #require для безопасного разворачивания опционалов — по сути, он заменяет XCTUnwrap из XCTest:
@Test func парсингJSON() throws {
let data = """
{"name": "Swift", "version": 6}
""".data(using: .utf8)
let jsonData = try #require(data)
let language = try #require(JSONDecoder().decode(Language.self, from: jsonData) as Language?)
#expect(language.name == "Swift")
#expect(language.version == 6)
}
Параметризованное тестирование
Одна из самых мощных возможностей Swift Testing — и, на мой взгляд, одна из главных причин для перехода — параметризованные тесты. Они позволяют запускать одну и ту же логику проверки с разными входными данными, полностью устраняя дублирование кода.
Базовый пример
@Test("Валидация email-адресов", arguments: [
"[email protected]",
"[email protected]",
"[email protected]"
])
func валидныеEmail(email: String) {
#expect(EmailValidator.isValid(email))
}
Каждое значение из массива arguments запускается как отдельный тест-кейс. В Xcode Test Navigator вы увидите дерево с каждым значением, а при неудаче можно перезапустить конкретный кейс индивидуально. Очень удобно.
Использование zip для парных аргументов
Когда нужно проверить соответствие входных данных ожидаемым результатам, пригодится функция zip:
@Test("Конвертация температуры", arguments: zip(
[0.0, 100.0, -40.0, 37.0],
[32.0, 212.0, -40.0, 98.6]
))
func конвертацияЦельсияВФаренгейт(celsius: Double, expectedFahrenheit: Double) {
let result = TemperatureConverter.toFahrenheit(celsius)
#expect(abs(result - expectedFahrenheit) < 0.01)
}
Важный нюанс: без zip использование нескольких аргументных массивов приведёт к декартовому произведению (каждый элемент первого массива будет комбинироваться с каждым элементом второго). Так что zip — ваш друг для попарного сопоставления.
Перечисления как аргументы
Особенно удобно использовать перечисления, реализующие протокол CaseIterable:
enum SupportedLanguage: String, CaseIterable, Sendable {
case swift, kotlin, dart, rust
}
@Test("Все языки имеют описание", arguments: SupportedLanguage.allCases)
func языкИмеетОписание(language: SupportedLanguage) {
let description = LanguageInfo.description(for: language)
#expect(!description.isEmpty)
}
Улучшение читаемости с CustomTestStringConvertible
Для сложных типов данных в параметризованных тестах рекомендуется реализовать протокол CustomTestStringConvertible — так отчёты станут гораздо информативнее:
struct TestUser: Sendable {
let name: String
let age: Int
let isActive: Bool
}
extension TestUser: CustomTestStringConvertible {
var testDescription: String {
"\(name) (возраст: \(age), активен: \(isActive))"
}
}
@Test("Проверка прав доступа", arguments: [
TestUser(name: "Анна", age: 25, isActive: true),
TestUser(name: "Борис", age: 17, isActive: true),
TestUser(name: "Вера", age: 30, isActive: false)
])
func проверкаДоступа(user: TestUser) {
let hasAccess = AccessControl.canAccess(user)
if user.age >= 18 && user.isActive {
#expect(hasAccess)
} else {
#expect(!hasAccess)
}
}
Трейты: настройка поведения тестов
Трейты (traits) — это механизм конфигурации, управляющий тем, как и когда выполняются тесты. Они применяются к @Test и @Suite и наследуются от родительского набора к дочерним тестам.
Условное выполнение
// Тест выполняется только при определённом условии
@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil))
func тестТолькоНаCI() {
// Интеграционный тест, запускаемый только на CI
}
// Отключённый тест с пояснением
@Test(.disabled("Ожидаем исправления бага FB12345"))
func временноОтключенныйТест() {
// Этот тест будет пропущен
}
Привязка к багам
@Test(.bug("https://github.com/project/issues/42", "Некорректная сортировка"))
func тестСортировки() {
let sorted = [3, 1, 2].customSort()
#expect(sorted == [1, 2, 3])
}
Ограничение времени выполнения
@Test(.timeLimit(.minutes(2)))
func тестДолгойОперации() async {
let result = await heavyComputation()
#expect(result.isValid)
}
Последовательное выполнение с .serialized
По умолчанию тесты в Swift Testing выполняются параллельно. Но если тесты в наборе зависят от общего состояния или должны идти в определённом порядке, используйте трейт .serialized:
@Suite("Тесты базы данных", .serialized)
struct DatabaseTests {
@Test func создание() async throws {
try await database.create(record: testRecord)
let count = try await database.count()
#expect(count == 1)
}
@Test func чтение() async throws {
let record = try await database.read(id: testRecord.id)
#expect(record != nil)
}
@Test func удаление() async throws {
try await database.delete(id: testRecord.id)
let count = try await database.count()
#expect(count == 0)
}
}
Теги: гибкая классификация тестов
Теги позволяют группировать тесты по произвольным категориям, независимо от их расположения в коде. На практике это невероятно удобный инструмент для выборочного запуска тестов.
Определение и использование тегов
// Определение тегов
extension Tag {
@Tag static var критичный: Self
@Tag static var интеграционный: Self
@Tag static var медленный: Self
@Tag static var сетевой: Self
}
// Применение тегов к тестам
@Test(.tags(.критичный, .сетевой))
func загрузкаДанных() async throws {
let data = try await api.fetchUsers()
#expect(!data.isEmpty)
}
// Применение тега ко всему набору
@Suite(.tags(.интеграционный))
struct IntegrationTests {
@Test func тестБазыДанных() { /* ... */ }
@Test func тестAPI() { /* ... */ }
}
Теги, применённые к @Suite, автоматически наследуются всеми тестами внутри набора. В Xcode Test Navigator можно переключить группировку на «Tags» — и вот вы уже видите тесты по логическим группам, а не по файловой структуре.
Запуск тестов по тегам из командной строки
# Запуск только критичных тестов
swift test --filter .критичный
# Запуск всех тестов, кроме медленных
swift test --skip .медленный
Подтверждения (Confirmations): замена XCTestExpectation
Если вы когда-нибудь мучились с паттерном XCTestExpectation + wait(for:timeout:) — вы точно оцените подтверждения в Swift Testing. Это гораздо более чистый и понятный механизм.
Базовое использование
@Test("Делегат вызывается при успешной загрузке")
func делегатВызывается() async {
await confirmation("обратный вызов загрузки") { confirm in
let loader = DataLoader()
loader.onComplete = { _ in
confirm()
}
await loader.load(from: testURL)
}
}
Проверка количества вызовов
@Test("Обработчик событий вызывается трижды")
func количествоВызовов() async {
await confirmation("событие нажатия", expectedCount: 3) { pressed in
let handler = EventHandler()
handler.onKeyPress = { event in
if event == .keyDown {
pressed()
}
}
// Эмулируем три нажатия
await handler.simulateKeyPresses(count: 3)
}
}
Проверка отсутствия вызова
@Test("Обработчик ошибок не вызывается при успехе")
func безОшибок() async {
await confirmation("обработчик ошибок", expectedCount: 0) { errorHandler in
let service = PaymentService()
service.onError = { _ in
errorHandler()
}
await service.processPayment(amount: 100)
}
}
withKnownIssue: работа с известными проблемами
У каждого проекта есть известные баги, до которых ещё не дошли руки. Раньше приходилось либо отключать тесты, либо мириться с красными отметками. withKnownIssue решает эту проблему элегантно — тест не будет считаться проваленным, но если баг вдруг исправят, вы получите уведомление:
@Test func тестСИзвестнымБагом() {
withKnownIssue("Сортировка не работает для пустых массивов") {
let result = customSort([])
#expect(result == [])
}
}
// Для нестабильных тестов
@Test func нестабильныйТест() {
withKnownIssue(isIntermittent: true) {
let result = try flakyNetworkCall()
#expect(result.isSuccess)
}
}
Параметр isIntermittent: true указывает, что проблема возникает не всегда — тест не будет считаться проваленным, даже если иногда проходит успешно.
Жизненный цикл тестов и управление состоянием
Инициализация и очистка
В Swift Testing setup и teardown перемещаются в обычные init и deinit. Никакой магии с override — просто стандартные возможности языка:
@Suite("Тесты файловой системы")
final class FileSystemTests {
let tempDirectory: URL
let fileManager: FileManager
init() throws {
fileManager = FileManager.default
tempDirectory = fileManager.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try fileManager.createDirectory(
at: tempDirectory,
withIntermediateDirectories: true
)
}
deinit {
try? fileManager.removeItem(at: tempDirectory)
}
@Test func создание_файла() throws {
let fileURL = tempDirectory.appendingPathComponent("test.txt")
try "Hello".write(to: fileURL, atomically: true, encoding: .utf8)
#expect(fileManager.fileExists(atPath: fileURL.path))
}
@Test func удаление_файла() throws {
let fileURL = tempDirectory.appendingPathComponent("delete_me.txt")
try "".write(to: fileURL, atomically: true, encoding: .utf8)
try fileManager.removeItem(at: fileURL)
#expect(!fileManager.fileExists(atPath: fileURL.path))
}
}
Обратите внимание: для использования deinit нужен класс (или актор), а не структура. Каждый тест получает свой собственный экземпляр, так что инициализация и очистка выполняются изолированно.
Рекомендация: предпочитайте структуры
Apple рекомендует структуры в качестве тестовых наборов, если deinit вам не нужен. Структуры легче, безопаснее в многопоточной среде и лучше работают с параллельным выполнением. В большинстве случаев — это правильный выбор.
Реальный пример: тестирование сетевого слоя
Теория — это хорошо, но давайте посмотрим на что-то приближённое к реальной жизни. Вот комплексный пример тестирования сетевого слоя, который объединяет большинство возможностей Swift Testing.
Определение протокола и мока
// Протокол сетевого клиента
protocol NetworkClient: Sendable {
func fetch<T: Decodable>(from endpoint: Endpoint) async throws -> T
}
// Мок для тестирования
final class MockNetworkClient: NetworkClient, @unchecked Sendable {
var responses: [String: Any] = [:]
var errors: [String: Error] = [:]
var callCount: [String: Int] = [:]
func fetch<T: Decodable>(from endpoint: Endpoint) async throws -> T {
let key = endpoint.path
callCount[key, default: 0] += 1
if let error = errors[key] {
throw error
}
guard let response = responses[key] as? T else {
throw NetworkError.invalidResponse
}
return response
}
}
Полный тестовый набор
import Testing
extension Tag {
@Tag static var networking: Self
@Tag static var userFeature: Self
}
@Suite("Тесты репозитория пользователей", .tags(.networking, .userFeature))
struct UserRepositoryTests {
let mockClient: MockNetworkClient
let repository: UserRepository
init() {
mockClient = MockNetworkClient()
repository = UserRepository(client: mockClient)
}
// MARK: - Успешные сценарии
@Test("Загрузка списка пользователей")
func загрузкаСписка() async throws {
// Подготовка
let expectedUsers = [
User(id: 1, name: "Алексей", email: "[email protected]"),
User(id: 2, name: "Мария", email: "[email protected]")
]
mockClient.responses["/users"] = expectedUsers
// Действие
let users = try await repository.getAllUsers()
// Проверка
#expect(users.count == 2)
#expect(users[0].name == "Алексей")
#expect(users[1].email == "[email protected]")
}
@Test("Загрузка пользователя по ID", arguments: [1, 2, 42, 100])
func загрузкаПоID(userId: Int) async throws {
let expectedUser = User(id: userId, name: "Тест \(userId)", email: "test\(userId)@mail.com")
mockClient.responses["/users/\(userId)"] = expectedUser
let user = try await repository.getUser(id: userId)
#expect(user.id == userId)
#expect(user.name == "Тест \(userId)")
}
// MARK: - Сценарии с ошибками
@Test("Обработка сетевых ошибок", arguments: [
NetworkError.timeout,
NetworkError.noConnection,
NetworkError.serverError(500)
])
func обработкаОшибок(expectedError: NetworkError) async {
mockClient.errors["/users"] = expectedError
#expect(throws: NetworkError.self) {
try await repository.getAllUsers()
}
}
// MARK: - Кэширование
@Test("Повторный запрос использует кэш")
func кэширование() async throws {
let user = User(id: 1, name: "Кэшированный", email: "[email protected]")
mockClient.responses["/users/1"] = user
// Первый запрос — обращение к сети
_ = try await repository.getUser(id: 1)
// Второй запрос — должен использовать кэш
_ = try await repository.getUser(id: 1)
#expect(mockClient.callCount["/users/1"] == 1)
}
}
Этот пример демонстрирует реалистичный подход: моки, параметризованные тесты для разных ID и типов ошибок, проверка кэширования через подсчёт вызовов и теги для классификации. Всё в одном месте, всё читаемо.
Тестирование SwiftUI ViewModel
Swift Testing отлично подходит для тестирования ViewModel в архитектуре MVVM. Лично я считаю, что именно здесь новый фреймворк раскрывается в полной мере. Рассмотрим типичный пример:
@Observable
class ArticleListViewModel {
private(set) var articles: [Article] = []
private(set) var isLoading = false
private(set) var errorMessage: String?
private let repository: ArticleRepository
init(repository: ArticleRepository) {
self.repository = repository
}
func loadArticles() async {
isLoading = true
errorMessage = nil
do {
articles = try await repository.fetchArticles()
} catch {
errorMessage = "Не удалось загрузить статьи: \(error.localizedDescription)"
}
isLoading = false
}
func deleteArticle(at index: Int) async throws {
guard index >= 0 && index < articles.count else {
throw AppError.invalidIndex
}
let article = articles[index]
try await repository.delete(article)
articles.remove(at: index)
}
}
@Suite("Тесты ArticleListViewModel")
struct ArticleListViewModelTests {
let mockRepository: MockArticleRepository
init() {
mockRepository = MockArticleRepository()
}
@Test("Начальное состояние ViewModel")
func начальноеСостояние() {
let viewModel = ArticleListViewModel(repository: mockRepository)
#expect(viewModel.articles.isEmpty)
#expect(!viewModel.isLoading)
#expect(viewModel.errorMessage == nil)
}
@Test("Успешная загрузка статей")
func успешнаяЗагрузка() async {
let testArticles = [
Article(id: 1, title: "Swift Testing", content: "..."),
Article(id: 2, title: "SwiftUI Tips", content: "...")
]
mockRepository.articles = testArticles
let viewModel = ArticleListViewModel(repository: mockRepository)
await viewModel.loadArticles()
#expect(viewModel.articles.count == 2)
#expect(!viewModel.isLoading)
#expect(viewModel.errorMessage == nil)
}
@Test("Ошибка загрузки устанавливает сообщение")
func ошибкаЗагрузки() async {
mockRepository.shouldFail = true
let viewModel = ArticleListViewModel(repository: mockRepository)
await viewModel.loadArticles()
#expect(viewModel.articles.isEmpty)
#expect(!viewModel.isLoading)
#expect(viewModel.errorMessage != nil)
}
@Test("Удаление статьи по индексу")
func удаление() async throws {
mockRepository.articles = [
Article(id: 1, title: "Первая", content: ""),
Article(id: 2, title: "Вторая", content: ""),
Article(id: 3, title: "Третья", content: "")
]
let viewModel = ArticleListViewModel(repository: mockRepository)
await viewModel.loadArticles()
try await viewModel.deleteArticle(at: 1)
#expect(viewModel.articles.count == 2)
#expect(viewModel.articles[0].title == "Первая")
#expect(viewModel.articles[1].title == "Третья")
}
@Test("Удаление по невалидному индексу", arguments: [-1, 100, 999])
func удалениеНевалидныйИндекс(index: Int) async {
let viewModel = ArticleListViewModel(repository: mockRepository)
#expect(throws: AppError.invalidIndex) {
try await viewModel.deleteArticle(at: index)
}
}
}
Тестирование ViewModel — один из важнейших аспектов обеспечения качества SwiftUI-приложений. И здесь Swift Testing по-настоящему сияет благодаря встроенной поддержке async/await и выразительным макросам.
Интеграция с Swift Concurrency
Swift Testing глубоко интегрирован с системой конкурентности Swift. Тестовые функции могут быть асинхронными и выбрасывающими ошибки — и это работает из коробки, без всяких обёрток:
@Test func загрузкаИПарсингДанных() async throws {
let service = APIService()
let users = try await service.fetchUsers()
#expect(users.count > 0)
let firstUser = try #require(users.first)
#expect(!firstUser.name.isEmpty)
#expect(firstUser.email.contains("@"))
}
Работа с акторами
actor Counter {
private var value = 0
func increment() { value += 1 }
func getValue() -> Int { value }
}
@Suite("Тесты актора Counter")
struct CounterTests {
@Test func инкремент() async {
let counter = Counter()
await counter.increment()
await counter.increment()
await counter.increment()
let value = await counter.getValue()
#expect(value == 3)
}
}
Тестирование с TaskGroup
@Test("Параллельная обработка данных")
func параллельнаяОбработка() async throws {
let processor = DataProcessor()
let items = (1...100).map { DataItem(id: $0) }
let results = try await processor.processInParallel(items)
#expect(results.count == 100)
#expect(results.allSatisfy { $0.isProcessed })
}
Миграция с XCTest: пошаговое руководство
Хорошая новость: XCTest и Swift Testing прекрасно сосуществуют в одном тестовом таргете. Можно мигрировать постепенно — писать новые тесты уже на Swift Testing и конвертировать старые по мере необходимости. Никакого «всё или ничего».
Таблица соответствий
// ╔════════════════════════════════════╦════════════════════════════════════╗
// ║ XCTest ║ Swift Testing ║
// ╠════════════════════════════════════╬════════════════════════════════════╣
// ║ class MyTests: XCTestCase { } ║ @Suite struct MyTests { } ║
// ║ func testSomething() ║ @Test func something() ║
// ║ XCTAssertEqual(a, b) ║ #expect(a == b) ║
// ║ XCTAssertNil(x) ║ #expect(x == nil) ║
// ║ XCTAssertThrowsError(expr) ║ #expect(throws: Error.self) { } ║
// ║ try XCTUnwrap(optional) ║ try #require(optional) ║
// ║ XCTestExpectation + wait ║ confirmation { } ║
// ║ setUpWithError() ║ init() throws ║
// ║ tearDown() ║ deinit ║
// ║ XCTSkipIf(condition) ║ .enabled(if: !condition) ║
// ╚════════════════════════════════════╩════════════════════════════════════╝
Пример миграции
До (XCTest):
import XCTest
class UserServiceTests: XCTestCase {
var sut: UserService!
override func setUpWithError() throws {
sut = UserService(database: MockDatabase())
}
override func tearDownWithError() throws {
sut = nil
}
func testCreateUser() throws {
let user = try sut.createUser(name: "Иван", email: "[email protected]")
XCTAssertNotNil(user)
XCTAssertEqual(user?.name, "Иван")
}
func testCreateUserWithInvalidEmail() {
XCTAssertThrowsError(try sut.createUser(name: "Иван", email: "invalid")) { error in
XCTAssertTrue(error is ValidationError)
}
}
func testFetchUser() {
let expectation = expectation(description: "User fetched")
sut.fetchUser(id: 1) { result in
if case .success(let user) = result {
XCTAssertEqual(user.name, "Иван")
} else {
XCTFail("Expected success")
}
expectation.fulfill()
}
wait(for: [expectation], timeout: 5)
}
}
После (Swift Testing):
import Testing
@Suite("Тесты UserService")
struct UserServiceTests {
let sut: UserService
init() {
sut = UserService(database: MockDatabase())
}
@Test("Создание пользователя")
func создание() throws {
let user = try #require(sut.createUser(name: "Иван", email: "[email protected]"))
#expect(user.name == "Иван")
}
@Test("Создание с невалидным email")
func невалидныйEmail() {
#expect(throws: ValidationError.self) {
try sut.createUser(name: "Иван", email: "invalid")
}
}
@Test("Загрузка пользователя")
func загрузка() async throws {
let user = try await sut.fetchUser(id: 1)
#expect(user.name == "Иван")
}
}
Обратите внимание, как сократился объём кода. Исчезли setUp/tearDown, XCTestExpectation, множественные функции утверждений. Асинхронный тест теперь просто использует async/await напрямую. Ощущение, что с плеч сбросили тяжёлый рюкзак.
Комбинирование трейтов: практический пример
Трейты можно (и нужно) комбинировать для точной настройки поведения тестов:
@Suite("Интеграционные тесты API", .serialized, .tags(.интеграционный, .сетевой))
struct APIIntegrationTests {
@Test(
"Загрузка списка продуктов",
.enabled(if: ProcessInfo.processInfo.environment["API_KEY"] != nil),
.timeLimit(.minutes(1)),
.bug("https://issues.example.com/123", "Иногда возвращает 500")
)
func загрузкаПродуктов() async throws {
let api = ProductAPI()
let products = try await api.fetchProducts()
#expect(products.count > 0)
#expect(products.allSatisfy { $0.price > 0 })
}
@Test(
"Поиск по категории",
.tags(.медленный),
.timeLimit(.minutes(2))
)
func поискПоКатегории() async throws {
let api = ProductAPI()
let electronics = try await api.search(category: "электроника")
#expect(!electronics.isEmpty)
}
}
Интеграция с Xcode
Swift Testing тесно интегрирован с Xcode — и это чувствуется буквально на каждом шагу.
Test Navigator и группировка по тегам
В Xcode Test Navigator тесты Swift Testing отображаются с иконкой ромба (вместо привычных кружков XCTest). Визуально сразу видно, какие тесты новые, а какие старые. Можно переключить группировку на вкладку «Tags» — и тесты сгруппируются по вашим тегам, а не по файлам. Мелочь, а удобно.
Улучшенные отчёты о сбоях
При провале теста Xcode показывает не просто «тест упал», а полное выражение с фактическими значениями. Если #expect(user.age > 18) не проходит, вы увидите что-то вроде: «Ожидалось, что 15 > 18 будет true». Никакого угадывания, что же пошло не так.
Test Report с вкладкой Insights
Xcode генерирует подробные отчёты о тестировании с вкладкой Insights. Она автоматически анализирует паттерны — какие тесты чаще падают, какие занимают больше времени, какие комбинации параметризованных тестов проблемны. Полезно для больших проектов, где сотни тестов.
SwiftUI Instruments
Начиная с Xcode 16, доступен специализированный шаблон SwiftUI Instruments для профилирования тестов. Swift Testing не поддерживает XCTMetric напрямую, но вы можете использовать Instruments для анализа производительности тестируемого кода в связке с новым фреймворком.
Ограничения Swift Testing
При всех преимуществах, у Swift Testing есть ряд ограничений, о которых стоит знать заранее:
- UI-тестирование: Swift Testing не поддерживает
XCUIApplicationи автоматизацию интерфейса. Для UI-тестов по-прежнему нужен XCTest. - Метрики производительности:
XCTMetricиmeasure { }недоступны. Для бенчмарков придётся остаться на XCTest или использовать специализированные инструменты. - Objective-C: Swift Testing работает только с кодом на Swift. Тесты для Objective-C должны оставаться в XCTest.
- Порядок выполнения: даже с
.serializedнет гарантии конкретного порядка — гарантируется только последовательность, но не какой именно тест пойдёт первым. - Нет глобальных setup/teardown: в отличие от XCTest с его
class func setUp(), Swift Testing не предоставляет аналога глобальной инициализации для всего набора.
Важно: XCTest не является устаревшим. Apple чётко обозначила, что оба фреймворка будут развиваться параллельно. Для UI-тестирования и измерения производительности XCTest остаётся единственным вариантом.
Лучшие практики
1. Используйте структуры для тестовых наборов
Структуры обеспечивают лучшую изоляцию и безопасность в многопоточной среде. Классы — только когда действительно нужен deinit.
2. Предпочитайте #expect для большинства проверок
#require — только для условий, без которых продолжение теста бессмысленно. Разворачивание опционалов, проверка предусловий — вот его ниша.
3. Параметризуйте повторяющиеся тесты
Вместо копирования тестовой логики для разных входных данных используйте параметризованные тесты. Меньше дублирования, проще добавлять новые кейсы.
4. Используйте теги для организации
Теги особенно полезны для разделения быстрых модульных тестов и медленных интеграционных. На CI можно запускать только быстрые тесты при каждом коммите, а полный набор — перед релизом. Экономит кучу времени.
5. Давайте тестам осмысленные имена
Строковый параметр @Test("описание") позволяет использовать человекочитаемые названия на любом языке — с пробелами и спецсимволами. Пользуйтесь этим, будущие вы скажут спасибо при чтении отчётов.
6. Мигрируйте постепенно
Нет необходимости переводить все тесты за один раз. Начните с новых тестов, а старые конвертируйте по мере необходимости. Оба фреймворка прекрасно живут бок о бок.
Сравнение производительности: Swift Testing vs XCTest
Одно из ключевых преимуществ Swift Testing — параллельное выполнение тестов по умолчанию. В XCTest тесты внутри одного XCTestCase идут последовательно, а параллельность достигается только на уровне таргетов через мультипроцессность. Swift Testing использует внутрипроцессную параллельность на основе Swift Concurrency, что существенно снижает накладные расходы.
На практике это означает:
- Быстрый запуск: не нужно создавать отдельные процессы для каждого тестового таргета
- Меньше памяти: все тесты работают в одном процессе
- Обнаружение скрытых зависимостей: параллельное выполнение выявляет тесты, которые неявно зависят от общего состояния (и это отличная возможность их починить)
- Масштабируемость: фреймворк эффективно задействует все доступные ядра
Если ваш проект содержит сотни модульных тестов, переход на Swift Testing может заметно ускорить полный прогон. Особенно это ощутимо на машинах с Apple Silicon, где многоядерная архитектура используется на полную.
Работа с Swift Package Manager
Swift Testing полностью поддерживается в проектах на основе Swift Package Manager. Для серверных Swift-приложений или кроссплатформенных библиотек это особенно важно:
# Запуск всех тестов
swift test
# Запуск конкретного тестового набора
swift test --filter UserRepositoryTests
# Запуск тестов с подробным выводом
swift test --verbose
# Запуск тестов параллельно (по умолчанию)
swift test --parallel
Swift Testing не нужно добавлять в зависимости — он автоматически доступен в тестовых таргетах начиная со Swift 6. Просто import Testing — и вперёд.
Для проектов на более ранних версиях Swift можно добавить фреймворк как пакетную зависимость через GitHub-репозиторий swift-testing. Но рекомендую обновиться до Swift 6, чтобы получить все преимущества встроенной интеграции.
Заключение
Swift Testing — это значительный шаг вперёд в тестировании Swift-приложений. Фреймворк использует лучшие возможности языка (макросы, конкурентность, типы-значения) для создания более выразительных, безопасных и поддерживаемых тестов.
Ключевые преимущества, ради которых стоит переходить:
- Простота: два макроса (
#expectи#require) вместо 40+ функций XCTest - Изоляция: новый экземпляр тестового набора для каждого теста — никаких побочных эффектов
- Параметризация: один тест с множеством входных данных без дублирования
- Параллелизм: параллельное выполнение по умолчанию на многоядерных процессорах
- Гибкость: трейты и теги для тонкой настройки поведения и классификации тестов
- Интеграция: глубокая связка с Xcode, Swift Concurrency и Swift Package Manager
XCTest при этом не устарел и по-прежнему необходим для UI-тестирования и бенчмарков. Но для модульных и интеграционных тестов Swift Testing — однозначно лучший выбор. Оба фреймворка отлично сосуществуют, так что миграция может быть постепенной и безболезненной.
Попробуйте Swift Testing в своём следующем проекте — добавьте import Testing, напишите первую функцию с @Test, и вы сразу почувствуете разницу. Современный синтаксис, встроенная поддержка конкурентности и понятные отчёты об ошибках превращают тестирование из рутины в действительно приятную часть разработки.