🍎 Swift

Pruebas de código en Swift: guía detallada

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты написал программу для умного дома. Она должна включать свет, когда стемнеет, и выключать, когда светло. Ты написал код, запустил — и всё работает. Но что произойдёт, если датчик освещённости сломается и начнёт передавать отрицательные значения? Или если пользователь случайно нажмёт кнопку включения 100 раз подряд? Твоя программа может «упасть» (закрыться с ошибкой) или, что ещё хуже, начать вести себя непредсказуемо.

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

«Код без тестов — это не программа, а гипотеза» — шутят опытные разработчики. И в этой шутке есть большая доля правды.

Зачем тестировать? Вот три главные причины:

  • Экономия времени. Найти ошибку через месяц после написания кода в 10 раз дороже (по времени исправления), чем найти её сразу.
  • Уверенность. Когда ты меняешь одну часть программы, тесты проверят, не сломалась ли другая часть.
  • Документация. Тесты показывают, как именно должен работать твой код. Новый разработчик может прочитать тесты и понять логику программы.

В этой статье мы изучим, как тестировать код на языке Swift — основном языке для создания приложений под iPhone, iPad и Mac. Ты узнаешь, как писать тесты, которые автоматически проверяют твои программы, и как избежать типичных ошибок.

Теория: как это работает изнутри

В Swift тестирование построено на фреймворке XCTest. Это встроенная библиотека, которая предоставляет всё необходимое для написания и запуска тестов. Давай разберёмся с ключевыми понятиями.

Юнит-тесты (Unit Tests)

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

Тестовый класс и тестовые методы

В XCTest каждый тест — это метод внутри класса, который наследуется от XCTestCase. Имя тестового метода обязательно должно начинаться со слова test. Например: testAddition(), testUserRegistration().

Утверждения (Assertions)

Утверждение — это проверка, которая сравнивает ожидаемый результат с фактическим. Если утверждение истинно — тест пройден. Если ложно — тест падает, и Xcode показывает, что именно пошло не так.

Основные виды утверждений в XCTest:

Утверждение Что проверяет Пример
XCTAssertEqual(a, b) Равенство значений a и b XCTAssertEqual(2 + 2, 4)
XCTAssertTrue(expr) Выражение истинно XCTAssertTrue(age >= 18)
XCTAssertFalse(expr) Выражение ложно XCTAssertFalse(isEmpty)
XCTAssertNil(value) Значение равно nil XCTAssertNil(error)
XCTAssertNotNil(value) Значение не равно nil XCTAssertNotNil(user)
XCTAssertThrowsError(expr) Выражение выбрасывает ошибку XCTAssertThrowsError(try divide(5, by: 0))

Жизненный цикл теста

Каждый тест проходит три этапа, которые программисты называют «AAA»:

  1. Arrange (Подготовка) — создаём данные и объекты, необходимые для теста.
  2. Act (Действие) — выполняем тестируемую функцию.
  3. Assert (Проверка) — сравниваем результат с ожидаемым.

В XCTest есть специальные методы setUp() и tearDown(), которые выполняются до и после каждого теста. В setUp() обычно создают объекты, а в tearDown() — освобождают ресурсы.

Синтаксис в Swift: все формы и варианты

Давай разберём полный синтаксис тестового класса в Swift. Мы будем использовать Xcode и фреймворк XCTest.

Создание тестового файла

В Xcode ты создаёшь новый файл, выбираешь шаблон «Unit Test Case Class». Автоматически создаётся такой каркас:

import XCTest
// Импортируем наш основной модуль, чтобы тестировать его функции
@testable import MyApp

// Класс теста должен наследоваться от XCTestCase
final class MyAppTests: XCTestCase {
    
    // Эта функция выполняется перед каждым тестом
    override func setUpWithError() throws {
        // Здесь мы готовим данные для теста
        print("Начинаем новый тест!")
    }
    
    // Эта функция выполняется после каждого теста
    override func tearDownWithError() throws {
        // Здесь мы убираем за собой
        print("Тест завершён!")
    }
    
    // Тестовый метод — обязательно начинается с "test"
    func testExample() throws {
        // Arrange: готовим данные
        let a = 5
        let b = 3
        
        // Act: выполняем действие
        let result = a + b
        
        // Assert: проверяем результат
        XCTAssertEqual(result, 8, "Сумма 5 и 3 должна быть равна 8")
    }
}

Основные формы утверждений

Рассмотрим все варианты утверждений, которые предоставляет XCTest:

