
Тестирование на Swift долгие годы держалось на трех китах: XCTest, сторонние библиотеки и собственная смекалка. Но на WWDC 24 Apple представила новый, современный фреймворк — Swift Testing, который предлагает концептуально новый подход к тестированию.
Меня зовут Кирилл Гусев. Я мобильный разработчик в ОК. В этой статье я расскажу о том, какие возможности предоставляет Swift Testing.
Начнем со знакомства: немного о Swift Testing
Swift Testing — новый фреймворк для юнит-тестирования от Apple, представленный на WWDC 24 и призванный заменить классический XCTest. Swift Testing имеет открытый исходный код и разработан с учетом современных возможностей Swift.

Фреймворк поддерживает макросы и Swift Concurrency, а также является кроссплатформенным решением — работает на всех платформах, где есть Swift (macOS, iOS, Linux, Windows), что критично для серверной разработки и создания универсальных инструментов.
Вместе с тем, у фреймворка пока остаются некоторые ограничения. Например, он не поддерживает UI-тесты, работу с Performance и Objective-C код. Но даже при этом Swift Testing стал достойной альтернативой XCTest — этому способствует набор новых фич и возможностей. Остановимся на них подробнее.
Новые фичи
Для начала познакомимся с новыми функциями, доступными во фреймворке.
@Test
В Swift Testing реализована поддержка аннотации @Test. Благодаря этому, для определения теста теперь не обязательно создавать класс, который будет наследоваться от XCTestCase — достаточно создать функцию с аннотацией @Test.
import Testing @Test func foodTruckExists() { // Test logic goes here. }
При этом в аннотации можно задать и��я теста, чтобы легче было идентифицировать его назначение.
@Test("Food truck exists") func foodTruckExists() { // Test logic goes here. }
Также можно добавлять атрибуты-модификаторы MainActor и async throws.
@Test @MainActor func foodTruckExists() async throws { // Test logic goes here. }
Изменения жизненного цикла теста
В XCTest тесты привязаны исключительно к классам. Но в Swift Testing есть возможность использовать как классы, так и структуры.
Так, если раньше для setUp и tearDown были отдельные функции, то сейчас достаточно определить init и deinit для тестов.
@Suite
Вместе с поддержкой @Test, в Swift Testing поддерживается аннотация @Suite, которая используется в тестировании для объединения нескольких тестовых классов или групп тестов в один набор (suite).
@Suite("Arithmetic") struct ArithmeticTests { let calc = Calculator() @Test func addition() { let result = calc.addition(num1: 2, num2: 3) #expect(result == 5) } @Test func subtraction() { let result = calc.subtraction(num1: 5, num2: 3) #expect(result == 2) }
С ее помощью можно:
группировать тесты в один набор;
вкладывать Suite внутри друг друга для создания иерархии тестов;
применять дополнительные настройки и модификаторы (Traits или трейты) ко всем тестам в Suite одновременно;
настраивать отображение набора в Xcode и в результатах тестов.
При этом @Suite необязателен для работы, поскольку Swift Testing распознаёт тесты, находящиеся в любом типе (структуре, классе, акторе). Вместе с тем, @Suite позволяет явно задать имя и дополнительные параметры.
Expectation
В Swift Testing можно использовать expectation — механизм для проверки условий (утверждений) в тестах, который, в отличие от традиционных XCTAssert-функций из XCTest, использует более современный и гибкий подход через макрос #expect().
Примечательно, что функция expect сообщает о неудаче, но позволяет тесту продолжать выполнение. Это удобно, поскольку дает возможность посмотреть все ожидания, которые падают во время теста.
Например, у нас есть структура calculator и функция sum. Мы хотим проверить, что сумма будет равна разным значениям.

В примере видим, что первые два ожидания не оправдались, но тест завершился и проверил все ожидания.
#require
Функция require — аналог #expect, но с немедленным прекращением выполнения теста (с ошибкой ExpectationFailedError) при неудачной проверке.

Также она может использоваться для разворачивания опционалов. Это аналог XCTUnwrap, который был в XCTest.
@Test func stringParsesToInt() throws { let data = "2" let parsed = try #require(Int(data)) #expect(parsed == 2) }
Запись ошибок
В Swift Testing появилась возможность логировать ошибки при помощи Issue.record(), в которой мы можем залогировать причины падения теста.
Например, у нас может быть функция, которая описывает, что двигатель условного фургончика с едой может быть электрическим или дизельным.
enum Engine { case electric case diesel } struct FoodTruck { static let shared = FoodTruck() var engine: Engine = .diesel }
И мы можем запустить тест, в условиях которого задаем, что тест может упасть, если двигатель не электрический.
struct FoodTruckTests { @Test func engineWorks() { let engine = FoodTruck.shared.engine guard case .electric = engine else { Issue.record("Engine is not electric") return } // ... } }
Так как у нас двигатель не электрический, тест объективно упадет.

withKnownIssue
withKnownIssue — параметр аннотации @Test, который помечает тест как содержащий известную проблему (баг). Его использование дает возможность:
запускать тест, но не считать его провал как
failure;отслеживать известные проблемы;
получать предупреждения при неожиданном успехе.
Например, возьмем тест, в котором grillWorks помечен как имеющий известную проблему — "Grill is out of fuel". В этом случае, если при выполнении теста возникнет ошибка, связанная с тем, что гриль не может быть запущен из-за отсутствия топлива, тест не будет считаться проваленным.
struct FoodTruckTests { @Test func grillWorks() async { withKnownIssue("Grill is out of fuel") { try FoodTruck.shared.grill.start() } // ... } }
withKnownIssue также позволяет указывать, если ошибка интермиттирующая, то есть проявляется не всегда, а только в определенных условиях. Это означает, что тесты должны быть готовы к тому, что ошибка может возникать не всегда, а только в определенных условиях.
struct FoodTruckTests { @Test func grillWorks() async { withKnownIssue("Grill may need fuel", isIntermittent: true) { try FoodTruck.shared.grill.start() } // ... } }
Функция withKnownIssue также позволяет задавать условия, при которых известная проблема считается актуальной, и сопоставлять ошибки с определенными критериями.
Например, можно указать, что проблема с отсутствием топлива в гриле актуальна только при условии, что гриль установлен (FoodTruck.shared.hasGrill). Также проверять, что в объекте issue есть ошибка (issue.error != nil), чтобы считать проблему актуальной.
struct FoodTruckTests { @Test func grillWorks() async { withKnownIssue("Grill is out of fuel") { try FoodTruck.shared.grill.start() } when: { FoodTruck.shared.hasGrill } matching: { issue in issue.error != nil } // ... } }
Кастомизация тестов
Swift Testing предлагает возможность использования различных трейтов, с помощью которых можно:
задать дополнительное поведение или метаданные к тестам и
test suite;настраивать выполнение тестов;
добавлять описания, теги, ограничения по времени и многое другое.
Разберем несколько наглядных примеров.
Выключение тестов
Например, можно выключить тест, который не нужен.
@Test("Food truck sells burritos", .disabled()) func sellsBurritos() async throws { // ... }
Также можно указать причину, по которой выключается определенный тест:
@Test("Food truck sells burritos", .disabled("We only sell Thai cuisine")) func sellsBurritos() async throws { // ... }
Помимо этого, можно выключать Suite и все определенные в нем тесты.
@Suite("Arithmetic", .disabled()) struct ArithmeticTests
Запуск теста при определенном условии
Также с помощью трейтов можно настраивать запуск теста при определенном условии. Например, чтобы тест запускался только летом.
enum Season { case winter case spring case summer case autumn static var current: Season = .summer } @Test("Ice cream is cold", .enabled(if: Season.current == .summer)) func isCold() async throws { // ... }
Примечательно, что два упомянутых условия для теста можно комбинировать — например, выключить тест на всё время, но включать только летом.
@Test( "Ice cream is cold", .enabled(if: Season.current == .summer), .disabled("We ran out of sprinkles") ) func isCold() async throws { // ... }
Добавление тегов
Помимо этого, в Swift Testing появилась возможность использования тегов. То есть, теперь можно не только объединять в группы похожие тесты, но и присваивать им одинаковые теги для упрощения последующего использования — можно создать новый тег, расширив его и добавив статическую переменную.
extension Tag { @Tag static var example: Tag enum CustomTags {} } extension Tag.CustomTags { @Tag static var jsonTests: Tag }
Достаточно просто добавить в аннотацию свойства tags, и в Xcode пометятся все тесты с определенным тегом.
Например, задаем некоторые функции:
@Test("Decode json", .tags(.CustomTags.jsonTests)) func decodeJson() @Test(.tags(.example)) func decodeJSONTypeCastingFails() @Suite(.tags(.CustomTags.jsonTests)) struct JSONDecoderTests
А на выходе получаем две группы тестов по тегам:
CustomTags.jsonTests;example.
В дальнейшем такая сортировка не только упрощает поиск похожих тестов, но и позволяет запускать все тесты с одним тегом одновременно.
Линковка багов
Помимо прочего, в Swift Testing доступен удобный механизм линковки багов. Так, упавший тест с неожиданным поведением можно выключить и слинковать с задачей, с которой он будет фикситься.
@Test(.disabled(), .bug(«https://jira.vk.team/browse/OKAT-6683", "Баг")) func responseSerializerFailsDataIsNil() throws
При этом, в интерфейсе предусмотрена отдельная кнопка, которая позволяет сразу перейти к нужной задаче.

Создание кастомного трейта
Также в Swift Testing можно создавать кастомные трейты. Это может пригодиться, например, чтобы инкапсулировать часто используемые конфигурации тестов (например, переопределённые зависимости, специальные настройки окружения, общие параметры). То есть, вместо повторения одной и той же логики в каждом тесте или Suite можно создать один кастомный трейт и применять его везде.
Для этого достаточно создать структуру или класс, который будет подписан на протокол TestTrait, и расширить трейт, подписав на нужный трейт с помощью статической переменной.
@Test(.mockJson) func example() { // ... } struct MockJsonTrait: TestTrait { } extension Trait where Self == MockJsonTrait { static var mockJson: Self { } }
Параметризованные тесты
Одной из важных фишек Swift Testing стала возможность создания параметризированных тестов, то есть тех, которые выполняются многократно с разными наборами входных данных. Это позволяет проверить одну и ту же функциональность с различными значениями, чтобы убедиться в ее правильности в разных случаях.
Параметризация теста
Рассмотрим ситуацию, где нам надо проверить тест, в котором есть определенное количество аргументов. Для этого создадим enum или массив, и пробежимся по нему.
enum Food { case burger, iceCream, burrito, noodleBowl, kebab }
Теоретически можно создать тест, который через for loop проверяет каждый аргумент: в нашем случае — возможность приготовления каждого вида еды.
enum Food { case burger, iceCream, burrito, noodleBowl, kebab } @Test("All foods available") func foodsAvailable() async throws { for food: Food in [.burger, .iceCream, .burrito, .noodleBowl, .kebab] { let foodTruck = FoodTruck(selling: food) #expect(await foodTruck.cook(food)) } }
Но со Swift Testing тест можно упростить, передав все аргументы в аннотацию, что облегчит его понимание.
enum Food { case burger, iceCream, burrito, noodleBowl, kebab } @Test("All foods available", arguments: [Food.burger, .iceCream, .burrito, .noodleBowl, .kebab]) func foodAvailable(_ food: Food) async throws { let foodTruck = FoodTruck(selling: food) #expect(await foodTruck.cook(food)) }
Помимо этого, для enum можно добавить CaseIterable. В таком случае в аргументы можно просто передать значение allCases, что будет подразумевать проверку всех аргументов.
enum Food: CaseIterable { case burger, iceCream, burrito, noodleBowl, kebab } @Test("All foods available", arguments: Food.allCases) func foodAvailable(_ food: Food) async throws { let foodTruck = FoodTruck(selling: food) #expect(await foodTruck.cook(food)) }
Но применение параметризированных тестов возможно и в более сложных кейсах. Например, когда в тесте указывается два массива.
Рассмотрим на функции, которая проверяет нормальную частоту биения сердца в каждом из возрастов. В теории массивы значений можно проверить через for loop.
@Test func verifyNormalHeartRate() { let age = [18, 30, 50, 70] let bpm = [77.0, 73, 65, 61] for (age, bpm) in zip(age, bpm) { let hr = HeartRate(bpm: bpm) let context = HeartRateContext(age: age, activity: .regular, heartRate: hr) #expect(context.zone == .normal) } }
Но рациональнее передать два массива в аргументы.
@Test(arguments: [18, 30, 50, 70], [77.0, 73, 65, 61]) func verifyNormalHeartRate(age: Int, bpm: Double) { let hr = HeartRate(bpm: bpm) let context = HeartRateContext(age: age, activity: .regular, heartRate: hr) #expect(context.zone == .normal) }
Но здесь возникает проблема. Так, в исходном варианте применяется zip, что позволяет проверять значения только парами. А при передаче массивов в аргументы можно получить 25 прогонов, поскольку Swift Testing будет пытаться проверить все возможные варианты. Соответственно, чтобы избежать подобных издержек, также нужно использовать zip для тестирования конкретных пар.
@Test(arguments: zip([18, 30, 50, 70], [77.0, 73, 65, 61])) func verifyNormalHeartRate(age: Int, bpm: Double) { let hr = HeartRate(bpm: bpm) let context = HeartRateContext(age: age, activity: .regular, heartRate: hr) #expect(context.zone == .normal) }
Это позволит сразу найти пару, которая будет падать, а не перебирать 25 раз.
Лимиты времени
В рамках параметризации тестов также можно применять некоторые Traits. Например, можно ограничивать время тестов в минутах.
@available(iOS 16.0, *) @Test(.timeLimit(.minutes(1))) func prepare(food: Food)
Надо отметить, что функция пока доступна только с iOS 16.
Выключение параллельности
Также можно выключать параллельность запуска тестов. Например, если нужно последовательно проверить возможность приготовления каждого блюда. Для этого достаточно указать в функции условие serialized.
@Test(.serialized, arguments: Food.allCases) func prepare(food: Food) { // This function will be invoked serially, once per food, because it has the // .serialized trait. }
Также serialized работает и для Suite. Например, пока последовательная проверка каждого аргумента не выполнится, второй тест не начнет свою работу.
@Suite(.serialized) struct FoodTruckTests { @Test(arguments: Condiment.allCases) func refill(condiment: Condiment) { // This function will be invoked serially, once per condiment, because the // containing suite has the .serialized trait. } @Test func startEngine() async throws { // This function will not run while refill(condiment:) is running. // One test must end before the other will start. } }
Возможности миграции с XCTest к Swift Testing
Теперь остановимся на том, как потенциально можно применить все упомянутые возможности Swift Testing. Для наглядности рассмотрим несколько сценариев.
Декодинг JSON
Для декодинга JSON в XCTest классически применяется довольно типовая структура.
import XCTest class JSONDecoderTests: OKTestCase { func testJSONDecodingTypeCastingFails() { let dataDecoder = JSONDecoder() let data = Data("{\"uid\": 12, \"firstName\": \"firstName\", \"lastName\": \"lastName\"}".utf8) let result = Result { try dataDecoder.decode(String.self, from: data) } XCTAssertTrue(result.isFailure) XCTAssertNil(result.success) XCTAssertNotNil(result.failure) } }
При работе со Swift Testing мы можем значительно упростить ее: сделать структуру (struct JSONDecoderTests), повесить аннотацию @Test и заменить XCTAssert на #expect.
import Testing struct JSONDecoderTests { @Test func decodeJSONTypeCastingFails() { let dataDecoder = JSONDecoder() let data = Data("{\"uid\": 12, \"firstName\": \"firstName\", \"lastName\": \"lastName\"}".utf8) let result = Result { try dataDecoder.decode(String.self, from: data) } #expect(result.isFailure) #expect(result.success == nil) #expect(result.failure != nil) } }
Замена setUp и tearDown
Помимо прочего, переход на Swift Testing позволит уйти от применения setUp и tearDown в тестах перформанса.
import XCTest final class OKPerformanceModuleTests: XCTestCase { private var collector: StorageCollector! private var scenarioId: String? override func setUpWithError() throws { self.scenarioId = nil self.collector = StorageCollector() OKPerfomanceModule.attachCollector(self.collector) } override func tearDownWithError() throws { if let scenarioId { OKPerfomanceModule.end(scenarioId: scenarioId, message: nil) } } }
Так, setUp и tearDown можно заменить на init и deinit.
import Testing final class OKPerformanceModuleTests { private var collector: StorageCollector private var scenarioId: String? init() { self.scenarioId = nil self.collector = StorageCollector() OKPerfomanceModule.attachCollector(self.collector) } deinit { if let scenarioId { OKPerfomanceModule.end(scenarioId: scenarioId, message: nil) } } }
Объединение логики повторяющихся тестов
Иногда тесты могут отличаться лишь некоторыми параметрами. Но в случае XCTest с этим часто приходится мириться. Например:
final class OKPerfomanceModuleTests: XCTestCase { // ... func testBeginChildCallNoMessage() throws { let parentId = UUID().uuidString let scenario = "testBeginChildCallNoMessage-scenario" self.scenarioId = OKPerfomanceModule.begin(scenario: scenario, parentId: parentId, message: nil) XCTAssertTrue(collector.scenario == scenario) XCTAssertTrue(collector.parentId == parentId) XCTAssertTrue(collector.message == nil) } func testBeginChildCallWithMessage() throws { let parentId = UUID().uuidString let scenario = "testBeginChildCallWithMessage-scenario" let message = "testBeginChildCallWithMessage-message" self.scenarioId = OKPerfomanceModule.begin(scenario: scenario, parentId: parentId, message: message) XCTAssertTrue(collector.scenario == scenario) XCTAssertTrue(collector.parentId == parentId) XCTAssertTrue(collector.message == message) } }
При работе со Swift Testing мы можем объединить два теста в один параметризованный. Например, можно создать аргумент и передать в него параметры, которые будут запускаться и проверяться в конкретном тесте.
final class OKPerfomanceModuleTests { @Test(arguments: [ Argument(scenario: "testBeginRootCallNoMessage"), Argument(scenario: "testBeginRootCallWithMessage-scenario", message: "testBeginRootCallWithMessage-message"), ]) func beginScenario(arg: Argument) { self.scenarioId = OKPerfomanceModule.begin(scenario: arg.scenario, parentId: arg.parentId, message: arg.message) #expect(collector.scenario == arg.scenario) #expect(collector.parentId == arg.parentId) #expect(collector.message == arg.message) } } struct Argument { let scenario: String let parentId: String? let message: String? init(scenario: String, parentId: String? = UUID().uuidString, message: String? = nil) { self.scenario = scenario self.parentId = parentId self.message = message } }
Переход от XCTUnwrap к require
Тесты в XCTest нередко используют XCTUnwrap.
class OKDecodableBatchResponseSerializerTests: OKTestCase { func testResponseSerializerFailsDataIsNil() throws { let serializer = DecodableTestBatchResponseSerializer() serializer.registry(User.self, forKey: .user) let result = Result { try serializer.serialize(response: nil, data: nil) } let networkError = try XCTUnwrap(result.failure?.asNetworkingError) XCTAssertEqual(networkError.isInputDataNilOrZeroLength, true) } }
Со Swift Testing мы можем начать использовать require. Для этого достаточно заменить XCTUnwrap на require и Assert на expect.
struct OKDecodableBatchResponseSerializerTests { @Test func responseSerializerFailsDataIsNil() throws { let serializer = DecodableTestBatchResponseSerializer() serializer.registry(User.self, forKey: .user) let result = Result { try serializer.serialize(response: nil, data: nil) } let networkError = try #require(result.failure?.asNetworkingError) #expect(networkError.isInputDataNilOrZeroLength) } }
Логирование ошибок
Также можно залогировать ошибку, которая есть в тесте. Например, если результат успешный — выполняем определенную часть теста, если нет — выбрасывается ошибка.
class JSONDecoderTests: OKTestCase { func testJSONDecoding() { let dataDecoder = JSONDecoder() let data = Data("{\"uid\": 12, \"firstName\": \"firstName\", \"lastName\": \"lastName\"}".utf8) let result = Result { try dataDecoder.decode(OKAPIUser.self, from: data) } XCTAssertTrue(result.isSuccess) XCTAssertNotNil(result.success) XCTAssertNil(result.failure) if let user = result.success { XCTAssertEqual(user.uid, 12) XCTAssertEqual(user.firstName, "firstName") XCTAssertEqual(user.lastName, "lastName") } else { XCTFail("Serialized result type is not dictionary: \(type(of: result.success))") } } }
Здесь можно несколько изменить конфигурацию через Guard, предусмотреть обработку ошибки через Issue.record и добавить expect.
struct JSONDecoderTests { @Test func decodeJson() { let dataDecoder = JSONDecoder() let data = Data("{\"uid\": 12, \"firstName\": \"firstName\", \"lastName\": \"lastName\"}".utf8) let result = Result { try dataDecoder.decode(OKAPIUser.self, from: data) } #expect(result.isSuccess) #expect(result.success != nil) #expect(result.failure == nil) guard let user = result.success else { Issue.record("Serialized result type is not dictionary: \(type(of: result.success))") return } #expect(user.uid == 12) #expect(user.firstName == "firstName") #expect((user.lastName == "lastName")) } }
Вместо выводов
Swift Testing представляет собой современный фреймворк с интуитивным API, который предоставляет поддержку Swift Concurrency, параметризации и кастомизации тестов, модификации их поведения и множество других возможностей, с которыми работа разработчиков и тестировщиков становится проще. Именно поэтому мы в ОК уже начали переводить тесты на Swift Testing и получать результаты от внедрения нового фреймворка.
О том, как будет продвигаться наша работа с новым инструментом — обязательно расскажем в одной из следующих статей.
