Property-based тестирование в Swift: лови баги, которые ты не искал

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

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                

Блоги

Рекомендаций по книгам