¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
Conoce nuestra marca.¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
Conoce nuestra marca.dev
Francisco Javier Saorín 23/01/2023 Cargando comentarios…
Con ayuda de las últimas tecnologías proporcionadas por Apple en cuanto a programación reactiva y concurrencia es posible desarrollar arquitecturas potentes que proporcionan sencillez y rápida testabilidad a nuestras aplicaciones iOS.
Si te has interesado por este post es que seguramente ya conoces la arquitectura MVVM, pero si eres nuevo en el mundillo aquí va un repaso rápido sobre los componentes o capas que la conforman:
Ahora que ya conocemos los conceptos básicos de nuestra arquitectura, vamos a definir una funcionalidad con la que poder ejemplificarla: un listado de usuarios obtenidos de un servicio dónde al tocar alguno de ellos se llamará al número de teléfono asociado al usuario.
Para este caso partiremos de un pequeño modelo sencillo y de un interactor que realizará dos funciones básicas con nuestros usuarios: simular que obtenemos la lista de usuarios de una fuente asíncrona, como puede ser una llamada a un servicio, y abrir la app de teléfono con el número del usuario:
struct User: Identifiable, Equatable {
let id = UUID()
let name: String
let phone: String
}
struct UsersInteractor {
var fetchUsers: () async -> [User]
var callUser: (User) -> ()
static func interactor() -> UsersInteractor {
return UsersInteractor(
fetchUsers: {
try? await Task.sleep(until: .now + .seconds(2), clock: .continuous)
return [User(name: "Tony", phone: "623523"),
User(name: "Joan", phone: "602837"),
User(name: "Paul", phone: "611323"),
User(name: "Lucy", phone: "695461")]
},
callUser: { user in
guard let url = URL(string: "tel:\(user.phone)") else { return }
UIApplication.shared.open(url)
})
}
}
El modelo User conforma los protocolos Identifiable y Equatable, más adelante veremos por qué. El id es generado automáticamente y el nombre y teléfono serán los datos de nuestros usuarios.
En cuanto al interactor se trata de un struct que hace uso de la composición mediante implementaciones por defecto. Esto resulta muy útil a la hora de crear mocks en nuestros tests. En la app, mediante el método interactor, tendremos nuestra implementación real mientras que en los tests podremos crear nuestras nuevas implementaciones al vuelo como veremos más adelante.
Por último, destacar que la llamada fetchUsers hace uso de async await para simular una llamada asíncrona. En un caso real se debe hacer uso de URLSession o cualquiera de nuestras librerías de red preferidas.
Nota: esto es una alternativa a la creación de interfaces mediante protocolos, siéntete libre de usar aquello con lo que estés cómodo, siempre teniendo en cuenta que si decides no utilizar ninguna de las dos opciones y usas herencia te encontrarás problemas a la hora de mockear la funcionalidad.
Como vimos en nuestro artículo SwiftUI y el futuro de la programación en el ecosistema Apple, Apple nos ha proporcionado con SwiftUI una alternativa declarativa a la creación de interfaces de usuario en iOS, dejando atrás constraints, xibs y storyboards.
Al tratarse de un estilo declarativo, disponemos de unas herramientas (entre otras) para detectar cambios en el contexto donde reside la vista en SwiftUI y de esta forma cambiar su contenido según la información disponible en cada momento. Estas herramientas son @ObservedObject y ObservableObject. El primero se trata de un property wrapper que le indica a la vista qué debe “observar” para regenerarse, mientras que el segundo es el protocolo que debe conformar el objeto observado. Por tanto:
struct UsersView: View {
@ObservedObject var viewModel: UsersViewModel
var body: some View {
}
}
final class UsersViewModel: ObservableObject {
private let usersInteractor: UsersInteractor
init(usersInteractor: UsersInteractor) {
self.usersInteractor = usersInteractor
}
}
let usersInteractor = UsersInteractor.interactor()
let viewModel = UsersViewModel(usersInteractor: usersInteractor)
let view = UsersView(viewModel: viewModel)
Ahora bien, no basta con definir qué objeto es “observado”, también hay que indicar que propiedad del ViewModel es el que desencadena cambios en la vista y para ello debemos entender el concepto de Estado y Evento.
Podemos definir como Estados aquellas posibles situaciones en las que se puede encontrar una vista, por ejemplo: “Cargando” y “Cargada con cierta información”. Esto es lo que realmente desencadena cambios en la vista, lo que hace que sea regenerada completamente.
Por otro lado, podemos definir Eventos como aquellas respuestas que son generadas por cierta acción en la vista, por ejemplo, el simple hecho de aparecer en pantalla o de pulsar un botón.
Esta conexión entre ViewModel y View es muy sencilla de implementar, gracias al framework Combine de Apple.
Para suscribirse a los posibles estados de la vista usaremos @Published, mientras que para mandar eventos expondremos un único método público del ViewModel:
final class UsersViewModel: ObservableObject {
enum Event {
case viewAppeared
case userTapped(User)
}
enum State: Equatable {
case loading
case loaded(LoadedState)
}
struct LoadedState: Equatable {
let users: [User]
}
@Published private(set) var state: State
private let usersInteractor: UsersInteractor
init(state: State = .loading, usersInteractor: UsersInteractor) {
self.state = state
self.usersInteractor = usersInteractor
}
public func send(_ event: Event) {
switch event {
case .viewAppeared:
loadUsers()
case .userTapped(let user):
userTapped(user)
}
}
}
private extension UsersViewModel {
func loadUsers() {
Task { @MainActor in
let users = await usersInteractor.fetchUsers()
state = .loaded(LoadedState(users: users))
}
}
func userTapped(_ user: User) {
usersInteractor.callUser(user)
}
}
Veamos en detalle qué cambios hemos hecho:
En unas pocas líneas ya tenemos lista toda la funcionalidad de nuestro módulo, solo queda construir la vista:
UsersView: View {
@ObservedObject var viewModel: UsersViewModel
var body: some View {
NavigationView {
if case let .loaded(loadedState) = viewModel.state {
loadedView(state: loadedState)
} else {
loadingView()
}
}.onAppear {
viewModel.send(.viewAppeared)struct
}
}
}
private extension UsersView {
func loadingView() -> some View {
ProgressView()
}
func loadedView(state: UsersViewModel.LoadedState) -> some View {
List {
ForEach(state.users) { user in
Text(user.name).onTapGesture {
viewModel.send(.userTapped(user))
}
}
}
}
}
Es una buena práctica definir diferentes vistas para cada uno de los estados, pero esto dependerá de la complejidad de la vista y de tus necesidades. En este caso, mientras el estado sea .loading se mostrará un indicador de carga y cuando el estado sea .loaded se mostrará el listado de usuarios, donde al pulsar uno de ellos se enviará el evento correspondiente al ViewModel. Al hacer uso de ForEach para recorrer los usuarios debemos hacer que User conforme el protocolo Identifiable.
Una vez lista nuestra aplicación podemos implementar los tests unitarios del ViewModel y gracias a que el único método que se expone es send(_ event: Event) el diseño de los tests resulta muy sencillo.
final class UsersTests: XCTestCase {
private var sut: UsersViewModel?
override func tearDown() {
sut = nil
}
}
Lo primero que podemos testear es que el estado inicial de la vista es el correcto:
func testInitialStateIsLoading() throws {
// Given
let usersInteractor = UsersInteractor.interactor()
sut = UsersViewModel(usersInteractor: usersInteractor)
// Then
XCTAssertTrue(sut!.state == .loading)
}
A continuación, testeamos el primer evento que puede recibir el viewModel, viewAppeared:
func testViewAppearedEvent() throws {
// Given
let testUser = User(name: "Test", phone: "666666")
let usersInteractor = UsersInteractor(fetchUsers: {
return [testUser]
}, callUser: { _ in
// Not needed here
})
sut = UsersViewModel(usersInteractor: usersInteractor)
// When
sut!.send(.viewAppeared)
// Then
let expectation = expectation(description: "Users loaded")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(sut!.state, .loaded(UsersViewModel.LoadedState(users: [testUser])))
}
En este test definimos nuestra propia implementación del interactor, ya que en este tipo de tests no debemos realizar llamadas a servicios en los propios tests, sino que debemos mockear la respuesta. El test será válido cuando, tras llamar al evento viewAppeared, cambie el estado de la vista a loaded con nuestro usuario de prueba. Como se trata en este caso de una llamada asíncrona mediante async await debemos hacer uso de las expectations para asegurarnos de que el mock funciona correctamente.
Solo queda implementar un test para el otro evento que es capaz de recibir el ViewModel:
func testUserTappedEvent() throws {
// Given
let expectation = expectation(description: "User tapped")
let testUser = User(name: "Test", phone: "666666")
let usersInteractor = UsersInteractor(fetchUsers: {
return [testUser]
}, callUser: { user in
XCTAssertEqual(user, testUser)
expectation.fulfill()
})
sut = UsersViewModel(usersInteractor: usersInteractor)
// When
sut!.send(.userTapped(testUser))
// Then
wait(for: [expectation], timeout: 1.0)
}
Aquí nuevamente desarrollamos una nueva implementación del interactor acorde al test y comprobamos que al enviar el evento userTapped se llama al método callUser del interactor y que el usuario es el correcto.
Teniendo claro el concepto de esta arquitectura y entendiendo que:
Podemos desarrollar todo tipo de módulos MVVM, haciendo uso de una arquitectura con un desarrollo sencillo y, sobre todo, testable.
El hecho de exponer un único método del ViewModel nos ayuda a pensar sobre qué estados puede tener la vista y qué eventos van a realizarse en ella. Parándonos un momento a listar los posibles estados y eventos que participarán en nuestro módulo ya tendremos gran parte del trabajo hecho incluso antes de ponernos a escribir código.
Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.
Cuéntanos qué te parece.