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»:
- Arrange (Подготовка) — создаём данные и объекты, необходимые для теста.
- Act (Действие) — выполняем тестируемую функцию.
- 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