Начало работы с модульными тестами в Swift

Модульные тесты в языках программирования гарантируют, что написанный код работает должным образом. Учитывая определенный ввод, вы ожидаете, что код будет иметь определенный вывод. Тестируя свой код, вы создаете уверенность для рефакторинга и выпусков, поскольку вы гарантируете, что код работает должным образом после успешного выполнения набора тестов.

Многие разработчики не пишут тесты, так как считают, что это занимает слишком много времени и рискует не уложиться в сроки. На мой взгляд, модульные тесты заставят вас уложиться в сроки, поскольку вы потратите меньше времени на исправление ошибок или выполнение исправлений для критической проблемы.

Это не будет охватывать тестирование утечек памяти или написание тестов пользовательского интерфейса для расширений общего доступа, но в основном будет сосредоточено на написании более качественных модульных тестов. Я также поделюсь своими лучшими практиками, которые помогли мне разработать более качественные и стабильные приложения.

Что такое модульное тестирование?

Модульные тесты — это автоматизированные тесты, которые запускают и проверяют фрагмент кода (известный как «модуль»), чтобы убедиться, что он ведет себя так, как задумано, и соответствует своему дизайну.

Модульные тесты имеют свою цель в Xcode и написаны с использованием среды XCTest. Подкласс XCTestCase содержит тестовые методы для запуска, в которых только методы, начинающиеся с «тест», будут проанализированы Xcode и доступны для запуска.

В качестве примера представьте, что у вас есть метод расширения строки для прописной буквы первой буквы:

extension String {
    func uppercasedFirst() -> String {
        let firstCharacter = prefix(1).capitalized
        let remainingCharacters = dropFirst().lowercased()
        return firstCharacter + remainingCharacters
    }
}

Мы хотим убедиться, что метод uppercasedFirst() работает должным образом. Если мы дадим ему ввод antoine, мы ожидаем, что он выведет Antoine. Мы можем написать модульные тесты для этого метода, используя метод XCTAsertEqual:

final class StringExtensionsTests: XCTestCase {
    func testUppercaseFirst() {
        let input = "antoine"
        let expectedOutput = "Antoine"
        XCTAssertEqual(input.uppercasedFirst(), expectedOutput, "The String is not correctly capitalized.")
    }
}

Если наш метод больше не работает должным образом, Xcode покажет ошибку, используя предоставленное нами описание:

Написание юнит-тестов на Swift

Есть несколько способов проверить один и тот же результат, но он не всегда дает одинаковую обратную связь в случае сбоя теста. Следующие советы помогут вам написать тесты, которые помогут вам быстрее решать неудачные тесты, используя подробные сообщения об ошибках.

Именование тестовых случаев и методов

Очень важно описать ваши модульные тесты, чтобы вы понимали, что тест пытается проверить. Вероятно, вы тестируете слишком много вещей, если не можете придумать короткое имя. Хорошее имя также поможет вам быстрее справляться с неудачными тестами.

Чтобы быстро найти тестовый пример для определенного класса, рекомендуется использовать это же имя в сочетании с «Тесты». Как и в приведенном выше примере, мы назвали StringExtensionTests на основании того факта, что мы тестируем набор расширений строк. Другим примером может быть ContentViewModelTests, если вы тестируете экземпляр ContentViewModel.

Не используйте XCTAsert для всего

XCTAsert можно использовать во многих сценариях, но в случае сбоя теста это приведет к разным результатам. Все следующие строки кода проверяют один и тот же результат:

func testEmptyListOfUsers() {
    let viewModel = UsersViewModel(users: ["Ed", "Edd", "Eddy"])
    XCTAssert(viewModel.users.count == 0)
    XCTAssertTrue(viewModel.users.count == 0)
    XCTAssertEqual(viewModel.users.count, 0)
}

Как видите, в методе используется описывающее имя, указывающее на необходимость тестирования пустого списка пользователей. Однако наша определенная модель представления не пуста, и поэтому все утверждения терпят неудачу.

Результаты показывают, почему важно использовать правильное утверждение для типа проверки. Метод XCTAsertEqual дает нам больше информации о том, почему утверждение не удалось. Это показано в красных ошибках и журналах консоли, которые помогают быстро определить неудачные тесты.

Установка и демонтаж