Метод Описание Параметры
XCTAssertEqual(exp1, exp2, message) Проверяет равенство значений exp1, exp2 — сравниваемые значения; message — сообщение при ошибке (необязательно)
XCTAssertNotEqual(exp1, exp2, message) Проверяет неравенство значений exp1, exp2 — сравниваемые значения
XCTAssertTrue(expr, message) Проверяет, что выражение истинно expr — булево выражение
XCTAssertFalse(expr, message) Проверяет, что выражение ложно expr — булево выражение
XCTAssertNil(expr, message) Проверяет, что значение nil expr — опциональное значение
XCTAssertNotNil(expr, message) Проверяет, что значение не nil expr — опциональное значение
XCTAssertThrowsError(expr, message) Проверяет, что выражение выбрасывает ошибку expr — выражение, которое может выбросить ошибку
XCTAssertNoThrow(expr, message) Проверяет, что выражение НЕ выбрасывает ошибку expr — выражение, которое может выбросить ошибку
XCTAssertGreaterThan(a, b, message) Проверяет, что a > b a, b — сравниваемые значения
XCTAssertLessThan(a, b, message) Проверяет, что a < b a, b — сравниваемые значения

Тестирование ошибок

В Swift есть два способа работы с ошибками: через throws и через Result. Рассмотрим оба:

// Тестируем функцию, которая выбрасывает ошибку
func testDivisionByZero() throws {
    // XCTAssertThrowsError проверяет, что код внутри блока выбрасывает ошибку
    XCTAssertThrowsError(try divide(10, by: 0)) { error in
        // Дополнительно можем проверить тип ошибки
        XCTAssertEqual(error as? MathError, MathError.divisionByZero)
    }
}

// Тестируем функцию, возвращающую Result
func testResultType() throws {
    let result = safeDivide(10, by: 2)
    switch result {
    case .success(let value):
        XCTAssertEqual(value, 5)
    case .failure(let error):
        XCTFail("Не ожидали ошибку: (error)")
    }
}

Асинхронные тесты

Современные приложения часто работают с сетью или базами данных асинхронно. Для таких случаев XCTest предоставляет XCTestExpectation:

func testAsyncOperation() throws {
    // Создаём ожидание
    let expectation = XCTestExpectation(description: "Загрузка данных")
    
    // Запускаем асинхронную операцию
    loadDataFromServer { data in
        // Проверяем, что данные не nil
        XCTAssertNotNil(data)
        // Сообщаем, что ожидание выполнено
        expectation.fulfill()
    }
    
    // Ждём выполнения ожидания (максимум 5 секунд)
    wait(for: [expectation], timeout: 5.0)
}

Примеры кода — от простого к сложному

Теперь перейдём к практике. Мы напишем 7 примеров — от самых простых до более сложных. Каждый пример будет рабочим и полностью объяснённым.

Пример 1: Тестирование простой математической функции

Начнём с самого базового — проверим функцию сложения.

import XCTest

// Функция, которую будем тестировать
func add(_ a: Int, _ b: Int) -> Int {
    return a + b
}

// Тестовый класс
final class MathTests: XCTestCase {
    
    func testAddition() {
        // Arrange: готовим входные данные
        let number1 = 5
        let number2 = 3
        
        // Act: вызываем тестируемую функцию
        let result = add(number1, number2)
        
        // Assert: проверяем, что результат равен 8
        // Если результат не совпадёт, покажется сообщение "5 + 3 должно быть 8"
        XCTAssertEqual(result, 8, "5 + 3 должно быть 8")
    }
    
    func testAdditionWithNegative() {
        // Тестируем сложение с отрицательными числами
        let result = add(-5, 3)
        XCTAssertEqual(result, -2, "-5 + 3 должно быть -2")
    }
    
    func testAdditionWithZero() {
        // Тестируем сложение с нулём
        let result = add(0, 7)
        XCTAssertEqual(result, 7, "0 + 7 должно быть 7")
    }
}

Объяснение: Мы создали три теста для одной функции. Каждый тест проверяет отдельный случай: обычное сложение, сложение с отрицательным числом и сложение с нулём. Это называется покрытие граничных случаев.

Пример 2: Тестирование строковых операций

Проверим функцию, которая переворачивает строку.

import XCTest

// Функция для переворота строки
func reverseString(_ input: String) -> String {
    return String(input.reversed())
}

final class StringTests: XCTestCase {
    
    func testReverseNormal() {
        // Обычный случай
        let result = reverseString("Hello")
        XCTAssertEqual(result, "olleH", "Строка 'Hello' должна перевернуться в 'olleH'")
    }
    
    func testReverseEmpty() {
        // Пустая строка
        let result = reverseString("")
        XCTAssertEqual(result, "", "Пустая строка должна остаться пустой")
    }
    
