Подстилая соломку
Подстилая соломку

Тестирование занимает важное место в iOS-разработке — без него нельзя гарантировать стабильность работы приложения в продакшене и оперативно выявлять возникающие баги. Но для части iOS-разработчиков тема тестов по-прежнему остается запутанной и сложной для понимания. Исправляем ситуацию.

Меня зовут Евгений Плёнкин. Я iOS разработчик компании СберЗдоровье. В предыдущем материале я рассказал об автоматизированном тестировании, кому оно нужно, месте модульных тестов в пирамиде тестирования и основном инструменте тестирования для iOS-разработчиков. Сегодня материал будет посвящен «чистым» тестам и видам имитирующих объектов, подменяющие реальные на время теста.

Приступим.

Статья написана в рамках серии «Модульное тестирование в iOS: всё, что надо знать».

«Чистые» тесты

Самый главный критерий, предъявляемый к коду, который пишет разработчик — читаемость. Обусловлено это тем, что его основным потребителем является не компилятор, а другой разработчик. 

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

Соблюдение требования читаемости особенно важно в долгосрочных проектах, когда без быстрого понимания кода сложно гарантировать отсутствие регресса приложения по функциональности и добавление новых функций без «поломки» уже внедрённых. 

Читаемость — общее требование, которое относится и к тестам. Роберт Мартин также ввёл понятие «чистый тест» (Clean test) — то есть тест, который хорошо читается. Есть три основных критерия чистого теста:

  • предметно-ориентированный язык — написан на понятном для человека языке;

  • отсутствие лишнего контекста — нет лишних вызовов, действий, манипуляций с кодом;

  • проверка одной истины.

Модульные тесты в iOS работают на основе xUnit архитектуры и пишутся в трех методах: setup (настройка), performTest (выполнение теста), tearDown (обнуление настройки). Эти же методы затрагивает и чистый тест.

Предметно-ориентированный язык

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

Код ниже — пример плохого описания теста с абстрактными названием тестового метода и переменными, которые не дают понимания сути тестирования.

func test1() {
    let l = Loader()
    let r = l.perform()
    XCTAssertEqual(r.count, 20)
}

С точки зрения читаемости корректнее указывать подробное название тестового метода с описанием ожиданий. Один из вариантов — написание названий тестовых методов через конструкцию «testThat» (проверяем что) с последующим подробным описанием сути теста. Такой шаблон имеет следующую конструкцию:

ПроверяемЧто<Система><СоответствуетТребованию>

testThat<SystemUnderTest><ExpectedRequirement>

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

func testThatLoaderReceivesRightRubricsAmount() {
    let rubricsLoader = Loader()
    let rubrics = rubricsLoader.perform()
    XCTAssertEqual(rubrics.count, 20, "<ОПИСАНИЕ>")
}

Другой вариант именования тестовых методов — формат разделения на Precondition (предварительное условие) и ExpectedOutcome (ожидаемые последствия), то есть предусловия и постусловия. При таком подходе мы указываем, какие условия нужны для проверки и что ожидаем получить на выходе.

test<Precondition><ExpectedOutcome>
test_<Precondition>_<ExpectedOutcome>

Например:

test_WhenNetworkRequestFails_UIDisplaysError
test_WhenURLIsNotHTTPS_RequestIsNotInitialised

Оба подхода — TestThat и разделение на Precondition и ExpectedOutcome — допустимы, если они правильно реализованы и подробно отображают суть теста, делая его читаемым.

Отсутствие лишнего контекста

Объемные тесты на десятки строк кода — не редкость. Но это не значит, что они должны превращаться в полотно с десятками ветвлений, которое сложно читать и невозможно понять без долгого вникания. Пример плохого теста — ниже.

func testThatUserManagerSaveUsers() {
    let firstUserName = "User1"
    let secondUserName = "User2"
        
    let userSaver = UserSaver()
    userSaver.saveUsersByName(names: [firstUserName, secondUserName])
        
    let users = userSaver.users
        
    var containFirstUser: Bool = false
    var containSecondUser: Bool = false
        
    for user in users {
        if user.name == firstUserName {
            containFirstUser = true
        } else if user.name == secondUserName {
            containSecondUser = true
        }
    }

    XCTAssertTrue(containFirstUser)
    XCTAssertTrue(containSecondUser)
}

Избежать подобных проблем можно с помощью рефакторинга — метода дробления больших методов на небольшие вспомогательные. Например с помощью техники extract method.

