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.

Conceptos básicos de la arquitectura MVVM

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:

Caso de uso: agenda de contactos

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.

Observación de objetos en SwiftUI

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.

Estados y eventos

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.

Representación de los eventos.

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:

  1. Definimos dos enums, uno para los eventos que vamos a recibir de la vista (uno para cuando la vista ha aparecido y otro para cuando se ha tocado un usuario) y otro para estados (cargando y cargada con una lista de usuarios). Necesitamos que el estado sea Equatable para que sea comparable y poder saber en qué estado nos encontramos. Esto implica que User también sea Equatable, de ahí que en su definición pongamos que conforma este protocolo.
  2. Definimos un objeto State, cuyo set será privado, ya que solo debe ser el propio ViewModel quién modifique su valor. Le añadimos el property wrapper @Published. Con esto indicamos a la vista de que esta es la propiedad del ViewModel a la que debe suscribirse. En el init establecemos .loading como su estado inicial.
  3. Finalmente, exponemos un método send(_ event: Event) que será el único punto de comunicación de la vista con el ViewModel, lo que hará más sencillo comprender qué acciones se realizan en la vista y a la vez será el único método que tendremos que probar en nuestros tests. En este caso, al recibirse el evento de que la vista ha aparecido en pantalla (.viewAppeared) se obtienen los usuarios y se actualiza el estado con los resultados, lo que desencadenará de forma automática un cambio en la vista. Por último, al recibir el evento .userTapped, se hace uso del interactor para llamar al número asociado al usuario tocado.

La vista

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.

Vistas para cada uno de los estados.

Unit testing

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.

Conclusiones

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.

Cuéntanos qué te parece.

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.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.