    func testReversePalindrome() {
        // Палиндром — слово, которое читается одинаково в обе стороны
        let result = reverseString("radar")
        XCTAssertEqual(result, "radar", "Палиндром 'radar' должен остаться неизменным")
    }
    
    func testReverseWithSpaces() {
        // Строка с пробелами
        let result = reverseString("Hello World")
        XCTAssertEqual(result, "dlroW olleH", "Пробелы тоже должны переворачиваться")
    }
}

Объяснение: Здесь мы видим, как важно тестировать разные типы входных данных: обычные строки, пустые строки, палиндромы и строки с пробелами. Каждый тест проверяет отдельное свойство функции.

Пример 3: Тестирование структуры с вычисляемыми свойствами

Создадим структуру для представления прямоугольника и протестируем её.

import XCTest

// Структура прямоугольника
struct Rectangle {
    let width: Double
    let height: Double
    
    // Вычисляемое свойство: площадь
    var area: Double {
        return width * height
    }
    
    // Вычисляемое свойство: периметр
    var perimeter: Double {
        return 2 * (width + height)
    }
    
    // Метод проверки: является ли прямоугольник квадратом
    func isSquare() -> Bool {
        return width == height
    }
}

final class RectangleTests: XCTestCase {
    
    func testArea() {
        // Создаём прямоугольник 5 на 3
        let rect = Rectangle(width: 5, height: 3)
        
        // Проверяем площадь: 5 * 3 = 15
        XCTAssertEqual(rect.area, 15, "Площадь 5*3 должна быть 15")
    }
    
    func testPerimeter() {
        let rect = Rectangle(width: 5, height: 3)
        
        // Проверяем периметр: 2 * (5 + 3) = 16
        XCTAssertEqual(rect.perimeter, 16, "Периметр 5*3 должен быть 16")
    }
    
    func testIsSquare() {
        // Квадрат
        let square = Rectangle(width: 4, height: 4)
        XCTAssertTrue(square.isSquare(), "Прямоугольник 4x4 должен быть квадратом")
        
        // Неквадрат
        let notSquare = Rectangle(width: 4, height: 5)
        XCTAssertFalse(notSquare.isSquare(), "Прямоугольник 4x5 не должен быть квадратом")
    }
    
    func testAreaWithZero() {
        // Прямоугольник с нулевой шириной
        let rect = Rectangle(width: 0, height: 5)
        XCTAssertEqual(rect.area, 0, "Площадь с нулевой шириной должна быть 0")
    }
}

Объяснение: Мы тестируем не просто функцию, а целую структуру с её свойствами и методами. Обрати внимание на тест testIsSquare — он проверяет оба возможных результата: и true, и false.

Пример 4: Тестирование функции с обработкой ошибок

Напишем функцию деления, которая выбрасывает ошибку при делении на ноль.

import XCTest

// Определяем свою ошибку
enum MathError: Error, Equatable {
    case divisionByZero
    case negativeResult
}

// Функция деления с проверками
func safeDivide(_ a: Double, _ b: Double) throws -> Double {
    // Проверяем деление на ноль
    guard b != 0 else {
        throw MathError.divisionByZero
    }
    
    let result = a / b
    
    // Проверяем отрицательный результат
    guard result >= 0 else {
        throw MathError.negativeResult
    }
    
    return result
}

final class DivisionTests: XCTestCase {
    
    func testNormalDivision() throws {
        // Обычное деление
        let result = try safeDivide(10, 2)
        XCTAssertEqual(result, 5, "10 / 2 должно быть 5")
    }
    
    func testDivisionByZero() {
        // Проверяем, что деление на ноль выбрасывает ошибку
        XCTAssertThrowsError(try safeDivide(10, 0)) { error in
            // Проверяем, что это именно та ошибка, которую мы ожидаем
            XCTAssertEqual(error as? MathError, MathError.divisionByZero)
        }
    }
    
    func testDivisionWithNegative() {
        // Проверяем, что отрицательный результат выбрасывает ошибку
        XCTAssertThrowsError(try safeDivide(-10, 2)) { error in
            XCTAssertEqual(error as? MathError, MathError.negativeResult)
        }
    }
    
    func testDivisionWithFraction() throws {
        // Деление с дробным результатом
        let result = try safeDivide(7, 2)
        XCTAssertEqual(result, 3.5, "7 / 2 должно быть 3.5")
    }
}

Объяснение: Здесь мы используем XCTAssertThrowsError для проверки, что функция правильно обрабатывает ошибочные ситуации. Мы также проверяем, что выбрасывается именно та ошибка, которую мы ожидаем.

