Property-based тестирование в Swift: когда обычные тесты пасуют
Вы когда-нибудь писали юнит-тест, который проходит на «пятёрку», а потом приложение падает на реальных данных? Знакомо, да? Классические example-based тесты (когда мы проверяем конкретный вход и выход) хороши, но у них есть фундаментальный недостаток: они проверяют только то, что мы сами придумали. А что, если баг живёт на границе допустимых значений, или в комбинации, которая просто не пришла нам в голову?
Тут на сцену выходит property-based тестирование в Swift. Это подход, при котором вы описываете не конкретные кейсы, а свойства (property), которые должны быть истинны для любых входных данных. А специальный фреймворк (например, SwiftCheck) генерирует сотни случайных значений и проверяет, не нарушается ли ваше свойство. Если баг найден, фреймворк «минимизирует» входные данные — и вы получаете самый простой пример, на котором всё ломается.
В этой статье мы разберём, как внедрить property-based тестирование Swift в ваш проект, напишем реальные примеры кода и посмотрим, какие баги можно поймать таким подходом. Поехали!
Что такое property-based тестирование и зачем оно нужно?
Представьте, что вы тестируете функцию, которая сортирует массив. Вместо того чтобы писать 10 тестов с разными массивами, вы говорите: «для любого массива после сортировки длина не меняется, а каждый элемент меньше или равен следующему». Это и есть свойство — инвариант, который обязан выполняться.
Property-based тестирование в Swift идеально подходит для:
- Проверки математических функций (сложение, деление, округление);
- Тестирования парсеров и сериализаторов (сериализация → десериализация должна возвращать оригинал);
- Проверки бизнес-логики с большим количеством комбинаций;
- Поиска регрессий на граничных значениях.
Главный бонус: вы находите баги, о существовании которых даже не подозревали. А это, согласитесь, круче, чем подтверждать очевидное.
SwiftCheck — главный инструмент для property-based тестирования в Swift
В мире Swift самым популярным фреймворком для такого подхода является SwiftCheck. Он портирует идеи из Haskell (библиотека QuickCheck) и отлично интегрируется с XCTest.
Чтобы начать, добавьте зависимость в Package.swift:
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "MyProject",
dependencies: [
.package(url: "https://github.com/typelift/SwiftCheck.git", from: "0.12.0")
],
targets: [
.testTarget(
name: "MyProjectTests",
dependencies: ["SwiftCheck"]),
]
)
Теперь можно писать тесты. Давайте разберём базовый синтаксис.
Первый property-based тест: проверка сложения
Напишем простое свойство: «сложение ассоциативно». Для любых целых чисел a + b + c должно равняться (a + b) + c.
import XCTest
import SwiftCheck
class MathPropertiesTests: XCTestCase {
func testAdditionIsAssociative() {
property("Сложение ассоциативно") <- forAll { (a: Int, b: Int, c: Int) in
return (a + b) + c == a + (b + c)
}
}
}
Запускаем тест — он прогоняет 100 случайных комбинаций (по умолчанию). Если хотя бы одна упадёт, SwiftCheck покажет конкретные значения. Обратите внимание: мы не указываем ни одного конкретного числа! Property-based тестирование Swift само генерирует данные.
Проверка обратного преобразования (roundtrip)
Это самый частый паттерн. Допустим, у нас есть структура, которая сериализуется в JSON. Свойство: «после сериализации и десериализации мы получаем тот же объект».
struct User: Codable, Equatable {
let id: Int
let name: String
let isActive: Bool
}
class CodableTests: XCTestCase {
func testUserRoundtrip() {
property("User сохраняется после кодирования/декодирования") <- forAll { (id: Int, name: String, isActive: Bool) in
let original = User(id: id, name: name, isActive: isActive)
guard let data = try? JSONEncoder().encode(original),
let decoded = try? JSONDecoder().decode(User.self, from: data) else {
return false
}
return original == decoded
}
}
}
Видите? Мы проверяем любых пользователей с любыми именами (даже пустыми или с эмодзи). Если JSONEncoder вдруг упадёт на строке с эмодзи — тест это покажет.
Продвинутые возможности: кастомные генераторы и сужение (shrinking)
Стандартные генераторы SwiftCheck покрывают базовые типы: Int, String, Bool, Array и т.д. Но что, если вам нужно тестировать свою доменную модель с ограничениями? Например, возраст пользователя должен быть от 0 до 120 лет.
Создаём кастомный генератор
extension Gen where T == Int {
static func validAge() -> Gen<Int> {
return Int.arbitrary.map { abs($0) % 121 } // 0...120
}
}
class AgeTests: XCTestCase {
func testAgeValidation() {
property("Возраст в допустимых пределах") <- forAll(Gen.validAge()) { age in
return (0...120).contains(age)
}
}
}
Этот тест проверит 100 случайных возрастов, гарантированно входящих в диапазон. А если бы мы использовали стандартный Int.arbitrary, то могли бы получить отрицательные числа или 9999 — и тест бы упал, что и нужно!
Shrinking: почему это важно?
Представьте, что тест упал на входных данных [123, -456, 789, 0, 99999]. Сложно понять, в чём дело. SwiftCheck автоматически пытается «сжать» данные до минимального набора, при котором тест всё ещё падает. В итоге вы получите, например, [-1] — и сразу поймёте, что проблема в отрицательных числах. Это колоссальная экономия времени.
Реальный кейс: тестирование парсера логики
Допустим, у нас есть функция, которая вычисляет скидку на товар. Скидка зависит от суммы покупки и статуса клиента (VIP или нет). Простейшая реализация:
enum CustomerStatus { case regular, vip }
func calculateDiscount(amount: Double, status: CustomerStatus) -> Double {
switch status {
case .regular:
return amount > 1000 ? amount * 0.1 : 0
case .vip:
return amount * 0