1. Почему «красивый тест» — это скорость разработки
Если прод‑код пишется «для компьютера», то тесты в реальности пишутся «для человека из будущего». И чаще всего этот человек — вы же, но через две недели, когда вы уже не помните, почему parseCommand вдруг стал возвращать nil для строки "add War and Peace". Хороший тест экономит время двумя способами: он быстро объясняет сценарий и быстро объясняет причину падения.
Тест без структуры обычно выглядит как «сделал что‑то → упало где‑то». А тест со структурой AAA выглядит как короткая история: «подготовили вход → вызвали функцию → проверили результат». И мозг перестаёт «дешифровать» код, он начинает его просто читать.
AAA как мини‑протокол общения с самим собой
AAA расшифровывается так:
- Arrange — подготовка: данные, состояние, объект, который тестируем.
- Act — действие: один основной вызов/шаг.
- Assert — проверка: что именно мы ожидаем увидеть.
Удобно держать в голове вот такую схему:
flowchart LR
A[Arrange: подготовили вход и SUT] --> B[Act: выполнили действие]
B --> C[Assert: проверили ожидание]
Смысл не в том, чтобы всегда писать комментарии // Arrange, // Act, // Assert (хотя для новичков это очень помогает), а в том, чтобы в голове тест всегда распадался на эти три части.
2. Arrange: готовим данные так, чтобы тест читался «с первого взгляда»
В Arrange‑части мы отвечаем на вопрос: «в каком мире происходит этот тест?». То есть какие входные значения, какие начальные настройки и какой «герой истории» (SUT — System Under Test) участвует в сценарии. Хорошая Arrange‑часть максимально явная: вы не заставляете читателя искать входные данные по всему файлу и угадывать, что именно важно.
SUT: один герой на тест
SUT — это то, что мы тестируем: функция, метод или тип. Если у вас в тесте десять объектов, три сервиса и два парсера, то это либо интеграционный тест (мы его сегодня не обсуждаем), либо вы случайно пытаетесь протестировать половину приложения одним тестом. Для unit‑теста полезно держать «одного главного персонажа».
Представим, что в нашем учебном CLI‑приложении LibraryCLI есть маленькая функция нормализации команды: обрезаем пробелы и приводим к нижнему регистру.
import Foundation
public func normalizeCommand(_ input: String) -> String {
input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
Теперь тест.
import XCTest
import LibraryCLI
final class NormalizeCommandTests: XCTestCase {
func testNormalizeCommand_trimsAndLowercases() {
// Arrange
let input = " ADD "
// Act
let result = normalizeCommand(input)
// Assert
XCTAssertEqual(result, "add")
}
}
Здесь Arrange предельно честный: видно вход " ADD " прямо глазами. Это важнее, чем «сэкономить» две строки.
Магические числа и строки: тесты тоже страдают
«Магические значения» вредят не только прод‑коду, но и тестам. Если вы напишете:
XCTAssertEqual(normalizeCommand(" ADD "), "add")
это вроде компактно, но мозг читателя вынужден держать в голове сразу два значения: вход и ожидаемое. Когда тестов становится много, это утомляет. Вынесенные let input = … и let expected = … превращают тест в мини‑спецификацию.
Ещё один важный момент: Arrange — это не обязано быть setUp(). setUp() хорош, когда он создаёт «чистый старт» (например, новый пустой репозиторий или новый парсер). Но если сценарий зависит от конкретных данных, лучше держать их прямо в тесте, чтобы они были видны.
3. Act: «одно действие» и почему это делает падения понятными
Часть Act отвечает на вопрос: «что именно мы делаем?». И здесь есть очень полезное правило для новичков: пусть Act будет коротким и одиночным. Один основной вызов, один основной результат. Это не запрет на несколько строк кода, а запрет на «сценарий в 30 шагов», где непонятно, какой шаг сломался.
Если в вашем Act есть цикл, создание десяти объектов, запись в файл и сетевой запрос — поздравляю, вы случайно написали мини‑приложение внутри теста.
Act часто выглядит скучно — и это хорошо
Скучный Act — это как скучная инструкция: она скучная, потому что понятная. Например, тестируем простейший парсер команды add.
Допустим, у нас есть такой тип:
public enum Command: Equatable {
case add(title: String)
}
И функция парсинга:
public func parseAddCommand(_ input: String) -> Command? {
let parts = input.split(separator: " ").map(String.init)
guard parts.count >= 2 else { return nil }
guard parts[0].lowercased() == "add" else { return nil }
let title = parts.dropFirst().joined(separator: " ")
return .add(title: title)
}
Тест с аккуратным Act:
import XCTest
import LibraryCLI
final class ParseAddCommandTests: XCTestCase {
func testParseAddCommand_whenValid_returnsCommand() {
// Arrange
let input = "add War and Peace"
// Act
let command = parseAddCommand(input)
// Assert
XCTAssertEqual(command, .add(title: "War and Peace"))
}
}
Act здесь — одна строка. И когда тест упадёт, вы точно знаете: проблема либо в parseAddCommand, либо в ожидании, а не в «где‑то в середине сценария».
4. Assert: проверяем смысл, а не «как именно оно сделано внутри»
Assert‑часть отвечает на вопрос: «что является правильным результатом?». И здесь очень легко начать проверять лишнее: промежуточные переменные, внутренние шаги, частные детали реализации. Новичок часто думает так: «чем больше проверок — тем надёжнее». На практике «лишние» проверки делают тест хрупким: вы меняете реализацию без изменения поведения, а тесты начинают падать.
«Один тест — одно главное ожидание»
Это не означает «один XCTAssert… на тест», потому что иногда полезно проверить сразу два тесно связанных факта. Но тест должен проверять одно правило поведения. Например: «валидная команда add возвращает .add(title:)», а не «валидная команда add и ещё что‑то про пробелы, и ещё что‑то про форматирование, и ещё что‑то про логирование».
Сообщения к ассерту: не стесняйтесь пояснять
Когда тестов становится много, одинаковые падения выглядят одинаково. Сообщение помогает.
XCTAssertEqual(command, .add(title: "War and Peace"), "input=\(input)")
Это особенно полезно, когда вы делаете серию похожих проверок через helper.
5. Нейминг тестов: называем тест так, чтобы он объяснил падение
Имена тестов — это ваша мини‑документация. Более того: это документация, которую вы читаете постоянно, потому что IDE и консоль тест‑раннера показывают именно имя упавшего теста. Поэтому хороший нейминг — это не «красивость», а инструмент диагностики.
Важно помнить, что обнаружение тестов в XCTest во многом завязано на соглашения и механизмы рантайма (XCTest исторически использует метаданные Objective‑C runtime для поиска тест‑кейсов). Поэтому «как вы назвали» действительно влияет на «что запустится» и «что вы увидите в отчёте».
Ещё одно соглашение живёт на уровне SwiftPM: тестовые таргеты обычно лежат в Tests/ и имеют суффикс Tests, и эта предсказуемость — часть того, как экосистема ожидает видеть ваши тесты.
Практичный формат имени: testSubject_whenCondition_expectedResult
В Swift имена методов обычно пишут в lowerCamelCase, а в тестах часто добавляют подчёркивания, чтобы глаз быстрее цеплялся за смысл. Это один из редких случаев, когда underscore в названии — не преступление против человечества, а помощь читателю.
Сравним варианты:
| Плохое имя | Почему плохо | Хорошее имя | Почему хорошо |
|---|---|---|---|
|
ничего не говорит | |
понятно, что проверяем и что ожидаем |
|
слишком общее | |
есть сценарий и результат |
|
неясно «что add?» | |
видно условие ошибки |
Идея такая: имя теста должно отвечать минимум на два вопроса: что тестируем (subject) и при каких условиях (when/with/given). Третий кусок (expected) — это бонус, который часто превращает имя в полноценную спецификацию.
Given–When–Then как «внутренний голос» теста
Иногда удобно держать в голове Given–When–Then (это почти то же, что AAA, только словами). В тесте это выглядит так:
| Концепт | В коде теста |
|---|---|
| Given (дано) | Arrange |
| When (когда) | Act |
| Then (тогда) | Assert |
Вы не обязаны писать эти слова в комментариях, но если вы новичок — они реально помогают не превратить тест в кашу.
6. Практика: контраст, набор кейсов и мини‑helpers
Контраст: один и тот же смысл, но разная читаемость
Понять AAA проще всего через сравнение: одинаковая проверка, но разная форма записи.
Вариант 1: тест без структуры
import XCTest
import LibraryCLI
final class BadStyleTests: XCTestCase {
func testParse() {
XCTAssertEqual(parseAddCommand("add milk") , .add(title: " milk"))
}
}
В этом тесте сразу несколько проблем. Во-первых, имя ничего не говорит: «parse чего?». Во-вторых, вы не видите глазами, что является входом и что является ожидаемым — это смешано в одной строке. В-третьих, ожидание, скорее всего, неправильное: мы хотим "milk", а не " milk", но это не очевидно.
Вариант 2: тот же смысл, но AAA заставляет думать аккуратно
import XCTest
import LibraryCLI
final class ParseAddCommandWhitespaceTests: XCTestCase {
func testParseAddCommand_whenExtraSpaces_keepsTitleReadable() {
// Arrange
let input = "add milk"
// Act
let command = parseAddCommand(input)
// Assert
XCTAssertEqual(command, .add(title: "milk"), "input=\(input)")
}
}
Даже если этот тест упадёт, он упадёт «культурно»: по имени понятно, какой кейс, и по сообщению понятно, какой input.
Небольшая «линия» тестов вокруг одного правила
Теперь соберём эти идеи в небольшой набор тестов вокруг одного правила.
Пример: команда add без названия — это ошибка
import XCTest
import LibraryCLI
final class ParseAddCommandInvalidInputTests: XCTestCase {
func testParseAddCommand_whenMissingTitle_returnsNil() {
// Arrange
let input = "add"
// Act
let command = parseAddCommand(input)
// Assert
XCTAssertNil(command)
}
}
Супер‑важная деталь: тест короткий. Вы не «доказываете» его сложностью. Вы доказываете его ясностью.
Пример: команда в другом регистре всё равно должна работать
import XCTest
import LibraryCLI
final class ParseAddCommandCaseInsensitivityTests: XCTestCase {
func testParseAddCommand_whenUppercasedCommand_returnsAdd() {
// Arrange
let input = "ADD milk"
// Act
let command = parseAddCommand(input)
// Assert
XCTAssertEqual(command, .add(title: "milk"))
}
}
Обратите внимание: имя тест‑класса тоже стало «говорящим». Это не обязательно, но когда проект растёт, такие названия резко облегчают навигацию.
Мини‑helpers в тестах: убираем копипаст и не пишем «вторую реализацию»
Копипаст в тестах — штука двоякая. С одной стороны, тесты должны быть явными. С другой стороны, если у вас пять тестов отличаются одной строкой входа, вы начинаете ненавидеть жизнь и откладывать добавление новых кейсов. Компромисс — маленькие helper‑функции, которые прячут шум, но не прячут смысл.
Helper, который помогает оформлению (а не «пересчитывает правильно»)
import XCTest
import LibraryCLI
final class NormalizeCommandHelperTests: XCTestCase {
private func assertNormalized(_ input: String, equals expected: String) {
let result = normalizeCommand(input)
XCTAssertEqual(result, expected, "input=\(input)")
}
func testNormalizeCommand_multipleInputs() {
assertNormalized(" ADD ", equals: "add")
assertNormalized(" list ", equals: "list")
}
}
Этот helper не повторяет алгоритм нормализации (не делает trim/lowercased сам). Он только вызывает SUT и делает ассерт с контекстом. Это важно: если helper начнёт «сам» вычислять expected тем же способом, вы можете получить ситуацию «код и тест ошибаются одинаково» — и тесты радостно зелёные, пока приложение горит.
makeSUT() как способ не размазывать создание объекта
Если SUT — это тип (например, парсер), удобно создать его отдельной функцией, чтобы Arrange оставался читаемым:
import XCTest
import LibraryCLI
final class ParserTests: XCTestCase {
private func makeSUT() -> CommandParser {
CommandParser()
}
func testParser_whenValidAdd_returnsCommand() {
// Arrange
let sut = makeSUT()
let input = "add milk"
// Act
let command = sut.parse(input)
// Assert
XCTAssertEqual(command, .add(title: "milk"))
}
}
Даже если вы пока не используете setUp(), makeSUT() часто делает тесты аккуратнее. Но держите меру: если SUT создаётся в одну строку, иногда проще писать прямо в Arrange.
7. Типичные ошибки при оформлении тестов по AAA и выборе имён
Ошибка №1: тест превращается в «сценарий на пол‑жизни», где нет границ AAA.
Очень легко начать добавлять в тест «ещё пару действий»: сначала создали объект, потом вызвали один метод, потом второй, потом третий, потом сравнили всё на свете. В итоге падение теста не локализуется, и вы тратите время на чтение вместо диагностики. Лечится простым приёмом: один тест — одно правило поведения, один основной Act.
Ошибка №2: входные данные спрятаны так глубоко, что тест нужно исполнять в голове как программу.
Когда Arrange размазан по setUp(), по helper‑ам и по случайным константам вверху файла, тест перестаёт быть документацией. Он становится мини‑квестом. Хорошая практика для новичков: сценарные входы держать прямо в тест‑методе в виде let input = "...", даже если кажется, что это «лишние строки».
Ошибка №3: названия тестов вида test1, testParse, testOk.
Такой нейминг убивает смысл тестов в отчёте. Когда в CI падает testOk, вам не хочется чинить баг — вам хочется закрыть ноутбук и уйти в лес. Помогает шаблон testSubject_whenCondition_expectedResult, который превращает имя в диагноз.
Ошибка №4: Assert проверяет детали реализации, а не контракт поведения.
Например, вы начинаете проверять, сколько раз внутри парсера вызвался split, или какие промежуточные токены получились, хотя наружу это не выходит. Такие тесты ломаются при рефакторинге и мешают улучшать код. Лучше проверять конечный результат (возвращаемое значение) и важные наблюдаемые эффекты.
Ошибка №5: helper в тестах становится «второй реализацией алгоритма».
Это классическая ловушка: вы пишете helper, который «правильно считает expected», используя тот же подход, что и SUT. Если в алгоритме ошибка, helper повторит эту ошибку, и тесты будут зелёные. Хороший helper в тесте должен помогать оформлению (подписи, создание SUT, повторяющийся XCTAssert…), но не должен вычислять результат тем же способом.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