@Test(...)

Глава про макрос @Test разделена на 4 сценария:

  1. Условие
  2. Общие характеристики или теги
  3. Запуск с различными аргументами
  4. Тонкости успользования

Здесь ты столкнешься с распространенными проблемами в тестировании на практике и способы их решени. Помимо этого, я расскажу о тонкостях работы макроса.
Прочитать о реализации данного макроса можно в главе о protocol Trait.

Условие или runtime condition

Во-первых, тесты с условием. Некоторые тесты должны выполняться только при определённых обстоятельствах — например, на конкретных устройствах или в определённом окружении (environments).

Для этого, ты можешь применить трейт условия (ConditionTrait) .enabled(if: ...):

@Test(.enabled(if: Backport.isRemoteVersion))
func backportVersion() async {
	// ...
}

Ты передаешь некоторое условие, которое будет оцениваться перед запуском теста и если условие ложно, тест помечается как пропущенный и не выполняется.

→ Test 'backportVersion()' skipped

В других случаях необходимо полностью отключить выполнение теста (чтобы тест никогда не выполнялся). Для этого используй трейт .disabled(...):

@Test(.disabled("Известный баг, отключаем до фикса #PR-3781"))
func fetchFeatureFlag() async {
  // ...
}

→ Test 'fetchFeatureFlag()' skipped: Известный баг, отключаем до фикса #PR-3781

Использование трейта .disabled(...) является предпочтительнее комментирования тела функции, поскольку в закомментированном состоянии — тело функции компилируется:

// Избегайте такого способа отключения теста
@Test("Закомментирую на время фикса #PR-3781")
func fetchAnotherFlag() {
// try await Task(priority: .background) {
//	...
// }
}

Тебе может показаться, что одного комментария недостаточно и по-хорошему нужно указать причину отключения: баг, ожидание PR (пулл реквеста) или иное условие. Что ж, в дополнение к комментарию ты можешь использовать трейт .bug(...), чтобы явно указать на проблему:

@Test(
	"Проверка валидности поля именя",
	.disabled("Бекендер исправляет модель"),
	.bug("https://github.com/issue/7329", "Сломанная валидация имени и модель #7329")
)
func validateNameProperty() async throws {
	// ...
}

Данный баг будет отображаться в отчете и ты сможешь перейти по ссылке, которая ассоциируется с ним:

Отчет в Xcode 16

Когда необходимо запустить тест только на конкретной версии ОС (операционной системы), можешь использовать атрибут @available(...), чтобы указать на какой версии доступен тест. Атрибут @available(...) позволяет понимать, что у теста есть условие, связанное с версией операционной системы и точнее отражать это в результатах.

@Test
@available(macOS 15, *)
func usesNewAPIs() {
  // ...
}

Избегайте использования проверки доступности с помощью макросов #available и #unavailable:

// ❌ Избегайте использования проверки доступности в рантайме с помощью #available и #unavailable
@Test
func hasRuntimeVersionCheck() {
  guard #available(macOS 15, *) else { return }
  // ...
}

// ✅ Используй атрибут @available для функции или метода
@Test
@available(macOS 15, *)
func usesNewAPIs() {
  // ...
}

Available attribute

Атрибут @available(...) используется для обозначения доступности типа данных или функции, а макрос #available используется когда необходимо выполнить часть кода только в определенной версии ОС.

Общие характеристики или теги

Давай обсудим, как ты можешь объединять тесты, которые имеют общие свойства, даже если они находятся в разных типах данных или файлах. Swift Testing поддерживает создание пользовательских тегов для тестов.

note

Тег (или тэг) — это ключевое слово для обозначения общих свойств в тестах.

В моём проект я уже использовал теги. Найти их можно в навигационном меню Xcode, а именно в Test Navigator снизу.

Теги

Чтобы увидеть тесты, к которым применены теги, ты можешь переключиться в новый режим группировки — по тегам.

Группировка по тегам

Давайте применим тег к одному из тестов. Для этого мы добавим трейт .tags(...) в атрибут @Test:

@Test("Сравниваем размер файла", .tags(.fileSize))
func checkSize() {
	let fileSize = Measurement<UnitInformationStorage>(value: 2432, unit: .megabytes)
	
	#expect(fileSize.description == "2.4MB")
}

После применения, тег отобразится в Test Navigator под соответствующим тегом. Я написал еще один тест, который также проверяет размер файла и добавлю его сюда:

@Test("Сравнение еще одного файла", .tags(.fileSize))
func checkSizeWithFormatter() {
	let fileSize = Measurement<UnitInformationStorage>(value: 2432, unit: .megabytes)
	let filter = Measurement<UnitInformationStorage>.FormatStyle(
		width: .wide,
		locale: .init(identifier: "ru_RU")
	)
	
	let formattedResult = filter.format(fileSize)
	#expect(formattedResult != "2.4 Мегабайта")
}

