1. unit‑тесты и как устроен XCTest в SwiftPM
Когда люди впервые слышат «unit‑тесты», они часто представляют что-то вроде «дополнительной домашки для роботов». На практике это скорее ремень безопасности: пока едете медленно — кажется лишним, но однажды спасает день (и нервы). Однако прежде чем писать умные проверки, нужно понять «коробку передач»: где тесты живут, как запускаются, кто их находит и почему setUp()/tearDown() — это не декоративные методы, а важный ритуал чистоты.
Unit‑тест — это автоматическая проверка поведения маленького кусочка кода (функции, метода, небольшого типа). «Маленький» здесь ключевое слово: мы не тестируем весь мир, мы тестируем правило. Например: «нормализация команды обрезает пробелы и приводит к нижнему регистру».
Где живут XCTest‑тесты: папка Tests/ и test target
Если Sources/ — это «кухня», где готовится продукт, то Tests/ — это «санэпидстанция», которая приходит с белыми перчатками и говорит: “А ну-ка, покажите, как вы это готовили”. В Swift Package Manager тесты действительно живут отдельно: это отдельная цель сборки (test target), и это важно, потому что тесты компилируются отдельно и импортируют ваш код как модуль.
В SwiftPM есть довольно жёсткая (и полезная) договорённость: тестовые таргеты располагаются под верхнеуровневой папкой Tests/, и имя тестового таргета обычно заканчивается на Tests. SwiftPM как раз опирается на такие соглашения, чтобы понимать «это тесты или нет» и строить правильную модель сборки.
Пример типичной структуры пакета (упрощённо):
LibraryCLI/
├─ Package.swift
├─ Sources/
│ └─ LibraryCLI/
│ └─ main.swift
└─ Tests/
└─ LibraryCLITests/
└─ SmokeTests.swift
Запуск тестов в терминале выглядит максимально дружелюбно (редкий случай в IT):
swift test
И здесь важная мысль: тесты — это такой же Swift‑код, просто запускаемый специальным раннером.
XCTestCase: «контейнер» для тестов и почему он выглядит как класс
Когда вы впервые видите final class SomethingTests: XCTestCase, может возникнуть вопрос: «Почему класс? Мы же любим struct!» Любим, да. Но XCTest исторически построен вокруг объектной модели, и тестовый набор — это объект, у которого есть жизненный цикл: создать, подготовить, выполнить тест, прибраться. Для этого class подходит естественно.
Кроме того, на Apple‑платформах XCTest исторически использует механизмы Objective‑C runtime, чтобы находить тесты среди наследников XCTestCase (по метаданным классов и методов). На других платформах и в SwiftPM‑сценарии тесты обычно обнаруживаются через логику самого SwiftPM (он «индексирует» тестовые методы и передаёт список тестов раннеру). Вам не нужно сейчас запоминать эти внутренности, но полезно понимать: XCTest действительно ищет тесты, а не запускает «что попало».
Минимальный тестовый класс выглядит так.
import XCTest
final class SmokeTests: XCTestCase {
func testExample() {
XCTAssertTrue(true) // тест, который всегда проходит (иногда это полезно)
}
}
Здесь уже видно главное:
- import XCTest — подключили фреймворк.
- final class …: XCTestCase — объявили тестовый класс.
- func test…() — тестовый метод.
Как XCTest находит тесты: почему метод должен начинаться с test
В XCTest есть простое, но железобетонное соглашение: тест — это метод экземпляра класса XCTestCase, имя которого начинается с test, и у него «обычная» сигнатура (без параметров, возвращаемое значение — Void). На практике это означает: хотите, чтобы метод запускался как тест — называйте test....
Это не каприз. XCTest устроен так, что он «находит» тесты автоматически по соглашению, а не по ручному списку. На Apple‑платформах это завязано на метаданные и runtime‑интроспекцию, а в SwiftPM — на механизм обнаружения тестов в пакете.
Посмотрим на простой пример.
import XCTest
final class DiscoveryTests: XCTestCase {
func helperNotATest() {
// Этот метод НЕ запустится как тест: имя не начинается с "test"
}
func testRuns() {
XCTAssertTrue(true) // Этот запустится
}
}
Практический вывод: если вы написали тест и он «не запускается», первое, что проверяем — имя метода. Это типичная ситуация даже у опытных разработчиков: мозг занят логикой, а пальцы назвали метод «как в обычном коде».
2. Жизненный цикл: setUp() и tearDown()
С setUp()/tearDown() новичков чаще всего подстерегает одна и та же ловушка: кажется, что setUp() вызывается «один раз перед всеми тестами». Но нет. В XCTest базовая модель такая: каждый тест должен начинаться с чистого состояния, поэтому setUp() выполняется перед каждым тестовым методом, а tearDown() — после каждого.
То есть реальный порядок примерно такой:
setUp → testA → tearDown → setUp → testB → tearDown → ...
Это сделано специально, чтобы тесты не зависели от порядка запуска, не «подъедали» состояние друг друга и не превращались в сериал «в предыдущей серии…».
Нарисуем это в виде маленькой схемы.
sequenceDiagram
participant R as XCTest Runner
participant T as YourTests (XCTestCase instance)
R->>T: setUp()
R->>T: testFirst()
R->>T: tearDown()
R->>T: setUp()
R->>T: testSecond()
R->>T: tearDown()
И ещё одна короткая таблица для памяти:
| Метод | Когда вызывается | Зачем |
|---|---|---|
|
перед каждым test...() | создать «чистый старт»: SUT, данные, окружение |
|
после каждого test...() | очистить ресурсы, сбросить ссылки, не оставить мусор |
3. SUT и организация подготовки
Что такое SUT и где хранить sut
В тестах часто используют термин SUT — System Under Test, то есть «объект, который мы тестируем». Иногда это будет функция, иногда — тип (struct/class), иногда — сервис. Чтобы тесты читались проще, SUT часто называют sut (да, это выглядит как “сут”… и да, я тоже в первый раз прочитал это как звук, который издаёт компилятор, когда вы забыли import).
Логика обычно такая: если SUT создаётся одинаково для каждого теста, удобнее создать его в setUp(). Если же для разных тестов нужна разная конфигурация, то лучше создавать SUT прямо в тесте, чтобы входные данные были видны глазами и не прятались в «магии setUp()».
Начнём с простого примера: у нас есть маленький тип Counter.
struct Counter {
private(set) var value: Int = 0
mutating func inc() {
value += 1
}
}
Теперь тесты, где sut создаётся в setUp().
import XCTest
final class CounterTests: XCTestCase {
private var sut: Counter!
override func setUp() {
super.setUp()
sut = Counter()
}
override func tearDown() {
sut = nil
super.tearDown()
}
func testInc_incrementsValue() {
sut.inc()
XCTAssertEqual(sut.value, 1)
}
}
Здесь мы сделали сразу несколько важных вещей, которые стоит проговорить словами:
- Мы объявили sut как Counter!. Это «implicitly unwrapped optional», и он здесь уместен именно потому, что мы гарантируем контракт: sut будет проинициализирован в setUp() до начала любого теста. Если контракт нарушится — тесты упадут быстро и громко, и это нормально.
- Мы вызываем super.setUp() и super.tearDown(). Это хорошая привычка: базовый класс тоже может выполнять свою внутреннюю подготовку/уборку.
- Мы в tearDown() ставим sut = nil. Для value‑types это не критично, но это дисциплина: у вас в курсе дальше будут reference‑типы и ресурсы, и там «обнулять ссылки» — полезный рефлекс.
Почему setUp() не должен превращаться в «сценарий на 200 строк»
Когда вы распробуете setUp(), очень хочется положить туда всё: создать SUT, создать тестовые данные, сделать 15 объектов, подготовить 3 массива, загрузить фикстуры… И вот тут начинается тихая трагедия.
Проблема не в том, что так нельзя. Проблема в том, что тест перестаёт читаться. Вы открываете testSomething() и видите «Act и Assert», а «Arrange» спрятан в setUp() и ещё в трёх helper‑методах. В результате тест падает, а вы сидите и играете в детектива: «Так, а что же у нас было в исходных данных?»
Поэтому базовое правило для начинающих такое: setUp() должен делать только то, что одинаково почти для всех тестов и действительно является «фоновой подготовкой». Всё, что относится к конкретному сценарию, лучше писать прямо в тесте, чтобы контекст был на виду.
Сравним два подхода на микро‑примере нормализации команды для нашего CLI.
import Foundation
func normalizeCommand(_ input: String) -> String {
input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
Если SUT — это просто функция, setUp() не нужен вообще, потому что «готовить» нечего. Тест будет максимально прозрачным.
import XCTest
final class NormalizeCommandTests: XCTestCase {
func testNormalizeCommand_trimsAndLowercases() {
let result = normalizeCommand(" ADD ")
XCTAssertEqual(result, "add")
}
}
Тут всё видно без прыжков по файлу: вход " ADD ", ожидание "add".
Пример: первый тест в LibraryCLI
Теперь приземлимся в наш учебный проект. Мы не будем сейчас усложнять архитектуру и спорить, какой target тестировать — идея лекции именно в механике XCTestCase и жизненного цикла. Представим, что в Sources/LibraryCLI/ у нас есть файл CommandNormalizer.swift с функцией нормализации.
import Foundation
func normalizeCommand(_ input: String) -> String {
input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
Дальше создаём файл тестов, например Tests/LibraryCLITests/NormalizeCommandTests.swift.
import XCTest
@testable import LibraryCLI
final class NormalizeCommandTests: XCTestCase {
func testNormalizeCommand_whenSpacesAndUppercase_returnsLowercasedTrimmed() {
let result = normalizeCommand(" ADD ")
XCTAssertEqual(result, "add")
}
}
Здесь появляется @testable import. Сегодня мы не углубляемся в модульные границы (это будет отдельной лекцией), но минимально важно понимать: тесты импортируют ваш production‑код как модуль, потому что они живут в отдельном test target. А @testable позволяет тестам видеть internal‑символы импортируемого модуля (но не private и не fileprivate).
И теперь запуск:
swift test
Если тест падает, swift test покажет, какой именно тест и в каком месте. Это не магия — это просто дисциплина: тесты называются предсказуемо, запускаются автоматически, и каждый начинается с чистого состояния.
Ещё пример setUp()/tearDown(): тесты не делят состояние
Самый быстрый способ «прочувствовать» setUp()/tearDown() — написать два теста, один из которых меняет состояние, а второй проверяет, что состояние снова чистое. Это почти как проверить, что ваш сосед по общаге всё-таки моет кружку после кофе (спойлер: обычно нет, но XCTest — моет).
import XCTest
final class CounterIsolationTests: XCTestCase {
private var sut: Counter!
override func setUp() {
super.setUp()
sut = Counter()
}
override func tearDown() {
sut = nil
super.tearDown()
}
func testInc_changesValue() {
sut.inc()
XCTAssertEqual(sut.value, 1)
}
func testStartsFromZero_eachTime() {
XCTAssertEqual(sut.value, 0)
}
}
Если бы setUp() вызывался «один раз на все тесты», второй тест мог бы увидеть value == 1 (если первый выполнялся раньше). Но в XCTest каждый тест окружён своим setUp()/tearDown(), поэтому второй тест должен стабильно видеть ноль.
4. Типичные ошибки при работе с XCTestCase и setUp()/tearDown()
Ошибка №1: тест «не запускается», потому что метод не начинается с test.
Это самая частая история. Вы написали func checkParse() и уверены, что это тест. XCTest так не думает. Соглашение — часть механизма обнаружения тестов. Если не соблюсти его, метод будет просто обычным методом и никогда не выполнится как тест.
Ошибка №2: ожидание, что setUp() вызывается один раз на класс.
Из-за этого люди кладут в setUp() «накопительное состояние» и удивляются, почему тесты иногда проходят, а иногда нет. На самом деле setUp() и tearDown() оборачивают каждый тест. Это сделано, чтобы тесты были независимыми по порядку и не делили состояние.
Ошибка №3: общий sut, который живёт между тестами.
Если вы сделали static var sut или создали SUT где-то глобально, вы фактически отменили идею изоляции. Такой набор тестов начинает «протекать»: один тест подготовил состояние, второй случайно им воспользовался, третий сломался при запуске отдельно. В норме SUT создаётся заново для каждого теста — либо в setUp(), либо прямо в тесте.
Ошибка №4: setUp() превращён в огромный скрытый сценарий.
Формально тесты проходят, но чтение падающего теста становится квестом: чтобы понять входные данные, нужно пролистать setUp(), потом helper, потом ещё один helper. Гораздо лучше держать setUp() минимальным: «создать SUT», «подготовить базовую конфигурацию», а сценарные данные писать в самом тесте.
Ошибка №5: забыли вызвать super.setUp() / super.tearDown().
Часто без этого «и так работает», но это плохая привычка. XCTestCase — базовый класс с собственной логикой. Вызов super — это уважение к механике фреймворка и страховка от редких, но неприятных сюрпризов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