Как всегда, лучше всего читать этот пост на Swift by Sundell 👍
Хотя принудительное развертывание (с использованием !
) является важной функцией Swift, без которой было бы трудно работать (особенно при взаимодействии с API-интерфейсами Objective-C), оно также позволяет обойти некоторые другие функции, которые делают Swift таким замечательным. Как мы рассмотрели в Обработка необязательных опций в Swift, использование принудительного развертывания при работе с опциями, которые на самом деле требуются логикой программы, может привести к в действительно сложных ситуациях и сбоях.
Таким образом, отказ от принудительного развертывания (когда это возможно) может помочь нам создавать более стабильные приложения и лучше выводить сообщения об ошибках, когда что-то идет не так, но как насчет написания тестов? Для безопасной работы с необязательными параметрами и неизвестными типами может потребоваться довольно много кода, поэтому вопрос в том, хотим ли мы выполнять всю эту дополнительную работу при написании тестов? Это то, что мы рассмотрим на этой неделе - давайте погрузимся!
Тесты против производственного кода
При работе с тестами мы часто проводим четкое различие между нашим тестовым кодом и нашим производственным кодом. Хотя важно хранить обе эти две кодовые базы отдельно (мы не хотим случайно отправлять наши макеты как часть нашей сборки App Store 😅), это не обязательно различие, которое мы должны использовать, когда говорим о качестве кода .
Если задуматься, каковы некоторые из причин, по которым мы хотим иметь высокий стандарт качества для кода, который доставляется нашим пользователям?
- Мы хотим, чтобы наше приложение было стабильным и бесперебойно работало для наших пользователей.
- Мы хотим, чтобы наше приложение было легко поддерживать и легко модифицировать в будущем.
- Мы хотим упростить привлечение новых людей в нашу команду.
Теперь, если мы вместо этого подумаем о наших тестах, чего мы хотим избегать?
- Нестабильные, нестабильные и трудные для отладки тесты.
- Тесты, на обслуживание и обновление которых уходит много времени, когда в наше приложение добавляются новые функции.
- Тесты, которые трудно понять новым людям, которые присоединяются к нашей команде.
Вы можете увидеть, к чему я клоню.
Долгое время я относился к тестируемому коду как к чему-то, что я просто быстро собрал, потому что кто-то сказал мне, что я должен писать тесты. Я не особо заботился об их качестве, потому что рассматривал их как рутинную работу, которую я вообще не хотел выполнять. Однако, как только я начал воочию убедиться, насколько быстрее я могу проверить свой код и насколько я уверен в том, что код действительно работает, мое отношение к тестам начало меняться.
Поэтому в наши дни я считаю важным, чтобы код тестирования соответствовал тем же высоким стандартам, что и производственный код отгрузки. Поскольку наш набор тестов - это то, с чем мы должны постоянно работать, обновлять и поддерживать, мы должны сделать это простым.
Проблема с принудительным разворачиванием
Так какое отношение все это имеет к принудительному развертыванию в Swift? 🤔
Хотя иногда бывает необходимо принудительное развертывание, его легко сделать незаменимым решением при написании тестов. Давайте посмотрим на пример, в котором мы пишем тест, чтобы убедиться, что механизм входа в UserService
работает должным образом:
class UserServiceTests: XCTestCase { func testLoggingIn() { // Setup a mock to always return a successful response // for the login endpoint let networkManager = NetworkManagerMock() networkManager.mockResponse(forEndpoint: .login, with: [ "name": "John", "age": 30 ])
// Setup a service and login let service = UserService(networkManager: networkManager) service.login(withUsername: "john", password: "password")
// Now we want to make assertions based on the logged in user, // which is an optional, so we force unwrap it let user = service.loggedInUser! XCTAssertEqual(user.name, "John") XCTAssertEqual(user.age, 30) } }
Как вы можете видеть выше, мы принудительно разворачиваем loggedInUser
нашей службы, прежде чем делать для нее утверждения. Хотя выполнение чего-то подобного выше не обязательно неправильно, это может привести к некоторым проблемам в будущем, если этот тест по какой-то причине начнет давать сбой.
Допустим, кто-то (и помните, «кто-то» всегда может означать «ваше будущее я» 😉) вносит изменения в сетевой код, что приводит к сбою вышеуказанного теста. Если это произойдет, будет доступно только следующее сообщение об ошибке:
Fatal error: Unexpectedly found nil while unwrapping an Optional value
Хотя это может не быть большой проблемой при локальной работе в Xcode (поскольку ошибка будет отображаться встроенно - по крайней мере, большую часть времени 🙃), это может стать довольно проблематичным, если это начнется при запуске непрерывной интеграции для проекта. Вышеупомянутое сообщение об ошибке может появиться внутри большой «стены текста», из-за чего очень сложно понять, откуда оно взялось. Кроме того, это предотвратит выполнение любых последующих тестов (поскольку процесс тестирования выйдет из строя), что может сделать его очень медленным и раздражающим при работе над исправлением.
Guard и XCTFail
Одно из возможных решений вышеупомянутой проблемы - просто использовать оператор guard
для изящного разворачивания рассматриваемого необязательного параметра и вызвать XCTFail()
в случае сбоя, например:
guard let user = service.loggedInUser else {
XCTFail("Expected a user to be logged in at this point")
return
}
Хотя в некоторых ситуациях описанный выше подход является допустимым, я действительно рекомендую избегать его, поскольку он добавляет поток управления в ваши тесты. Для обеспечения стабильности и предсказуемости вы обычно хотите, чтобы тесты следовали простому заданному правилу, когда, затем структурирование и добавление потока управления действительно могут затруднить чтение тестов. Если вам действительно не повезло, поток управления также может быть источником ложных срабатываний (подробнее об этом в следующей публикации).
Придерживаемся опций
Другой подход - оставить опциональные опции необязательными. Для некоторых случаев использования это полностью работает, включая наш UserManager
пример. Поскольку мы выполняем утверждения для name
и age
вошедшего в систему пользователя, мы автоматически получим сообщение об ошибке, если любое из этих свойств будет nil
. Если мы также введем дополнительную XCTAssertNotNil
проверку самого пользователя, у нас будет довольно надежный тест с отличной диагностикой.
let user = service.loggedInUser
XCTAssertNotNil(user, "Expected a user to be logged in at this point")
XCTAssertEqual(user?.name, "John")
XCTAssertEqual(user?.age, 30)
Теперь, если наш тест даст сбой, мы получим следующую информацию:
XCTAssertNotNil failed - Expected a user to be logged in at this point
XCTAssertEqual failed: ("nil") is not equal to ("Optional("John")")
XCTAssertEqual failed: ("nil") is not equal to ("Optional(30)")
Это значительно упрощает понимание того, что пошло не так и что нам нужно сделать, чтобы отладить и исправить проблему 🎉.
Метание тестов
Третий вариант, который действительно полезен в некоторых ситуациях, - это замена API-интерфейсов, возвращающих необязательные параметры, на вызывающие. Прелесть создания API-интерфейсов в Swift заключается в том, что их можно очень легко использовать в качестве дополнительных, когда это необходимо, поэтому во многих случаях вы не жертвуете удобством использования, выбирая подход бросания. Например, предположим, что у нас есть EndpointURLFactory
, который создает URL-адреса для определенных конечных точек в нашем приложении, который в настоящее время возвращает необязательный:
class EndpointURLFactory {
func makeURL(for endpoint: Endpoint) -> URL? {
...
}
}
Давайте теперь вместо этого преобразуем его в метательный API, например:
class EndpointURLFactory {
func makeURL(for endpoint: Endpoint) throws -> URL {
...
}
}
Все, что нам нужно сделать, когда нам все еще нужен необязательный URL, - это вызвать его с помощью try?
:
let loginEndpoint = try? urlFactory.makeURL(for: .login)
Большое преимущество, которое дает нам выполнение вышеизложенного с точки зрения тестирования, заключается в том, что теперь мы можем просто использовать try
в наших тестах и получить обработку недопустимых значений совершенно бесплатно с помощью бегуна XCTest. Это немного скрытая жемчужина, но тесты Swift на самом деле могут выдавать функции, проверьте это:
class EndpointURLFactoryTests: XCTestCase { func testSearchURLContainsQuery() throws { let factory = EndpointURLFactory() let query = "Swift"
// Since our test function is throwing, we can simply use 'try' here let url = try factory.makeURL(for: .search(query)) XCTAssertTrue(url.absoluteString.contains(query)) } }
Никаких опций, принудительного разворачивания и отличной диагностики на случай, если что-то начнет выходить из строя 👍.
Требование дополнительных опций
Однако не все API-интерфейсы можно преобразовать из возвращающих опциональных элементов в метательное. Но оказывается, что есть довольно хороший способ получить те же преимущества, что и при тестировании бросающих API, при написании тестов, также содержащих необязательные параметры.
Вернемся к первому UserManager
примеру. Что, если вместо того, чтобы принудительно развернуть loggedInUser
или рассматривать его как необязательный, мы могли бы просто сделать это:
let user = try require(service.loggedInUser)
XCTAssertEqual(user.name, "John")
XCTAssertEqual(user.age, 30)
Было бы здорово! 😎 Таким образом, мы могли бы избавиться от большого количества принудительного развертывания, но в то же время не усложняли бы наши тесты при написании или усложнении выполнения. Итак, что нам нужно сделать, чтобы достичь вышеуказанного? Это довольно просто, все, что нам нужно сделать, это добавить расширение к XCTestCase
, которое позволяет нам оценивать любое необязательное выражение и либо возвращать необязательное значение, либо выдавать ошибку, например:
extension XCTestCase { // We conform to LocalizedError in order to be able to output // a nice error message. private struct RequireError<T>: LocalizedError { let file: StaticString let line: UInt
// It's important to implement this property, otherwise we won't // get a nice error message in the logs if our tests starts to fail. var errorDescription: String? { return "😱 Required value of type \(T.self) was nil at line \(line) in file \(file)." } }
// Using file and line lets us automatically capture where // the expression took place in our source code. func require<T>(_ expression: @autoclosure () -> T?, file: StaticString = #file, line: UInt = #line) throws -> T { guard let value = expression() else { throw RequireError<T>(file: file, line: line) }
return value } }
Теперь, с учетом вышеизложенного, если наш UserManager
тест входа в систему начинает давать сбой, мы получим очень красивое сообщение об ошибке, которое дает нам точное место сбоя:
[UserServiceTests testLoggingIn] : failed: caught error: 😱 Required value of type User was nil at line 97 in file UserServiceTests.swift.
Вы можете узнать эту технику по моей микросхеме Require, которая добавляет метод require () ко всем параметрам для улучшения диагностики неизбежных принудительных развертываний.
Заключение
Поначалу может показаться неудобным относиться к вашему тестовому коду с той же осторожностью, что и к коду приложения, но в долгосрочной перспективе это может значительно упростить поддержку тестов - как при работе над чем-то самостоятельно, так и в большой команде. Включение хорошей диагностики и сообщений об ошибках является важной частью этого, поэтому, используя некоторые методы из этого поста, вы, надеюсь, сможете избежать множества сложных проблем в будущем.
Единственный раз, когда я всегда использую принудительно развернутые опции в тестовом коде, - это при настройке свойств в тестовых примерах. Поскольку они всегда будут создаваться в setUp
и удаляться в tearDown
, я не думаю, что стоит использовать их как настоящие опции. Как всегда, вам нужно взглянуть на свой собственный код и применить свои предпочтения, чтобы увидеть, какие компромиссы, по вашему мнению, стоит пойти.
Что вы думаете? Будете ли вы применять некоторые методы из этого поста в своем тестовом коде или уже используете что-то подобное? Дайте мне знать вместе с любыми вопросами, комментариями или отзывами, которые могут у вас возникнуть - здесь, в разделе комментариев к Swift by Sundell, или напишите мне в Twitter @johnsundell.
Спасибо за прочтение! 🚀