in Testing

Testing de ViewControllers en iOS

Escribimos mucho código de UI, tanto los ViewController como las UIView constituyen gran parte de nuestro código base. 

Además hay muchos casos extremos que tenemos que manejar al testear nuestras vistas.

Es poco habitual hacer pruebas sobre las Views, ya que generalmente se prueba la lógica de negocio, Casos de Uso, Repositorios, etc. relegando los test de UI.

Pero por qué?

Debo reconocer que es muy difícil cubrir todo el código de UI que tienes con tests unitarios.

Es mucho más  fácil probar lógica del negocio, pero probar vistas siempre me pareció algo no tan intuitivo. 

Hay varias formas de hacer tests sobre nuestras vistas:

  • Unit Test a cada elemento de la UI verificando su contenido 🤔
  • End to End Tests (XCUI) 🙈
  • SnapShot Tests 📸

Probar los componentes de la interfaz de usuario a menudo es complicado porque hay demasiadas partes móviles involucradas.

Para poder probar el controlador de vista, necesitamos que las cosas funcionen de forma aislada.

Un objetivo general del diseño es tener una clara separación de intereses.

Debemos recordar que el único trabajo de nuestras Views debe ser renderizar la UI y propagar las interacciones del usuario.

Un View controller que hace demasiadas cosas será un VC muy difícil de probar. Los patrones como MVVM, MVP ayudan en este caso.

Haciendo los View Controllers Testables

Generalmente si usamos MVVM o MVP o algún otro patrón de arquitectura al testear la salida de nuestros ViewModels indirectamente ya estamos probando la entrada de nuestras vistas. Y dado que las vista de mantienen como agentes pasivos, me parece un doble esfuerzo probar las vistas verificando el contenido de cada elemento.

La Inyección de dependencia es una técnica altamente difundida en el mundo iOS, esta nos permite aislar en este caso a nuestras Vistas para que durante testing podamos mockear los objetos.

Ejemplo usando MVVM:

Diseñar los ViewController para que dependan de un protocolo en vez de una instancia en concreta del viewModel:

class PopularsViewController: UIViewController, StoryboardInstantiable {
  
  // Desde el VC solo interactúo con el Api pública del Protocol
  var viewModel: PopularViewModelProtocol!
  
  static func create(with viewModel: PopularViewModelProtocol) -> PopularsViewController {
    let controller = PopularsViewController.instantiateViewController()
    controller.viewModel = viewModel
    return controller
  }
}

Definiendo un protocolo del viewModel:

protocol PopularViewModelProtocol {
  
  // MARK: - Input
  
  func viewDidLoad()
  func didLoadNextPage()
  func showIsPicked(with id: Int)
  func refreshView()
  
  // MARK: - Output
  
  var viewState: Observable<SimpleViewState<TVShowCellViewModel>> { get }
  func getCurrentViewState() -> SimpleViewState<TVShowCellViewModel>
}

Listo ahora podemos utilizar las interface del ViewModel Protocol y mockearlo para hacer el siguiente tipo de tests:

Snapshot Test 📸

Me concentraré en este tipo de test ya que me pareció genial este framework. Originalmente creado por Facebook, pero ahora es mantenido por los chicos de Uber.

Cómo funciona?

Prueba la interface de tu app tomando snapshot de la UI y comparándola con una imagen de referencia, tan simple como eso.

Es una herramienta muy útil en nuestro arsenal de testing para hacer que la UI luzca como pretendíamos.

Si bien tiene más características como probar UIViews independientes o probar Layers, pero por ahora dejémoslo en lo básico.

Mockeando el ViewModel

class PopularViewModelMock: PopularViewModelProtocol {
  
  func viewDidLoad() { }
  
  func didLoadNextPage() { }
  
  func showIsPicked(with id: Int) { }
  
  func refreshView() { }
  
  func getCurrentViewState() -> SimpleViewState<TVShowCellViewModel> {
    //...
    return .empty
  }
  
  var viewState: Observable<SimpleViewState<TVShowCellViewModel>>
  
  init(state: SimpleViewState<TVShowCellViewModel>) {
    viewState = Observable.just(state)
  }
}

Creando un SnapShot Test

class PopularViewTests: FBSnapshotTestCase {
  