Еще одно требование к оформлению тела теста для улучшения понимания контекста происходящего — разделение тестового метода на смысловые блоки. Для этого используют паттерн arrange, act и assert (ААА). Подробнее о структуре тестовой проверки и возможной автоматизации говорилось в первой части.

  • Arrange — описывает состояние системы до начала тестирования, это precondition - область для задания предварительных условий выполнения.

  • Act — описывает, что мы тестируем. Чаще всего состоит из одного вызова. Например, конкретный метод из публичного интерфейса.

  • Assert — описывает блок с утверждениями, которые проверяют во время теста.

func testThatLoaderReceivesRightRubricsAmount() {
    // arrange
    let rubricsLoader = Loader()
    // act
    let rubrics = rubricsLoader.perform()
    // assert
    XCTAssertEqual(rubrics.count, 20, "<ОПИСАНИЕ>")
}

На уровне XCTest нет формы разделения на блоки arrange, act и assert. Поэтому на практике участки кода в теле тестовых проверок размечают вручную простыми комментариями.

Есть и другие варианты нотаций:

  • Given/When/Then. Идентичен паттерну ААА.

func testThatLoaderReceivesRightRubricsAmount() {
    // given
    let rubricsLoader = Loader()
    // when
    let rubrics = rubricsLoader.perform()
    // then
    XCTAssertEqual(rubrics.count, 20, "<ОПИСАНИЕ>")
}
  • Setup/Exercise/Verify/Teardown. На практике его почти не используют.

func testThatLoaderReceivesRightRubricsAmount() {
    // setup
    var rubricsLoader: Loader?
    rubricsLoader = Loader()
    let expectedRubricsCount = 20
    // exercise
    let rubrics = rubricsLoader.perform()
    // verify
    XCTAssertEqual(rubrics.count, expectedRubricsCount)
    // teardown
    rubricsLoader = nil
}

Проверка одной истины

Один из основных критериев чистоты теста — проверка одной истины (assert one truth).

Это правило трактуют по-разному.

Некоторые воспринимают его ультимативно — не более одного assert (утверждения) в рамках конкретной тестовой проверки. Но если жестко следовать такой парадигме, даже на маленький класс вроде UsernameValidater, появится порядка десяти тестовых проверок.

Согласно другому подходу в рамках одного теста должно проверяться одно поведение, одно требование к классу. То есть assert’ов может быть несколько, но проверяемое бизнес-правило — одно.

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

func verifyView() {
    XCTAssertFalse(tableView.isHidden)
    XCTAssertTrue(indicator.isHidden)
}

Тестируемый код

Тестируемый код — код, к которому можно написать модульный тест.

Есть несколько основных характеристик тестируемого кода:

  • контроль входных параметров;

  • явно предсказывает выходные параметры;

  • не имеет скрытых состояний, влияющих на выходные параметры конкретного метода.

Характеристик тестируемости кода, как и его не тестируемости, много. Для примера их можно свести в таблицу.

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

  • Все зависимости — явные. Если зависимость явная, мы можем на основе ее состояния и набора входных параметров предсказать выходное значение.

  • Порождающая логика отделена от поведенческой. Это значит, что в контексте swift’a надо использовать простые инициализаторы, а создание объекта отделять от поведенческой логики.

Assertions

Мы используем assert’ы для проверки результатов выполнения кода. Они помогают быстро найти место ошибки в коде и её причину. Для этого assert’ы используют свои параметры file и line. File показывает в каком файле ошибка, если не указывать, то используется имя файла, где запускается тест, line — в какой строке кода утверждение оказалось ложным, если не указывать, то строка вызова этой функции.

public func XCTFail(
    _ message: String = default,
    file: StaticString = #file,
    line: UInt = #line
)

Также есть третий опциональный параметр — message. Он вводится в лог и указывает, когда конкретно утверждение оказалось ложным и что пошло не так.

Assert размещается в соответствующем блоке трёх фразовой нотации.

func testExamples() {
    // arrange

    // act

    // assert
    XCTFail("message")
}

Параметр message полезен тем, что существенно упрощает понимание проблемы и ее сути. Например, если в тесте изначально нет message, сообщение об ошибке будет неинформативным.

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

Это отчасти решает ситуацию. Но здесь есть проблема — если тест затрагивает сразу несколько фрагментов кода, текст message придется дублировать. 

Поэтому иногда стоит писать собственные assert'ы, вынеся проверку в функцию и избегая дублирования кода. Собственные assert'ы, по аналогии с assert’ами из XCTest, пишутся с обязательным указанием параметров message, file и line.

