Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Очень часто подготовка кода к модульному (или юнит-) тестированию имеет обыкновение идти рука об руку с работой по разделению ответственности, улучшению управления состояниями и его архитектуры в целом. Как правило, чем лучше абстрагирован и организован ваш код, тем легче его будет покрыть автоматизированными тестами.
Однако, стремясь сделать код более тестируемым, мы очень часто можем обнаружить, что в рамках этого процесса вводим массу новых протоколов и других видов абстракций, и в конечном итоге значительно усложняем наш код — особенно при тестировании асинхронного кода, который полагается на ту или иную форму сетевого взаимодействия.
Но действительно ли мы обречены платить такую цену за тестируемость? Что, если бы мы могли сделать наш код полностью пригодным для тестирования таким образом, чтобы от нас не требовалось вводить какие-либо новые протоколы, всевозможные моки или сложные абстракции? Давайте же разберемся, как мы могли бы реализовать это, используя новые возможности async/await
Swift.
Внедренное сетевое взаимодействие
Допустим, мы работаем над приложением, включающим в себя следующую ProductViewModel
, которая использует очень распространенный шаблон получения своего URLSession
(который будет использоваться для выполнения сетевых вызовов) - путем внедрения через инициализатор:
class ProductViewModel {
var title: String { product.name }
var detailText: String { product.description }
var price: Price { product.price(in: localUser.currency) }
...
private var product: Product
private let localUser: User
private let urlSession: URLSession
init(product: Product, localUser: User, urlSession: URLSession = .shared) {
self.product = product
self.localUser = localUser
self.urlSession = urlSession
}
func reload() async throws {
let url = URL.forLoadingProduct(withID: product.id)
let (data, _) = try await urlSession.data(from: url)
let decoder = JSONDecoder()
product = try decoder.decode(Product.self, from: data)
}
}
В приведенном выше коде нет ничего крамольного. Он работает, он использует внедрение зависимостей, чтобы избежать прямого доступа к URLSession.shared
как к синглтону (что уже имеет огромные преимущества с точки зрения тестирования и архитектуры в целом), даже если он все-равно по умолчанию использует инстанс shared
, из соображений удобства.
Тем не менее, можно определенно утверждать, что встраивание необработанных сетевых вызовов в такие типы, как модели (view models) и контроллеры (view controllers) представлений, — это то, чего в идеале следует избегать в целях лучшего разделения ответственности в нашем проекте и возможности повторно использовать этот сетевой код всякий раз, когда нам нужно выполнить аналогичный запрос в где-нибудь другом месте.
Таким образом, чтобы улучшить приведенный выше пример, мы можем извлечь код загрузки продукта из нашей модели представления в отдельный специальный тип ProductLoader
:
class ProductLoader {
private let urlSession: URLSession
init(urlSession: URLSession = .shared) {
self.urlSession = urlSession
}
func loadProduct(withID id: Product.ID) async throws -> Product {
let url = URL.forLoadingProduct(withID: id)
let (data, _) = try await urlSession.data(from: url)
let decoder = JSONDecoder()
return try decoder.decode(Product.self, from: data)
}
}
Далее, если мы заставим нашу модель представления использовать этот новый ProductLoader
, а не напрямую взаимодействовать с URLSession, то мы значительно упростим ее реализацию, поскольку теперь она может просто вызывать loadProduct
всякий раз, когда от нее требуется перезагрузить базовую модель данных:
class ProductViewModel {
...
private var product: Product
private let localUser: User
private let loader: ProductLoader
init(product: Product, localUser: User, loader: ProductLoader) {
self.product = product
self.localUser = localUser
self.loader = loader
}
func reload() async throws {
product = try await loader.loadProduct(withID: product.id)
}
}
Это уже значительное улучшение, но что, если теперь мы хотим реализовать пару модульных тестов, чтобы убедиться, что наша модель представления ведет себя так, как мы ожидаем? Для этого нам нужно мокать сетевое взаимодействие нашего приложения, поскольку мы определенно не хотим выполнять какие-либо реальные сетевые вызовы в наших модульных тестах (поскольку это может добавить задержки, некоторую ненадежность и потребовать от нас всегда быть онлайн во время работы над нашей кодовой базой).
Мокинг на основе протоколов
Одним из вариантов реализации такого мока было бы создание абстракции (протокола) Networking
, которая, по сути, просто требует от нас дублировать сигнатуру URLSession.data
в рамках этого протокола, а затем привести URLSession в соответствие с нашим новый протокол через экстеншн — вот так:
protocol Networking {
func data(
from url: URL,
delegate: URLSessionTaskDelegate?
) async throws -> (Data, URLResponse)
}
extension Networking {
// Если мы хотим избежать необходимости всегда передавать 'delegate: nil'
// в местах вызова, где нам не нужно использовать делегат, то следует
//также добавить следующий удобный API (который URLSession предоставляет
//сам при его непосредственном использовании):
func data(from url: URL) async throws -> (Data, URLResponse) {
try await data(from: url, delegate: nil)
}
}
extension URLSession: Networking {}
Это позволит нам заставить ProductLoader
принимать любой объект, который соответствует нашему новому протоколу Networking
, а не только конкретный инстанс URLSession
как раньше (для удобства мы по-прежнему будем использовать URLSession.shared
):
class ProductLoader {
private let networking: Networking
init(networking: Networking = URLSession.shared) {
self.networking = networking
}
func loadProduct(withID id: Product.ID) async throws -> Product {
let url = URL.forLoadingProduct(withID: id)
let (data, _) = try await networking.data(from: url)
let decoder = JSONDecoder()
return try decoder.decode(Product.self, from: data)
}
}
Теперь, когда вся эта подготовительная работа завершена, мы наконец можем приступить к написанию наших тестов. Начнем мы с создания мок-реализации нашего протокола Networking
, а затем ProductLoader
и ProductViewModel
, которые используют эту мок-реализацию для выполнения всех сетевых вызовов, что, в свою очередь, позволяет нам писать наши тесты следующим образом:
class NetworkingMock: Networking {
var result = Result<Data, Error>.success(Data())
func data(
from url: URL,
delegate: URLSessionTaskDelegate?
) async throws -> (Data, URLResponse) {
try (result.get(), URLResponse())
}
}
class ProductViewModelTests: XCTestCase {
private var product: Product!
private var networking: NetworkingMock!
private var viewModel: ProductViewModel!
override func setUp() {
super.setUp()
product = .stub()
networking = NetworkingMock()
viewModel = ProductViewModel(
product: product,
localUser: .stub(),
loader: ProductLoader(networking: networking)
)
}
func testReloadingProductUpdatesTitle() async throws {
product.name = "Reloaded product"
networking.result = try .success(JSONEncoder().encode(product))
XCTAssertNotEqual(viewModel.title, product.name)
try await viewModel.reload()
XCTAssertEqual(viewModel.title, product.name)
}
...
}
Если хотите узнать больше о методе .stub()
, который вызывается выше для создания стаб-версий наших моделей данных, ознакомьтесь со статьей “Defining testing data in Swift”.
Отлично! Мы успешно отрефакторили всю нашу ProductViewModel
, чтобы сделать его полностью тестируемой, и начали покрывать ее модульными тестами. Очень хорошо.
Но если мы внимательнее посмотрим на приведенный выше тестовый пример, мы увидим, что наш ProductLoader
практически не задействован в нашем тестовом коде. Это потому, что в данном случае нас интересует только мокинг нашего сетевого кода, поскольку его было бы достаточно проблематично запускать в контексте тестирования.
Вот теперь определенно можно утверждать, что нам следовало бы добавить дополнительный протокол и мок-слой для ProductLoader
, что позволило бы нам мокать его напрямую, а не использовать его реальную реализацию с мок-инстансом сетевого взаимодействия. Вы даже можете возразить, что приведенный выше модульный тест на самом деле вовсе не является модульным тестом, а по сути представляет из себя интеграционный тест, поскольку он объединяет несколько модулей (наша модель представления, загрузчик продукта и сетевое взаимодействие) для выполнения проверок.
Однако, если бы мы пошли по этому хрестоматийному для модульного тестирования пути и ввели еще один протокол и тип-мок, то мы могли бы быстро скатиться по скользкой дорожке, где каждый отдельный объект в нашей кодовой базе также имеет связанный с ним протокол и тип-мок, что привело бы к большому дублированию кода и дополнительной сложности (даже при использовании инструментов генерации кода для автоматического создания всех этих типов).
Но, возможно, есть способ, которым мы могли бы получить желаемое не утонув во всем этом вспомогательном коде? Давайте посмотрим, сможем ли мы заставить наш вышеприведенный тест-кейс просто взять и проверить нашу ProductViewModel
одним модулем, а также избавиться от всех этих моков и протоколов, специально созданных в целях тестируемости, в процессе.
Добавим немного функционального программирования
Если мы перестанем думать о коде загрузки нашего продукта с точки зрения объектно-ориентированных конструкций, таких как классы и протоколы, и вместо этого посмотрим на него с более функциональной точки зрения, то мы могли бы переписать код загрузки нашей модели представления, используя следующая сигнатуру функции:
typealias Loading<T> = () async throws -> T
Это функция, которая асинхронно загружает некоторое значение и либо возвращает его, либо выдает ошибку.
Затем давайте еще раз изменим нашу ProductViewModel
, чтобы теперь она принимала некоторую функцию, соответствующую приведенной выше сигнатуре (специализированную нашей моделью Product
), ане инстанс ProductLoader
как раньше:
class ProductViewModel {
...
private var product: Product
private let localUser: User
private let reloading: Loading<Product>
init(product: Product,
localUser: User,
reloading: @escaping Loading<Product>) {
self.product = product
self.localUser = localUser
self.reloading = reloading
}
func reload() async throws {
product = try await reloading()
}
}
Один момент, который мне очень нравится в приведенном выше шаблоне, заключается в том, что он по-прежнему позволяет нам продолжать использовать существующие Networking
и ProductLoader
, как и раньше — все, что нам нужно сделать, это вызвать этот код с reloading функции/замыкания, которую мы передаем в нашу ProductViewModel
при ее создании:
func makeProductViewModel(
for product: Product,
localUser: User,
networking: Networking
) -> ProductViewModel {
let loader = ProductLoader(networking: networking)
return ProductViewModel(
product: product,
localUser: localUser,
reloading: {
try await loader.loadProduct(withID: product.id)
}
)
}
Если вы уже давно читаете Swift by Sundell, то вы можете узнать приведенный выше шаблон из “Functional networking in Swift” 2019-го года, в которой для достижения аналогичного результата использовались Future и Promise.
Но вот где все становится действительно интересно. Теперь при модульном тестировании нашей ProductViewModel
нам больше не нужно ни беспокоиться о мокинге нашего сетевого взаимодействия, ни даже создавать ProductLoader
— все, что нам нужно сделать, это внедрить встроенное (inline) замыкание, возвращающее определенное значение типа Product
, которое мы затем можем изменять (mutate) всякий раз, когда мы хотим каким-либо образом изменить наш reloading-ответ:
class ProductViewModelTests: XCTestCase {
private var product: Product!
private var viewModel: ProductViewModel!
override func setUp() {
super.setUp()
product = .stub()
viewModel = ProductViewModel(
product: product,
localUser: .stub(),
reloading: { [unowned self] in self.product }
)
}
func testReloadingProductUpdatesTitle() async throws {
product.name = "Reloaded product"
XCTAssertNotEqual(viewModel.title, product.name)
try await viewModel.reload()
XCTAssertEqual(viewModel.title, product.name)
}
...
}
Обратите внимание, что во всем нашем тест-кейсе больше нет никаких протоколов или типов-моков! Поскольку теперь мы полностью отделили нашу ProductViewModel
от нашего сетевого кода, мы можем провести модульное тестирование этого класса в полной изоляции от всего остального, поскольку он просто получает доступ к замыканию, которое откуда-то загружает значение типа Product
.
Масштабирование
Но теперь возникает большой вопрос — как этот шаблон масштабируется, если нам нужно выполнять несколько видов операций загрузки в пределах данного типа? Чтобы ответить на этот вопрос, давайте начнем с введения второго типа сигнатуры асинхронной функции, которая позволит нам выполнять экшн по заданному значению:
typealias AsyncAction<T> = (T) async throws -> Void
Затем предположим, что мы хотим расширить нашу ProductViewModel
поддержкой добавления данного продукта в избранные (путем его маркировки), а также иметь возможность добавлять этот продукт в сформированный пользователем список. Чтобы мы могли это сделать, на нужно внедрить эти две новые функции в виде отдельных замыканий — вот так:
class ProductViewModel {
...
private let reloading: Loading<Product>
private let favoriteToggling: Loading<Product>
private let listAdding: AsyncAction<List.ID>
init(product: Product,
localUser: User,
reloading: @escaping Loading<Product>,
favoriteToggling: @escaping Loading<Product>,
listAdding: @escaping AsyncAction<List.ID>) {
self.product = product
self.localUser = localUser
self.reloading = reloading
self.favoriteToggling = favoriteToggling
self.listAdding = listAdding
}
func reload() async throws {
product = try await reloading()
}
func toggleProductFavoriteStatus() async throws {
product = try await favoriteToggling()
}
func addProductToList(withID listID: List.ID) async throws {
try await listAdding(listID)
}
}
Приведенный выше код по-прежнему вполне себе работает, но наша реализация начинает становиться немного запутанной, так как теперь при инициализации нашей модели представления нам приходится жонглировать несколькими замыканиями.
Итак, давайте вдохновимся статьей “Extracting view controller actions in Swift” и сгруппируем три приведенных выше замыкания в структуру Action’ов, которая привнесет в наш код какую-никакую структуру (простите за каламбур) как при реализации, так и при инициализации нашей ProductViewModel
:
class ProductViewModel {
...
private let actions: Actions
init(product: Product, localUser: User, actions: Actions) {
self.product = product
self.localUser = localUser
self.actions = actions
}
func reload() async throws {
product = try await actions.reload()
}
func toggleProductFavoriteStatus() async throws {
product = try await actions.toggleFavorite()
}
func addProductToList(withID listID: List.ID) async throws {
try await actions.addToList(listID)
}
}
extension ProductViewModel {
struct Actions {
var reload: Loading<Product>
var toggleFavorite: Loading<Product>
var addToList: AsyncAction<List.ID>
}
}
func makeProductViewModel(
for product: Product,
localUser: User,
networking: Networking,
listManager: ListManager
) -> ProductViewModel {
let loader = ProductLoader(networking: networking)
return ProductViewModel(
product: product,
localUser: localUser,
actions: ProductViewModel.Actions(
reload: {
try await loader.loadProduct(withID: product.id)
},
toggleFavorite: {
try await loader.toggleFavoriteStatusForProduct(
withID: product.id
)
},
addToList: { listID in
try await listManager.addProduct(
withID: product.id,
toListWithID: listID
)
}
)
)
}
Изменение, произведенное выше, по-прежнему позволяет нам мокать все три вышеупомянутых экшена, используя простые замыкания в наших тестах, а также упрощает управление этими экшенами, особенно если мы планируем продолжить добавлять новые в будущем. Конечно, приведенный выше шаблон, вероятно, не будет так хорошо масштабироваться для типов, которые имеют 10, 15, 20 экшенов, но для них, вероятно, также справедлив вопрос: а не слишком ли много обязанностей у таких типов?
Тем не менее, вышеприведенный шаблон есть за что покритиковать - дело в том, что в конечном итоге он переносит некоторые детали внутренней реализации нашей ProductViewModel
в места вызовов, которые создают ее инстансы. Например, наша функция makeProductViewModel
теперь должна точно знать, какую логику она должна поместить в каждое из Action-замыканий нашей модели представления.
Одним из способов решения этой проблемы было бы предоставление дефолтных реализаций этих замыканий с помощью базовых объектов, которые наш производственный код в идеале должен использовать, — что можно было бы сделать с помощью экстеншена, который можно поместить в тот же файл, что и сама наша ProductViewModel
:
extension ProductViewModel.Actions {
init(productID: Product.ID,
loader: ProductLoader,
listManager: ListManager) {
reload = {
try await loader.loadProduct(withID: productID)
}
toggleFavorite = {
try await loader.toggleFavoriteStatusForProduct(
withID: productID
)
}
addToList = {
try await listManager.addProduct(
withID: productID,
toListWithID: $0
)
}
}
}
С этим последним штрихом наша функция makeProductViewModel
теперь может просто внедрить зависимости нашей модели представления, более или менее точно так же, как это делалось при использовании нашего предыдущего сетапа на основе протокола:
func makeProductViewModel(
for product: Product,
localUser: User,
networking: Networking,
listManager: ListManager
) -> ProductViewModel {
ProductViewModel(
product: product,
localUser: localUser,
actions: ProductViewModel.Actions(
productID: product.id,
loader: ProductLoader(networking: networking),
listManager: listManager
)
)
}
При таком подходе мы, возможно, достигли довольно хорошего баланса между возможностью модульного тестирования нашей модели представления с помощью очень легкого набора абстракций, не передавая какие-либо детали реализации в любое место вызова, которое будет инициализировать эту модель представления в нашем производственном коде.
Заключение
Хотя идеальной сетапа для внедрения зависимостей, вероятно, не существует, экспериментируя с различными методами, мы часто можем прийти к архитектуре, обеспечивающей баланс между тем, как организована наша кодовая база, ее потребностью в тестируемости и личными предпочтениями разработчиков, работающих с ней.
Я надеюсь, что вы нашли эту статью полезной и занимательной, и хотя я не говорю, что кто-то должен заменить все свои протоколы вышеизложенным функциональным сетапом, я думаю, что на этот подход, по крайней мере, стоит обратить внимание — особенно сейчас, когда у нас в распоряжении есть вся мощь async/await
.
Чтобы узнать больше о техниках внедрения зависимостей в Swift, посетите эту страницу, и если у вас есть какие-либо вопросы, комментарии или отзывы, не стесняйтесь связываться со мной по электронной почте.
Перевод материала подготовлен для будущих студентов курса "iOS Developer. Professional". А всех желающих приглашаем на открытый урок на тему «Дополненная реальность(AR) в iOS приложениях», который пройдет сегодня в 20:00. На занятии напишем мини-приложение с помощью ARKit и RealityKit.