JavaRush /Курсы /Swift SELF /Arrange–Act–Assert и нейминг тестов

Arrange–Act–Assert и нейминг тестов

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

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 в названии — не преступление против человечества, а помощь читателю.

Сравним варианты:

Плохое имя Почему плохо Хорошее имя Почему хорошо
test1()
ничего не говорит
testNormalizeCommand_trimsAndLowercases()
понятно, что проверяем и что ожидаем
testParse()
слишком общее
testParseAddCommand_whenValid_returnsCommand()
есть сценарий и результат
testAdd()
неясно «что add?»
testParseAddCommand_whenMissingTitle_returnsNil()
видно условие ошибки

Идея такая: имя теста должно отвечать минимум на два вопроса: что тестируем (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…), но не должен вычислять результат тем же способом.

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