Лучшие практики

Данная глава содержит примеры кода с коротким пояснением, которые ты встречал в книге.

Информация о текущем тесте

Ты можешь получить доступ об информации к текущему тесту, только если он выполняется, иначе Test.current всегда будет nil:

@Test("Информация о тесте")
func information() throws {
	let currentTest: Test = try #require(Test.current)
	let location = #_sourceLocation()

	#expect(currentTest.sourceLocation.fileName == location.fileName)
	#expect(currentTest.displayName == nil)
}

❌ Expectation failed:
(currentTest.displayName → "Информация о тесте") == nil

Аргументы вместо цикла for

Первое, что приходит в голову использовать цикл for (или функции высшего порядка: map, forEach и т.д.) при работе с перечислением:

enum Planet: CaseIterable {
	case mercury, venus, earth, gargantua, mars, jupiter, saturn, pluto, uranus, neptune, endurance
}

func isPlanetInSolarSystem(_ planet: Planet) -> Bool {
	switch planet {
		case .mercury, .venus, .earth, .mars, .jupiter, .saturn, .uranus, .neptune: true
		case .pluto, .gargantua, .endurance: false
	}
}

@Test("Планета находится в солнечной системе?")
func explorePlanets() {
	for planet in Planet.allCases {
		#expect(isPlanetInSolarSystem(planet))
	}
}

Test "Планета находится в солнечной системе?" recorded an issue at ManyArguments.
❌ Expectation failed: isPlanetInSolarSystem(planet → .gargantua)
❌ Expectation failed: isPlanetInSolarSystem(planet → .pluto)
❌ Expectation failed: isPlanetInSolarSystem(planet → .endurance)

Более правильным вариантом будет использование аргументов в атрибуте @Test вместо цикла for :

@Test(
	"Планета находится в солнечной системе?",
	arguments: Planet.allCases
)
func matchPlanet(planet: Planet) {
	#expect(isPlanetInSolarSystem(planet))
}

Аргументы теста

◇ Test "Планета находится в солнечной системе?" started.
◇ Passing 1 argument planet → .gargantua to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → .mercury to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → .venus to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → .mars to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → .earth to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → .jupiter to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → .saturn to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → .pluto to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → .neptune to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → .uranus to "Планета находится в солнечной системе?"
​​​​​​​​◇ Passing 1 argument planet → .endurance to "Планета находится в солнечной системе?"
✘ Test "Планета находится в солнечной системе?" recorded an issue with 1 argument planet → .gargantua at ManyArguments.swift:26:2: Expectation failed: isPlanetInSolarSystem(planet → .gargantua)
✘ Test "Планета находится в солнечной системе?" recorded an issue with 1 argument planet → .pluto at ManyArguments.swift:26:2: Expectation failed: isPlanetInSolarSystem(planet → .pluto)
​​✘ Test "Планета находится в солнечной системе?" recorded an issue with 1 argument planet → .endurance at ManyArguments.swift:26:2: Expectation failed: isPlanetInSolarSystem(planet → .endurance)

❌ Expectation failed: isPlanetInSolarSystem(planet → .gargantua)
❌ Expectation failed: isPlanetInSolarSystem(planet → .pluto)
❌ Expectation failed: isPlanetInSolarSystem(planet → .endurance)

И не забудь реализовать протокол CustomTestStringConvertible при работе с параметрами:

extension Planet: CustomTestStringConvertible {
	var testDescription: String {
		switch self {
			case .mercury: "Жаркое место"
			case .venus: "Экстримальное давление"
			case .earth: "Безопасная Земля"
			case .gargantua: "Черная Дыра"
			case .mars: "Красная планета"
			case .jupiter: "Газовый гигант"
			case .saturn: "Властелин колец"
			case .pluto: "Маленький, но гордый"
			case .uranus: "Ледяной гигант"
			case .neptune: "Синий гигант"
			case .endurance: "Корабль надежды"
		}
	}
}

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

# ...
◇ Passing 1 argument planet → Экстримальное давление to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → Маленький, но гордый to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → Ледяной гигант to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → Синий гигант to "Планета находится в солнечной системе?"
◇ Passing 1 argument planet → Безопасная Земля to "Планета находится в солнечной системе?"
​​​​​◇ Passing 1 argument planet → Корабль надежды to "Планета находится в солнечной системе?"
​​​✘ Test "Планета находится в солнечной системе?" recorded an issue with 1 argument planet → Корабль надежды at ManyArguments.swift:83:2: Expectation failed: isPlanetInSolarSystem(planet → Корабль надежды)
✘ Test "Планета находится в солнечной системе?" recorded an issue with 1 argument planet → Черная Дыра at ManyArguments.swift:83:2: Expectation failed: isPlanetInSolarSystem(planet → Черная Дыра)
✘ Test "Планета находится в солнечной системе?" recorded an issue with 1 argument planet → Маленький, но гордый at ManyArguments.swift:83:2: Expectation failed: isPlanetInSolarSystem(planet → Маленький, но гордый)