Поскольку оба теста связаны с размером файла, давай сгруппируем их:

struct AboutFileSize {
	@Test("Сравниваем размер файла", .tags(.fileSize))
	func checkSize() {
		let fileSize = Measurement<UnitInformationStorage>(value: 2432, unit: .megabytes)

		#expect(fileSize.description == "2.4MB")
	}

	@Test("Сравнение еще одного файла", .tags(.fileSize))
	func checkSizeWithFormatter() {
		let fileSize = Measurement<UnitInformationStorage>(value: 2432, unit: .megabytes)
		let filter = Measurement<UnitInformationStorage>.FormatStyle(
			width: .wide,
			locale: .init(identifier: "ru_RU")
		)

		let formattedResult = filter.format(fileSize)
		#expect(formattedResult != "2.4 Мегабайта")
	}
}

Теперь мы можешь применить тег fileSize к атрибуту @Suite, чтобы тег применялся ко всем тестам в этом типе данных. Поскольку теги применяются ко всем вложенным методам, то можно убрать теги из методов:

@Suite(.tags(.fileSize))
struct AboutFileSize {
	@Test("Сравниваем размер файла")
	func checkSize() {
    // ...
  }
  // ...
}

Ты можешь ассоциировать теги с тестами, которые имеют общие черты. Например, ты можешь применить общий тег ко всем тестам, которые проверяют определенную функцию или вложенный тип данных. Это позволяет запускать все тесты с конкретным тегом, фильтровать их в Test Report и даже видеть аналитические данные, например, когда несколько тестов с одним и тем же тегом начинают падать.

Теги могут применяться к тестам в разных файлах, типам данных с атрибутом @Suite и даже использоваться в нескольких таргетах.

При использовании Swift Testing предпочтительнее использовать теги вместо имен тестов для их включения или исключения из тестового плана.

О том, как создать собственный тег прочитай здесь.

Аргументы

Перед прочтением тонкостей, я бы хотел показать последний процесс связанный с повторением тестов, а именно аргументами.

Предположим, что у тебя есть сервис по достопримечательностям и ты хочешь узнать информацию о каждом из них:

struct PlaceService {
	func search(by name: String) async -> Bool {
		let places: [String] = [
			"Gullfoss",
			"Saint Victor",
			"Vestmannaeyjar",
			"Skogafoss",
			"Hong Kong"
		]

		return places.contains(name)
	}
}

@Test
func findPlace() async throws {
	let places: [String] = [
		"Gullfoss",
		"Moscow",
		"Vestmannaeyjar",
		"Skogafoss",
		"Paris",
		"Borocay",
		"Hong Kong"
	]

	let service = PlaceService()

	for place in places {
		let result = await service.search(by: place)
		#expect(result)
	}
}

Ошибка в тесте, но где?

Тест выше работает, однако использование цикла for имеет свои недостатки:

  1. Ты не можешь видеть результат каждого вызова #expect(…) в навигационном меню тестов.
  2. Ты не можешь повторно запустить отдельный тест для одного элемента массива.
  3. Тесты выполняются последовательно.

Исправим ситуацию и передадим массив в качестве параметра arguments:

@Test(
	arguments: [
		"Gullfoss",
		"Moscow",
		"Vestmannaeyjar",
		"Skogafoss",
		"Paris",
		"Borocay",
		"Hong Kong"
	]
)
func findPlace(place: String) async throws {
	let service = PlaceService()
	let result = await service.search(by: place)
	#expect(result)
}

Просто добавь параметр в функцию, избавься от цикла for, переместите аргументы в атрибут @Test — и готово!

Better than for in

Параметризованные тесты можно использовать даже в более сложных сценариях, например для тестирования всех комбинаций двух наборов входных данных.

Поддерживаемые типы данных

note

Слишком большой Range 0 ..< .max может выполняться очень долго или совсем не завершиться.

Тонкости

Последний параграф познакомит тебя с особенностями использования макроса @Test, которые доступны при детальном чтении исходного кода, который реализует сам макрос. За это отвечает структура данных TestDeclarationMacro.

Ты написал много методов и один из них изолирован на MainActor. Поэтому помимо применения атрибута @Test, ты можешь применить глобальный актор, чтобы не получить ошибку компиляции на Swift 6:

@Test("Как определить, функция для теста изолирована на глобальном акторе ?")
@MainActor
func determineGlobalActor() async {
  // ...
}

Для выполнения кода и изоляции на глобальном акторе, можно применить глобальный актор к функции. Если изоляция не нужна для всей функции, то можно изолировать только определенный код:

@Test
func executeAtGlobalActor() async {
  await MainActor.run {
    // ...
  }
}

Да, разработчики подготовили специальный механизм для определения изоляции и выполнения на MainActor. Если ты знаешь как реализовать собственный глобальный актор, то у меня плохие новости — Swift Testing не умеет определять изоляцию для кастомных глобальных актор, только MainActor.