Пример 5: Тестирование класса с состоянием

Создадим класс для управления банковским счётом.

import XCTest

// Класс банковского счёта
class BankAccount {
    private(set) var balance: Double
    
    init(initialBalance: Double) {
        self.balance = initialBalance
    }
    
    // Пополнение счёта
    func deposit(amount: Double) {
        guard amount > 0 else { return }
        balance += amount
    }
    
    // Снятие со счёта
    func withdraw(amount: Double) -> Bool {
        guard amount > 0, amount <= balance else {
            return false
        }
        balance -= amount
        return true
    }
}

final class BankAccountTests: XCTestCase {
    
    var account: BankAccount!
    
    // Эта функция выполняется перед каждым тестом
    override func setUp() {
        super.setUp()
        // Создаём новый счёт с балансом 1000
        account = BankAccount(initialBalance: 1000)
    }
    
    // Эта функция выполняется после каждого теста
    override func tearDown() {
        account = nil
        super.tearDown()
    }
    
    func testInitialBalance() {
        // Проверяем начальный баланс
        XCTAssertEqual(account.balance, 1000, "Начальный баланс должен быть 1000")
    }
    
    func testDeposit() {
        // Пополняем счёт на 500
        account.deposit(amount: 500)
        // Баланс должен стать 1500
        XCTAssertEqual(account.balance, 1500, "После пополнения на 500 баланс должен быть 1500")
    }
    
    func testWithdrawSuccess() {
        // Снимаем 300
        let success = account.withdraw(amount: 300)
        // Проверяем, что операция успешна
        XCTAssertTrue(success, "Снятие 300 должно быть успешным")
        // Проверяем новый баланс
        XCTAssertEqual(account.balance, 700, "После снятия 300 баланс должен быть 700")
    }
    
    func testWithdrawFailure() {
        // Пытаемся снять больше, чем есть на счёте
        let success = account.withdraw(amount: 2000)
        // Проверяем, что операция не удалась
        XCTAssertFalse(success, "Снятие 2000 должно быть неуспешным")
        // Баланс не должен измениться
        XCTAssertEqual(account.balance, 1000, "Баланс не должен измениться после неудачного снятия")
    }
    
    func testMultipleOperations() {
        // Несколько операций подряд
        account.deposit(amount: 500)   // 1000 + 500 = 1500
        account.withdraw(amount: 200)  // 1500 - 200 = 1300
        account.deposit(amount: 100)   // 1300 + 100 = 1400
        
        XCTAssertEqual(account.balance, 1400, "После серии операций баланс должен быть 1400")
    }
}

Объяснение: Обрати внимание на методы setUp() и tearDown(). Они гарантируют, что каждый тест начинается с чистого состояния. Это очень важно при тестировании объектов с состоянием.

Пример 6: Тестирование асинхронного кода

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

import XCTest

// Функция, которая выполняет действие после задержки
func delayedGreeting(name: String, completion: @escaping (String) -> Void) {
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
        completion("Привет, (name)!")
    }
}

final class AsyncTests: XCTestCase {
    
    func testDelayedGreeting() {
        // Создаём ожидание
        let expectation = XCTestExpectation(description: "Получение приветствия")
        
        // Вызываем асинхронную функцию
        delayedGreeting(name: "Анна") { greeting in
            // Проверяем результат
            XCTAssertEqual(greeting, "Привет, Анна!")
            // Сообщаем, что ожидание выполнено
            expectation.fulfill()
        }
        
        // Ждём выполнения (максимум 2 секунды)
        wait(for: [expectation], timeout: 2.0)
    }
    
    func testMultipleDelayedCalls() {
        // Тестируем несколько асинхронных вызовов
        let expectation1 = XCTestExpectation(description: "Первое приветствие")
        let expectation2 = XCTestExpectation(description: "Второе приветствие")
        
        delayedGreeting(name: "Иван") { greeting in
            XCTAssertEqual(greeting, "Привет, Иван!")
            expectation1.fulfill()
        }
        
        delayedGreeting(name: "Мария") { greeting in
            XCTAssertEqual(greeting, "Привет, Мария!")
            expectation2.fulfill()
        }
        
        // Ждём оба ожидания
        wait(for: [expectation1, expectation2], timeout: 2.0)
    }
}

Объяснение: Асинхронные тесты требуют особого подхода. Мы используем XCTestExpectation как «флажок», который говорит тесту: «Подожди, результат ещё не готов». Когда асинхронная операция завершается, мы вызываем fulfill(), и тест продолжается.

