JavaRush /Курсы /Swift SELF /Структура XCTest: XCTestCase, setUp/tearDown

Структура XCTest: XCTestCase, setUp/tearDown

Swift SELF
51 уровень , 3 лекция
Открыта

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()

И ещё одна короткая таблица для памяти:

Метод Когда вызывается Зачем
setUp()
перед каждым test...() создать «чистый старт»: SUT, данные, окружение
tearDown()
после каждого test...() очистить ресурсы, сбросить ссылки, не оставить мусор

3. SUT и организация подготовки

Что такое SUT и где хранить sut

В тестах часто используют термин SUTSystem 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 — это уважение к механике фреймворка и страховка от редких, но неприятных сюрпризов.

1
Задача
Swift SELF, 51 уровень, 3 лекция
Недоступна
Чистый счетчик
Чистый счетчик
1
Задача
Swift SELF, 51 уровень, 3 лекция
Недоступна
Список дел тесты
Список дел тесты
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