lazy var isMainActorIsolated = !functionDecl.attributes(named: "MainActor", inModuleNamed: "_Concurrency").isEmpty
var forwardCall: (ExprSyntax) -> ExprSyntax = {
  "try await Testing.__requiringTry(Testing.__requiringAwait(\($0)))"
}

let forwardInit = forwardCall

if functionDecl.noasyncAttribute != nil {
  if isMainActorIsolated {
    forwardCall = {
      "try await MainActor.run { try Testing.__requiringTry(\($0)) }"
    }
  } else {
    forwardCall = {
      "try { try Testing.__requiringTry(\($0)) }()"
    }
  }
}

note

Swift Testing умеет определять изоляцию только MainActor. Поддержки других глобальных акторов (@globalActor) нет.

Нет необходимости возвращать тип данных

Если ты внимательно читал код, то обратил внимание что ни одна функция не возвращает конкретный тип данных. Указание возвращаемого типа данных не является ошибкой, проверка с помощью макросов выполняется, но в этом случае ты получишь предупреждение:

@Test
func checkReturnType() -> any Collection {
  let collection = Array(1...10)
  #expect(collection.contains(10))

  return collection
}

⚠️ The result of this function will be discarded during testing

Возможно в будущем, инженеры Apple добавят такую возможность, но на данный момент они не нашли подходящего сценария, при котором необходимо возвращать тип данных. Такая проверка возможна с помощью проверки сигнатуры возвращаемого типа:

if let returnType = function.signature.returnClause?.type, !returnType.isVoid {
    diagnostics.append(.returnTypeNotSupported(returnType, on: function, whenUsing: testAttribute))
}

Неподдерживаемые ключевые слова

На момент выхода книги, в структуре данных TestDeclarationMacro, которая реализует макрос @Test, существуют неподдерживаемые ключевые слова:

struct TestDeclarationMacro: PeerMacro, Sendable {
    // ...
    // Ключевые слова inout, isolated или _const не поддерживаются.
    for parameter in parameterList {
        let invalidSpecifierKeywords: [TokenKind] = [.keyword(.inout), .keyword(.isolated), .keyword(._const),]
        // ...
    }
}

Это легко проверить, создав тест с одним из этих ключевых слов:

@Test("Проверка не поддерживаемых слов")
func parameterCanBeSupported(value: isolated (any Actor)? = #isolation) {}

❌ Attribute Test cannot be applied to a function with a parameter marked isolated

А теперь посмотри на ключевое слово _const:

func withImmutableValue(value: _const String) -> {
  value
}

Значения, известные на этапе компиляции (compile-time constant values), — это значения, которые могут быть известны или вычислены во время компиляции и гарантированно не изменяются после её завершения. Использование таких значений может служить различным целям: от обеспечения правил и гарантий безопасности до предоставления пользователям возможности создавать сложные алгоритмы, выполняемые на этапе компиляции.

note

Ознакомится более подробно с _const.

Test только для func

Возможно тебе захочется применить атрибут для теста замыкания, но ничего не выйдет. При сборке таргета с тестами, кнопки запуска не появится. Или иными словам, ты можешь применить атрибут только для функций или методов:

guard let function = declaration.as(FunctionDeclSyntax.self) else {
    diagnostics.append(.attributeNotSupported(testAttribute, on: declaration))
    return false
}

1 атрибут для 1 функции

Да, для кого-то это покажется слишком очевидным, но применить атрибут @Test можно только 1 раз:

let suiteAttributes = function.attributes(named: "Test")

if suiteAttributes.count > 1 {
    diagnostics.append(.multipleAttributesNotSupported(suiteAttributes, on: declaration))
}

Не приминим для Generics

Атрибуты @Test и @Suite не могут быть применены к дженерикам:

/// Create a diagnostic message stating that the `@Test` or `@Suite` attribute
/// cannot be applied to a generic declaration.
static func genericDeclarationNotSupported(_ decl: some SyntaxProtocol, whenUsing attribute: AttributeSyntax, becauseOf genericClause: some SyntaxProtocol, on genericDecl: some SyntaxProtocol) -> Self {
  if Syntax(decl) != Syntax(genericDecl), genericDecl.isProtocol((any DeclGroupSyntax).self) {
      return .containingNodeUnsupported(genericDecl, genericBecauseOf: Syntax(genericClause), whenUsing: attribute, on: decl)
  } else {
      // Avoid using a syntax node from a lexical context (it won't have source location information.)
      let syntax = (genericClause.root != decl.root) ? Syntax(decl) : Syntax(genericClause)
      return Self(
      syntax: syntax,
      message: "Attribute \(_macroName(attribute)) cannot be applied to a generic \(_kindString(for: decl))",
      severity: .error
      )
  }
}

Получаем ошибку компиляции:

@Test
func sumOf<V: Numeric>() {
  // ...
}

❌ Attribute 'Test' cannot be applied to a generic function