
Тестирование на 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 и получать результаты от внедрения нового фреймворка.
О том, как будет продвигаться наша работа с новым инструментом — обязательно расскажем в одной из следующих статей.