Пример 7: Тестирование с использованием mock-объектов

Иногда нам нужно протестировать код, который зависит от внешних сервисов (например, сети или базы данных). В таких случаях используют mock-объекты — «заглушки», которые имитируют поведение реальных объектов.

import XCTest

// Протокол для сервиса погоды
protocol WeatherService {
    func getTemperature(city: String) -> Int
}

// Реальный сервис (мы его не тестируем)
class RealWeatherService: WeatherService {
    func getTemperature(city: String) -> Int {
        // Здесь был бы запрос к реальному API
        return 25
    }
}

// Mock-сервис для тестирования
class MockWeatherService: WeatherService {
    var mockTemperature: Int = 20
    
    func getTemperature(city: String) -> Int {
        // Возвращаем заранее заданное значение
        return mockTemperature
    }
}

// Класс, который использует сервис погоды
class WeatherReporter {
    let service: WeatherService
    
    init(service: WeatherService) {
        self.service = service
    }
    
    func reportWeather(city: String) -> String {
        let temp = service.getTemperature(city: city)
        if temp > 25 {
            return "Жарко"
        } else if temp > 15 {
            return "Тепло"
        } else {
            return "Холодно"
        }
    }
}

final class WeatherTests: XCTestCase {
    
    func testHotWeather() {
        // Создаём mock с температурой 30 градусов
        let mockService = MockWeatherService()
        mockService.mockTemperature = 30
        
        let reporter = WeatherReporter(service: mockService)
        let report = reporter.reportWeather(city: "Москва")
        
        XCTAssertEqual(report, "Жарко", "При 30 градусах должно быть 'Жарко'")
    }
    
    func testWarmWeather() {
        let mockService = MockWeatherService()
        mockService.mockTemperature = 20
        
        let reporter = WeatherReporter(service: mockService)
        let report = reporter.reportWeather(city: "Москва")
        
        XCTAssertEqual(report, "Тепло", "При 20 градусах должно быть 'Тепло'")
    }
    
    func testColdWeather() {
        let mockService = MockWeatherService()
        mockService.mockTemperature = 5
        
        let reporter = WeatherReporter(service: mockService)
        let report = reporter.reportWeather(city: "Москва")
        
        XCTAssertEqual(report, "Холодно", "При 5 градусах должно быть 'Холодно'")
    }
}

Объяснение: Mock-объекты позволяют тестировать логику программы, не завися от внешних сервисов. Мы можем проверить все возможные сценарии, просто меняя возвращаемые значения mock-объекта.

Сравнение с Python: чем похоже и чем отличается

Если ты уже знаком с Python, тебе будет легче понять тестирование в Swift. Давай сравним два подхода.

Характеристика Swift (XCTest) Python (unittest)
Базовый класс XCTestCase unittest.TestCase
Именование тестов Метод должен начинаться с test Метод должен начинаться с test
Утверждения XCTAssertEqual, XCTAssertTrue и др. assertEqual, assertTrue и др.
Подготовка/очистка setUp() / tearDown() setUp() / tearDown()
Асинхронные тесты XCTestExpectation asyncio или сторонние библиотеки
Проверка ошибок XCTAssertThrowsError assertRaises
Запуск тестов Через Xcode (Cmd+U) или терминал python -m unittest
Типизация Строгая, на уровне компилятора Динамическая, проверяется во время выполнения

Основные отличия:

  • Типизация: В Swift типы проверяются на этапе компиляции. Если ты передашь строку туда, где ожидается число, код даже не скомпилируется. В Python такая ошибка проявится только во время выполнения теста.
  • Асинхронность: В Swift асинхронные тесты — встроенная возможность. В Python для этого нужно использовать дополнительные библиотеки или писать свои обёртки.
  • Интеграция с IDE: Swift тесно интегрирован с Xcode. Ты можешь запускать отдельные тесты, видеть зелёные/красные индикаторы прямо в редакторе кода.
  • Производительность: Swift-тесты обычно работают быстрее, потому что код компилируется, а не интерпретируется.
«Выбор языка для тестирования — это не вопрос «что лучше», а вопрос «что подходит под твою задачу». Swift — для iOS/macOS, Python — для анализа данных и веба.»

Типичные ошибки и подводные камни

Даже опытные разработчики иногда допускают ошибки при написании тестов. Вот самые распространённые из них.

Ошибка 1: Тестирование слишком многого в одном тесте

Плохой пример:

func testEverything() {
    let result1 = add(2, 3)
    XCTAssertEqual(result1, 5)
    
    let result2 = reverseString("Hello")
    XCTAssertEqual(result2, "olleH")
    
    let