Параметры, используемые в нескольких методах тестирования, могут быть определены как свойства в вашем классе тестового примера. Вы можете использовать метод setUp() для установки начального состояния для каждого тестового метода и метод tearDown() для очистки. Вы можете выбрать несколько вариантов настройки и варианта отключения, например, один, поддерживающий параллелизм, или вариант броска, в котором вы можете провалить тест раньше, если настройка не удалась.

Примером может быть создание экземпляра пользовательских значений по умолчанию для использования в модульных тестах:

struct SearchQueryCache {
    var userDefaults: UserDefaults = .standard

    func storeQuery(_ query: String) {
        /// ...
    }
}

final class SearchQueryCacheTests: XCTestCase {

    private var userDefaults: UserDefaults!
    private var userDefaultsSuiteName: String!

    override func setUpWithError() throws {
        try super.setUpWithError()
        userDefaultsSuiteName = UUID().uuidString
        userDefaults = UserDefaults(suiteName: userDefaultsSuiteName)
    }

    override func tearDownWithError() throws {
        try super.tearDownWithError()
        userDefaults.removeSuite(named: userDefaultsSuiteName)
        userDefaults = nil
    }

    func testSearchQueryStoring() {
        /// Use the generated user defaults as input:
        let cache = SearchQueryCache(userDefaults: userDefaults)

        /// ... write the test
    }
}

Это гарантирует, что вы не манипулируете стандартными пользовательскими настройками по умолчанию, используемыми во время тестирования в симуляторе. Во-вторых, вы убедитесь, что в начале теста у вас чистое состояние. Мы использовали метод удаления, чтобы удалить набор пользовательских настроек по умолчанию и соответствующим образом очистить.

Методы Throwing

Как и при написании кода приложения, вы также можете определить метод броскового теста. Это позволяет вам сделать тест непройденным всякий раз, когда метод внутри теста выдает ошибку. Например, при тестировании декодирования ответа JSON:

func testDecoding() throws {
    /// When the Data initializer is throwing an error, the test will fail.
    let jsonData = try Data(contentsOf: URL(string: "user.json")!)

    /// The `XCTAssertNoThrow` can be used to get extra context about the throw
    XCTAssertNoThrow(try JSONDecoder().decode(User.self, from: jsonData))
}

Метод XCTAsertNoThrow можно использовать, когда результат метода броска не требуется для дальнейшего выполнения теста. Вы должны использовать метод XCTAsertThrowsError, чтобы соответствовать ожидаемому типу ошибки. В качестве примера вы можете написать тесты для валидатора лицензионного ключа:

struct LicenseValidator {
    enum Error: Swift.Error {
        case emptyLicenseKey
    }

    func validate(licenseKey: String) throws {
        guard !licenseKey.isEmpty else {
            throw Error.emptyLicenseKey
        }
    }
}

class LicenseValidatorTests: XCTestCase {
    let validator = LicenseValidator()

    func testThrowingEmptyLicenseKeyError() {
        XCTAssertThrowsError(try validator.validate(licenseKey: ""), "An empty license key error should be thrown") { error in
            /// We ensure the expected error is thrown.
            XCTAssertEqual(error as? LicenseValidator.Error, .emptyLicenseKey)
        }
    }

    func testNotThrowingLicenseErrorForNonEmptyKey() {
        XCTAssertNoThrow(try validator.validate(licenseKey: "XXXX-XXXX-XXXX-XXXX"), "Non-empty license key should pass")
    }
}

Развертка необязательных значений

Метод XCTUnwrap лучше всего использовать в бросающем тесте, так как это бросающее утверждение:

func testFirstNameNotEmpty() throws {
    let viewModel = UsersViewModel(users: ["Antoine", "Maaike", "Jaap"])

    let firstName =  try XCTUnwrap(viewModel.users.first)
    XCTAssertFalse(firstName.isEmpty)
}

XCTUnwrap утверждает, что значение необязательной переменной не равно нулю, и возвращает его значение, если утверждение успешно. Это не позволяет вам писать XCTAsertNotNil в сочетании с распаковкой или обработкой условной цепочки для остальной части тестового кода. Я рекомендую вам прочитать мою статью Как тестировать опционалы в Swift с помощью XCTest для более подробной информации.

Запуск модульных тестов в Xcode

После того, как вы написали свои тесты, пришло время их запустить. Благодаря следующим советам это стало немного более продуктивным.