func assertStringsEqual(
    actual: String,
    expected: String,
    file: StaticString = #file,
    line: UInt = #line
) {
    if actual != expected {
        XCTFail("Expected \(expected), but was \(actual)", file: file, line: line)
    }
}

Test doubles

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

Такая ситуация скорее исключение, зачастую есть зависимости. Это не исключает необходимость написания модульных тестов, а лишь создает дополнительные требования: все явные зависимости конкретного класса, для которого пишется тест, должны быть прикрыты. Если это правило не соблюдается и изоляция не обеспечивается, тест становится «хрупким» и повлиять на него могут даже другие участки тестового кода. В результате это может привести к миганию тестов, нестабильным пайплайнам сборки проектов и отсутствию положительных сторон покрытия тестами.

Обеспечить изоляцию можно с помощью тестовых двойников (Test Double). 

По определению Роберта Мартина тестовые двойники (test double) — объекты, которые имитируют поведение реальных. Они могут иметь разную степень приближенности к имитации и, в соответствии с ней, имеют разную степень ответственности за тестовую проверку.

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

Тестовые двойники могут быть организованы разными вариантами. Согласно Роберту Мартину есть два способа организации:

В первом случае тестовые двойники состоят из dummies, stubs, spies и mocks. Такой порядок расположения компонентов не случаен — каждый из них превосходит предыдущих по возможностям. Всё, что умеет dummy умеет и stub, spy умеет то же, что stub, а mock, наряду со своими возможностями, также умеет всё, что и все предыдущие. 

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

Например, есть протокол сервиса UserManagerProtocol, в котором есть один публичный метод.

protocol UserManagerProtocol {

    func save(username: String, password: String) -> Bool

}

Допустим, что некий viewController, на который мы будем писать тесты, зависит от UserManager. Получается, что UserManager — зависимость viewController.
Для тестирования нужна изоляция. Поскольку для изоляции нужно купировать зависимости, UserManager подменяется на test double.

Если тест пишется на сам UserManager, то он должен быть в тестовом коде реальным объектом, test double не покрываются тестами.

  • Dummy. Самая примитивная реализация, при обращении к любому методу которой, приложение падает. Dummy используют для возможности создания тестируемого объекта, но без обращения к подменяемой зависимости.

class UserManagerDummy: UserManagerProtocol {
    
    func save(username: String, password: String) -> Bool {
        fatalError("Dummy implementation")
    }

}
  • Stub. Вид тестового объекта, который отдает заранее подготовленный ответ на запрос тестируемого объекта, system under test.

class UserManagerStub: UserManagerProtocol {

    func save(username: String, password: String) -> Bool {
        return true
    }

}
  • Spy. Знает сколько раз был вызван метод. Может содержать аккумуляторы входных параметров. Через Spy тестируют поток управления: сколько раз и какой метод был вызван, какие параметры были переданы.

class UserManagerSpy: UserManagerProtocol {

    var saveCallsCount = 0
    func save(username: String, password: String) -> Bool {
        saveCallsCount += 1
        return true
    }

}
  • Mock или True Mock. Содержит наборы кастомных assert’ов под конкретный тип данных. Используется редко из-за своей сложности.

class UserManagerMock: UserManagerProtocol {

    var saveCallsCount = 0
    func save(username: String, password: String) -> Bool {
        saveCallsCount += 1
        return true
    }

    func verifySave(expectedCallCount: Int) {
        XCTAssertEqual(expectedCallCount, saveCallsCount)
    }

}

Теперь об элементе второй ветки — Fake. 

Fake — специфический тип объекта, который содержит бизнес-логику. В отличие от dummies, stubs, spies, mocks, он может быть использован как в тестовом, так и в продакшен коде. В последнем случае он может заменять серверную часть или обращение к базе данных, которая ещё не готова, считывая заранее подготовленные json файлы.

По итогу объект fake представляет собой упрощённый двойник оригинального объекта. Благодаря чему его легко и быстро можно внедрить, а время выполнения теста уменьшить. 

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

class UserManagerFake: UserManagerProtocol {

    func save(username: String, password: String) -> Bool {
        username == "UserFirst"
    }

}

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

Вместо выводов

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

В следующей статье я расскажу об ошибках при написании тестов и современных возможностях Xcode при написании модульных тестов.

А ваши тесты можно назвать чистым? Какие методы из описанных используете?

Комментарии (0)