В навигационном меню тестов ты тоже увидишь имена, заданные раннее:

CustomTestStringConvertible

Помимо перечислений и массивов, аргемунт принимает ClosedRange:

@Test(
	"Исследование планеты за время",
	arguments: 90...110
)
func explorePlanets(duration: Int) async {
	let spaceStation = SpaceStation(studying: .gargantua)
	#expect(await spaceStation.explore(.gargantua, duration: duration))
}

Для исследования Гаргантюа требуется минимум 100 дней

Проверка доступности @available

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

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

@Test
func anotherExample() {
  if #unavailable(iOS 15) { }
}

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

Проверка условия с помощью guard

Из-за оператора return в теле guard осуществился ранний выход из метода, поэтому макрос #expect не сравнил цвет и результат теста оказался неверным.

@Test
func brewTea() {
	let greenTea = BestTea(name: "Green", optimalTime: 2, color: nil)
	guard let color = greenTea.color else {
		print("Color is nil!")
		return
	}
	#expect(color == .green)
}

Плохая практика
Макрос #expect не сравнил цвет, поведение теста неверное!

На замену guard используем макрос #require, для распаковки опционального значения. В случае, если значение color равно nil, осуществляется ранний выход и тест не проходит проверку, вернув ошибку.

@Test
func brewTeaCorrect() throws {
	let greenTea = BestTea(name: "Green", optimalTime: 2, color: nil)
	let color = try #require(greenTea.color)
	#expect(color == .green)
}

Хорошая практика
Expectation failed:
(greenTea → BestTea(id: 32B06194-BCD9-4A4D-AEAA-9ACB3C037D95, name: "Green", optimalTime: 2, color: nil)).color → nil → nil

Ожидаемая ошибка withKnownIssue

Если ты знаешь, что свойство или метод вызовут ошибку по какой-то причине, то можешь использовать специальную функцию withKnownIssue, чтобы тест был пройден:

@Test
func matchAvailableCharger() {
	withKnownIssue("Порт зарядки сломан") {
		try Supercharger.shared.openChargingPort()
	}
}

❌ Test matchAvailableCharger() passed after 0.001 seconds with 1 known issue

Confirmation

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

При вызове await confirmation(...) ты передаешь замыкание соответсвующее типу Confirmation:

@Test("Вызов метода расчета размера после загрузки")
func cleanupAfterDownload() async {
	let downloader = CoreMLDownloader()

	await confirmation() { confirmation in
		downloader.onCompleteDownload = { _ in confirmation() }
		_ = await downloader.size(for: CoreMLModel(.fastViT))
	}
}

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

await confirmation(expectedCount: 0) { confirmation in
	// ...
}

note

Используй await confirmation() когда хочешь вызвать callback.

А теперь к другой ситуации. Часть твоего кода была написана уважаем человеком, любящим использовать коллбеки и теперь при переходе на SC ты должен оборачивать каждый метод с помощью continuation:

@Test
func launchRocket() async throws {
    let rocket = await Rocket.prepareForLaunch()

    try await withCheckedThrowingContinuation { continuation in
        rocket.launch(with: .systemCheck) { result, error in
            if let result {
                continuation.resume(returning: result)
            } else if let error {
                continuation.resume(throwing: error)
            }
        }
    }
}

Swift Testing автоматически оборачивает синхронный код с коллбеком, поэтому нет необходимости использовать withCheckedThrowingContinuation в тестах.

important

Не используй механизм синхронизации CheckedContinuation между синхронным и асинхронным кодом в тестировании!

Отключай тест, а не комментируй

По привычке ты захочешь закомментировать тело теста, чтобы ничего не выполнялось:

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

Однако в библиотеке тестирования закоментированный код будет скомпилирован. Поэтому лучшей практикой будет пропуск теста, вместо комментария тела:

@Test("Закомментирую на время фикса #PR-3781", .disabled())
func fetchAnotherFlag() {
	// ...
}

note

Вместо комментария тела функции используй трейт .disabled()