Использование тестовых треугольников

Вы можете запустить отдельные тесты или набор тестов, используя ведущий треугольник:

Тот же квадрат отображается красным или зеленым цветом в зависимости от последних результатов тестовых прогонов.

Повторите последний тест

Повторно запустите последний тест запуска, используя:

⌃ Control + ⌥ Option + ⌘ Command + G.

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

Запустите комбинацию тестов

Выберите тесты, которые вы хотите запустить, используя CTRL или SHIFT, щелкните правой кнопкой мыши и выберите «Выполнить X Test Methods».

Применение фильтров в навигаторе тестов

Панель фильтров в нижней части навигатора тестов позволяет сузить обзор тестов.

  • Используйте поле поиска для поиска конкретного теста по его названию.
  • Показать только тесты для текущей выбранной схемы. Это полезно, если у вас есть несколько схем тестирования.
  • Показывать только неудачные тесты. Это поможет вам быстро найти, какой тест не прошел.

Включить покрытие на боковой панели

Счетчик итераций теста показывает, был ли выполнен конкретный фрагмент кода во время последнего запуска теста.

Он показывает количество итераций (18 в приведенном выше примере), а фрагмент кода становится зеленым при достижении. Когда часть кода окрашена в красный цвет, это означает, что она не была покрыта во время последнего запуска тестов.

Ваше мышление при написании модульных тестов

Ваше мышление — отличная отправная точка для написания качественных модульных тестов. С помощью нескольких основных принципов вы обеспечите производительность, сохраните фокус и напишете тесты, в которых больше всего нуждается ваше приложение.

Ваш тестовый код так же важен, как и код вашего приложения.

После погружения в практические советы я хочу рассказать о необходимом мышлении. Как и при написании кода приложения, вы должны сделать все возможное, чтобы написать качественный код для тестов.

Подумайте о повторном использовании кода, использовании протоколов, определении свойств, если они используются в нескольких тестах, и обеспечении того, чтобы ваши тесты очищали все созданные данные. Это облегчит поддержку ваших модульных тестов и предотвратит ненадежные и странные сбои тестов. Если вы новичок в ненадежных тестах, я рекомендую вам прочитать мою статью Решение нестабильных тестов с использованием повторений тестов в Xcode.

100% покрытие кода не должно быть вашей целью

Хотя это цель для многих, 100% покрытие не должно быть вашей основной целью при написании тестов. Сначала обязательно протестируйте хотя бы самую важную бизнес-логику, так как это уже отличное начало. Достижение 100% может занять много времени, а преимущества не всегда значительны. Даже для достижения 100% может потребоваться много усилий.

Кроме того, 100% охват может вводить в заблуждение. Приведенный выше пример модульного теста имеет 100% покрытие, так как он охватывает все методы. Однако он не тестировал все сценарии, так как тестировался только с непустым массивом. В то же время возможен и сценарий с пустым массивом, в котором свойство hasUsers должно возвращать false.

Вы можете включить тестовое покрытие из окна настроек схемы. Это окно можно открыть, выбрав

Product ➞ Scheme ➞ Edit Scheme

Напишите тест перед исправлением ошибки

Заманчиво прыгнуть на ошибку и исправить ее как можно скорее. Хотя это здорово, было бы еще лучше, если бы вы могли предотвратить повторение той же ошибки в будущем. Написав модульный тест перед исправлением ошибки, вы гарантируете, что такая же ошибка больше не повторится. См. это как «Исправление ошибок через тестирование», также известное как TDBF ;-).

Во-вторых, вы можете начать писать исправление и запустить новый модульный тест, чтобы убедиться, что исправление работает. Этот метод быстрее, чем запуск симулятора, чтобы убедиться, что ваше исправление работает.

Заключение

Написание качественных модульных тестов является важным навыком для разработчика. Вы сможете создать уверенность в своей кодовой базе, гарантируя, что вы ничего не сломаете перед новым выпуском. Используя правильные утверждения, вы позволяете себе быстрее решать неудачные тесты. Обеспечьте как минимум тестирование критически важного бизнес-кода и избегайте достижения 100% покрытия кода.

Если вы хотите улучшить свои знания Swift, посетите страницу категории Swift. Не стесняйтесь обращаться ко мне или написать мне в Твиттере, если у вас есть какие-либо дополнительные советы или отзывы.

Спасибо!