$ sudo teach IT

Зачем нужны тесты

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

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

Официальные термины: Swift Testing

В экосистеме Swift есть современный фреймворк для тестирования — Swift Testing. Он подключается через import Testing и постепенно вытесняет более старый XCTest в новых проектах. Основные элементы:

  • @Test — атрибут перед функцией, который превращает обычную функцию в тест. В отличие от XCTest, тестовой функции не обязательно называться с приставки test и не обязательно быть методом класса, унаследованного от специального базового типа.
  • #expect(...) — макрос, который проверяет булево выражение. Если оно ложно, тест помечается как проваленный, но выполнение функции продолжается дальше — за один запуск вы узнаёте обо всех проблемах, а не только о первой.
  • #require(...) — похож на #expect, но при провале сразу прерывает тест. Используется, когда без выполнения условия проверять дальше бессмысленно — например, когда нужно развернуть опционал перед следующими шагами.

Как выглядит тест

import Testing

@Test func сложениеПоложительных() {
    let result = 2 + 2
    #expect(result == 4)
}

Функция помечена @Test, поэтому среда тестирования находит её сама и запускает без дополнительной регистрации. Внутри #expect проверяет обычное булево выражение: если оно ложно, тест зафиксирует провал вместе с местом в коде, где это произошло.

@Test func первыйЭлементСуществует() throws {
    let items = [1, 2, 3]
    let first = try #require(items.first)
    #expect(first == 1)
}

Здесь #require разворачивает опционал items.first. Если бы массив оказался пустым, #require сразу прервал бы тест — не пришлось бы дальше работать со значением nil и получать неочевидную ошибку где-то ниже по коду.

Параметризованные тесты

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

@Test(arguments: [1, 4, 9, 16])
func этоТочныйКвадрат(_ n: Int) {
    let root = Int(Double(n).squareRoot())
    #expect(root * root == n)
}

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

Что стоит проверять тестами

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

Не стоит писать тесты ради самого факта их наличия: проверка того, что 1 + 1 == 2, или тест, который просто вызывает функцию и ничего не сравнивает, не приносит пользы. Также обычно не тестируют код, зависящий от внешнего мира без специальной подготовки — сеть, текущее время, случайные числа: результат каждый раз разный, и тест будет то падать, то проходить без изменений в коде.

Чем это отличается от XCTest

XCTest требует, чтобы тестовый метод жил внутри класса-наследника XCTestCase и назывался с приставки test — например, testДеление(). Проверки там делаются через семейство функций XCTAssert... (XCTAssertEqual, XCTAssertTrue и другие). Swift Testing устроен свободнее: тест — это обычная функция с атрибутом @Test, её можно объявить где угодно в тестовом модуле, а параметризация из коробки избавляет от ручного дублирования. Оба подхода до сих пор используются в реальных проектах, но новые проекты чаще выбирают Swift Testing.

От теории к практике

В редакторе задач этого курса пакет Testing недоступен, поэтому запустить @Test и #expect прямо здесь не получится. Зато можно потренироваться в том, из чего тестирование складывается по сути: написать функцию, которую легко проверить, и написать саму проверку — обычным кодом, без специального фреймворка. Именно этим вы и займётесь в задаче ниже.

Частые ошибки

  • Писать функцию так, что её результат зависит от внешнего состояния — глобальной переменной, файла, времени. Такую функцию тяжело тестировать: при одинаковом входе она может давать разный результат.
  • Проверять сразу много всего в одном тесте так, что при провале непонятно, что именно сломалось. Лучше несколько маленьких проверок с понятными именами, чем одна большая.
  • Путать #expect и #require: если после проверки код обращается к развёрнутому значению, а вы использовали #expect, то при провале программа всё равно попытается использовать несуществующее значение и упадёт с посторонней ошибкой.
  • Тестировать только «счастливый путь» и забывать про пустую строку, пустую коллекцию, нуль, отрицательное число — именно на этих случаях чаще всего находятся настоящие ошибки.

Резюме

  • Тест — это код, который вызывает вашу функцию и сравнивает результат с ожидаемым, чтобы не проверять всё вручную заново при каждом изменении.
  • Swift Testing подключается через import Testing; тестовая функция помечается атрибутом @Test.
  • #expect фиксирует провал и продолжает тест; #require при провале сразу прерывает выполнение.
  • @Test(arguments: [...]) запускает один тест несколько раз с разными значениями без копирования кода.
  • Тестами стоит покрывать функции с чёткой логикой и обязательно проверять граничные случаи, а не только обычный сценарий.

Проверьте себя

3 вопроса

Функция и её собственная проверка

В песочнице этого курса нет пакета Testing, поэтому напрямую воспользоваться @Test и #expect не получится. Вместо этого напишите две обычные функции — так, как если бы вы готовили код к тестированию вручную.

Первая функция — isPalindrome(_ text: String) -> Bool. Она должна определять, читается ли строка одинаково слева направо и справа налево, без учёта регистра букв. Например, "шалаш" и "Kayak" — палиндромы, а "Swift" — нет. Пустая строка и строка из одного символа считаются палиндромами.

Вторая функция — checkResult(_ actual: Bool, expected: Bool) -> Bool. Она играет роль простейшей проверки в духе #expect: должна вернуть true, если actual совпадает с expected, и false в противном случае.