  let firstShow = TVShow.stub(id: 1, name: "Dark 🐶", voteAverage: 8.0)
  let secondShow = TVShow.stub(id: 2, name: "Dragon Ball Z 🔥", voteAverage: 9.0)
  let thirdShow = TVShow.stub(id: 3, name: "Este es un TVShow con un nombre muy largo que fue creado con fines de Test🚨", voteAverage: 10.0)  
  lazy var firstPage = TVShowResult.stub(page: 1,
                                         results: [firstShow, secondShow],
                                         totalResults: 3,
                                         totalPages: 2)
  
  lazy var secondPage = TVShowResult.stub(page: 2,
                                          results: [thirdShow],
                                          totalResults: 3,
                                          totalPages: 2)
  
  let emptyPage = TVShowResult.stub(page: 1, results: [], totalResults: 0, totalPages: 1)
  
  override func setUp() {
    super.setUp()
    self.recordMode = true
  }

  func test_WhenViewPopulated_thenShowPopulatedScreen() {
    
    let totalCells = (self.firstPage.results + self.secondPage.results)
      .map { TVShowCellViewModel(show: $0) }
    
    // given
    let viewModel = PopularViewModelMock(state: .populated(totalCells) )
    let viewController = PopularsViewController.create(with: viewModel)
    
    FBSnapshotVerifyView(viewController.view)
  }
}

Resultado:

A simple vista puedo ver que que está renderizando una tabla con 3 elementos.

Que pasa si sucede un error, y si la vista está cargando una siguiente página? Y si el servicio no retorna ningún elemento?

  func test_WhenViewPaging_thenShowPagingScreen() {
    
    let firsPageCells = firstPage.results!.map { TVShowCellViewModel(show: $0) }
    
    // given
    let viewModel = PopularViewModelMock(state: .paging(firsPageCells, next: 2) )
    let viewController = PopularsViewController.create(with: viewModel)
    
    FBSnapshotVerifyView(viewController.view)
  }
 
  func test_WhenViewIsEmpty_thenShowEmptyScreen() {
    // given
    let viewModel = PopularViewModelMock(state: .empty)
    let viewController = PopularsViewController.create(with: viewModel)
    
    FBSnapshotVerifyView(viewController.view)
  }
  
  func test_WhenViewIsError_thenShowErrorScreen() {
    // given
    let viewModel = PopularViewModelMock(state: .error("Error to Fetch Shows") )
    let viewController = PopularsViewController.create(with: viewModel)
    
    FBSnapshotVerifyView(viewController.view)
  }

La primera vez que genero los snapshots debo ejecutar los test con la variable recordMode = true:

 override func setUp() {
    super.setUp()
    self.recordMode = true
  }

Para verificar las snapshots vuelvo a ejecutar con la variable recordMode en false o comentada:

override func setUp() {
    super.setUp()
    //self.recordMode = true
  }

Ahora todos los Test Pasan

Y si se modifica la UI?

Qué pasa por ejemplo que se modifica el renderizado de la tabla y se omite o modifica algún campo. Adrede modifico el nombre del TVShow y el color de un label en la celda.

En este caso nuestros test empiezan a fallar:

En el folder de referencia nos genera 3 archivos por cada test fallido:

Nos genera la Imagen Original que sirve como referencia, el nuevo snapshot creado y la diferencia entre estos.

Imagen original, Nueva imagen y Diferencias

Generalmente puedes manejar escribiendo assertions, pero son difíciles de visualizar, en cambio al usar este framework, puedes ver al instante exactamente qué está pasando solo mirando la captura.

Resumiendo

Espero que empiezan a usar Snapshot test, me parece una potente herramienta para empezar a probar nuestros ViewControllers y Views.

Nos permite reducir la posibilidad de introducir cambios inesperados dentro del código, lo cual es enorme para los negocios.

Los snapshot tests también son útiles durante la etapa de desarrollo. Es mucho más fácil preparar instantáneas para todos los dispositivos y casos que ejecutar cada configuración en un simulador.

Referencias:

https://github.com/uber/ios-snapshot-test-case

https://www.objc.io/issues/1-view-controllers/testing-view-controllers/

https://www.raywenderlich.com/5043-ios-snapshot-test-case-testing-the-ui

https://www.vadimbulavin.com/unit-testing-view-controller-uiviewcontroller-and-uiview-in-swift/

Write a Comment

Comment