Cómo escribir pruebas automatizadas para iOS

Publicado: 2022-03-11

Como buen desarrollador, hace todo lo posible para probar todas las funciones y todas las rutas de código y resultados posibles en el software que escribe. Pero es extremadamente raro e inusual poder probar manualmente todos los resultados posibles y todos los caminos posibles que un usuario podría tomar.

A medida que la aplicación se vuelve más grande y más compleja, la probabilidad de que se pierda algo a través de las pruebas manuales aumenta significativamente.

Las pruebas automatizadas, tanto de la interfaz de usuario como de las API del servicio de back-end, le darán más confianza de que todo funciona según lo previsto y reducirán el estrés al desarrollar, refactorizar, agregar nuevas funciones o cambiar las existentes.

Con las pruebas automatizadas, puede:

  • Reducir errores: no existe un método que elimine por completo cualquier posibilidad de errores en su código, pero las pruebas automatizadas pueden reducir en gran medida la cantidad de errores.
  • Realice cambios con confianza: evite errores al agregar nuevas funciones, lo que significa que puede realizar cambios de forma rápida y sencilla.
  • Documentar nuestro código: al revisar las pruebas, podemos ver claramente lo que se espera de ciertas funciones, cuáles son las condiciones y cuáles son los casos extremos.
  • Refactorice sin problemas: como desarrollador, a veces puede tener miedo de refactorizar, especialmente si necesita refactorizar una gran parte del código. Las pruebas unitarias están aquí para garantizar que el código refactorizado aún funcione según lo previsto.

Este artículo le enseña cómo estructurar y ejecutar pruebas automatizadas en la plataforma iOS.

Pruebas unitarias frente a pruebas de interfaz de usuario

Es importante diferenciar entre pruebas unitarias y de interfaz de usuario.

Una prueba unitaria prueba una función específica bajo un contexto específico . Las pruebas unitarias verifican que la parte probada del código (generalmente una sola función) hace lo que se supone que debe hacer. Hay muchos libros y artículos sobre pruebas unitarias, por lo que no lo cubriremos en esta publicación.

Las pruebas de IU son para probar la interfaz de usuario. Por ejemplo, le permite probar si una vista se actualiza según lo previsto o si se activa una acción específica como debería ser cuando el usuario interactúa con un determinado elemento de la interfaz de usuario.

Cada prueba de IU prueba una interacción específica del usuario con la IU de la aplicación. Las pruebas automatizadas pueden, y deben, realizarse tanto en la prueba unitaria como en los niveles de prueba de la interfaz de usuario.

Configuración de pruebas automatizadas

Dado que XCode admite pruebas de unidad e interfaz de usuario listas para usar, es fácil y sencillo agregarlas a su proyecto. Al crear un nuevo proyecto, simplemente marque "Incluir pruebas unitarias" e "Incluir pruebas de interfaz de usuario".

Cuando se crea el proyecto, se agregarán dos nuevos objetivos a su proyecto cuando se hayan marcado estas dos opciones. Los nuevos nombres de destino tienen "Pruebas" o "UITests" adjuntos al final del nombre.

Eso es todo. Está listo para escribir pruebas automatizadas para su proyecto.

Imagen: Configuración de pruebas automatizadas en XCode.

Si ya tiene un proyecto existente y desea agregar la interfaz de usuario y la compatibilidad con pruebas unitarias, tendrá que trabajar un poco más, pero también es muy directo y simple.

Vaya a Archivo → Nuevo → Destino y seleccione Paquete de prueba de unidad de iOS para pruebas de unidad o Paquete de prueba de interfaz de usuario de iOS para pruebas de interfaz de usuario.

Imagen: Selección del paquete de prueba de unidades de iOS.

Presiona Siguiente .

En la pantalla de opciones de objetivo, puede dejar todo como está (si tiene varios objetivos y desea probar solo objetivos específicos, seleccione el objetivo en el menú desplegable Objetivo para probar).

Pulse Finalizar . Repita este paso para las pruebas de IU y tendrá todo listo para comenzar a escribir pruebas automatizadas en su proyecto existente.

Escribir pruebas unitarias

Antes de que podamos comenzar a escribir pruebas unitarias, debemos comprender su anatomía. Cuando incluya pruebas unitarias en su proyecto, se creará una clase de prueba de ejemplo. En nuestro caso, se verá así:

 import XCTest class TestingIOSTests: XCTestCase { override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } func testExample() { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } func testPerformanceExample() { // This is an example of a performance test case. self.measure { // Put the code you want to measure the time of here. } } }

Los métodos más importantes para comprender son setUp y tearDown . El método setUp se llama antes de cada método de prueba, mientras que el método tearDown se llama después de cada método de prueba. Si ejecutamos pruebas definidas en esta clase de prueba de ejemplo, los métodos se ejecutarían así:

configuración → ejemplo de prueba → desmontaje configuración → ejemplo de rendimiento de prueba → desmontaje

Sugerencia: Las pruebas se ejecutan presionando cmd + U, seleccionando Producto → Prueba, o haciendo clic y manteniendo presionado el botón Ejecutar hasta que aparezca el menú de opciones, luego seleccione Prueba en el menú.

Si desea ejecutar solo un método de prueba específico, presione el botón a la izquierda del nombre del método (que se muestra en la imagen a continuación).

Imagen: Selección de un método de prueba específico.

Ahora, cuando tenga todo listo para escribir pruebas, puede agregar una clase de ejemplo y algunos métodos para probar.

Agregue una clase que será responsable del registro de usuarios. Un usuario ingresa una dirección de correo electrónico, una contraseña y una confirmación de contraseña. Nuestra clase de ejemplo validará la entrada, verificará la disponibilidad de la dirección de correo electrónico e intentará el registro del usuario.

Nota: este ejemplo utiliza el patrón arquitectónico MVVM (o Model-View-ViewModel).

Se usa MVVM porque hace que la arquitectura de una aplicación sea más limpia y fácil de probar.

Con MVVM, es más fácil separar la lógica comercial de la lógica de presentación, evitando así el problema del controlador de vista masivo.

Los detalles sobre la arquitectura MVVM están fuera del alcance de este artículo, pero puede obtener más información al respecto en este artículo.

Vamos a crear una clase de modelo de vista responsable del registro de usuarios. .

 class RegisterationViewModel { var emailAddress: String? { didSet { enableRegistrationAttempt() } } var password: String? { didSet { enableRegistrationAttempt() } } var passwordConfirmation: String? { didSet { enableRegistrationAttempt() } } var registrationEnabled = Dynamic(false) var errorMessage = Dynamic("") var loginSuccessful = Dynamic(false) var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } }

Primero, hemos agregado algunas propiedades, propiedades dinámicas y un método init.

No se preocupe por el tipo Dynamic . Es parte de la arquitectura MVVM.

Cuando un valor de Dynamic<Bool> se establece en verdadero, un controlador de vista que está vinculado (conectado) a RegistrationViewModel habilitará el botón de registro. Cuando loginSuccessful se establece en verdadero, la vista conectada se actualizará sola.

Ahora agreguemos algunos métodos para verificar la validez de la contraseña y el formato del correo electrónico.

 func enableRegistrationAttempt() { registrationEnabled.value = emailValid() && passwordValid() } func emailValid() -> Bool { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" let emailTest = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailTest.evaluate(with: emailAddress) } func passwordValid() -> Bool { guard let password = password, let passwordConfirmation = passwordConfirmation else { return false } let isValid = (password == passwordConfirmation) && password.characters.count >= 6 return isValid }

Cada vez que el usuario escribe algo en el campo de correo electrónico o contraseña, el método enableRegistrationAttempt verificará si el correo electrónico y la contraseña están en el formato correcto y habilitará o deshabilitará el botón de registro a través de la propiedad dinámica registrationEnabled .

Para simplificar el ejemplo, agregue dos métodos simples: uno para verificar la disponibilidad de un correo electrónico y otro para intentar el registro con el nombre de usuario y la contraseña proporcionados.

 func checkEmailAvailability(email: String, withCallback callback: @escaping (Bool?)->(Void)) { networkService.checkEmailAvailability(email: email) { (available, error) in if let _ = error { self.errorMessage.value = "Our custom error message" } else if !available { self.errorMessage.value = "Sorry, provided email address is already taken" self.registrationEnabled.value = false callback(available) } } } func attemptUserRegistration() { guard registrationEnabled.value == true else { return } // To keep the example as simple as possible, password won't be hashed guard let emailAddress = emailAddress, let passwordHash = password else { return } networkService.attemptRegistration(forUserEmail: emailAddress, withPasswordHash: passwordHash) { (success, error) in // Handle the response if let _ = error { self.errorMessage.value = "Our custom error message" } else { self.loginSuccessful.value = true } } }

Estos dos métodos utilizan NetworkService para verificar si hay un correo electrónico disponible e intentar el registro.

Para simplificar este ejemplo, la implementación de NetworkService no usa ninguna API de back-end, sino que es solo un código auxiliar que falsifica los resultados. NetworkService se implementa como un protocolo y su clase de implementación.

 typealias RegistrationAttemptCallback = (_ success: Bool, _ error: NSError?) -> Void typealias EmailAvailabilityCallback = (_ available: Bool, _ error: NSError?) -> Void protocol NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) }

NetworkService es un protocolo muy simple que contiene solo dos métodos: intento de registro y métodos de verificación de disponibilidad de correo electrónico. La implementación del protocolo es la clase NetworkServiceImpl.

 class NetworkServiceImpl: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } }

Ambos métodos simplemente esperan un tiempo (simulando el retraso de tiempo de una solicitud de red) y luego llaman a los métodos de devolución de llamada apropiados.

Sugerencia: es una buena práctica usar protocolos (también conocidos como interfaces en otros lenguajes de programación). Puede leer más al respecto si busca 'principio de programación a interfaces'. También verá cómo funciona bien con las pruebas unitarias.

Ahora, cuando se establece un ejemplo, podemos escribir pruebas unitarias para cubrir los métodos de esta clase.

  1. Cree una nueva clase de prueba para nuestro modelo de vista. Haga clic con el botón derecho en la carpeta TestingIOSTests en el panel Project Navigator, seleccione New File → Unit Test Case Class y asígnele el nombre RegistrationViewModelTests .

  2. Elimine los métodos testExample y testPerformanceExample , ya que queremos crear nuestros propios métodos de prueba.

  3. Dado que Swift usa módulos y nuestras pruebas están en un módulo diferente al código de nuestra aplicación, tenemos que importar el módulo de nuestra aplicación como @testable . Debajo de la declaración de importación y la definición de clase, agregue @testable import TestingIOS (o el nombre del módulo de su aplicación). Sin esto, no podríamos hacer referencia a ninguna de las clases o métodos de nuestra aplicación.

  4. Agregue la variable registrationViewModel .

Así es como se ve ahora nuestra clase de prueba vacía:

 import XCTest @testable import TestingIOS class RegistrationViewModelTests: XCTestCase { var registrationViewModel: RegisterationViewModel? override func setUp() { super.setUp() } override func tearDown() { super.tearDown() } }

Intentemos escribir una prueba para el método emailValid . Crearemos un nuevo método de prueba llamado testEmailValid . Es importante agregar la palabra clave de test al comienzo del nombre. De lo contrario, el método no se reconocerá como método de prueba.

Nuestro método de prueba se ve así:

 func testEmailValid() { let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl()) registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "email@test" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = nil XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "[email protected]" XCTAssert(registrationVM.emailValid(), "\(registrationVM.emailAddress) should be correct") }

Nuestro método de prueba utiliza un método de aserción, XCTAssert , que en nuestro caso verifica si una condición es verdadera o falsa.

Si la condición es falsa, la afirmación fallará (junto con la prueba) y nuestro mensaje se escribirá.

Hay muchos métodos de afirmación que puede usar en sus pruebas. Describir y mostrar cada método de afirmación puede crear fácilmente su propio artículo, por lo que no entraré en detalles aquí.

Algunos ejemplos de métodos de afirmación disponibles son: XCTAssertEqualObjects , XCTAssertGreaterThan , XCTAssertNil , XCTAssertTrue o XCTAssertThrows .

Puede leer más sobre los métodos de aserción disponibles aquí.

Si ejecuta la prueba ahora, el método de prueba pasará. Ha creado con éxito su primer método de prueba, pero aún no está listo para el horario de máxima audiencia. Este método de prueba todavía tiene tres problemas (uno grande y dos más pequeños), como se detalla a continuación.

Problema 1: está utilizando la implementación real del protocolo NetworkService

Uno de los principios básicos de las pruebas unitarias es que cada prueba debe ser independiente de cualquier factor externo o dependencia. Las pruebas unitarias deben ser atómicas.

Si está probando un método, que en algún momento llama a un método API desde el servidor, su prueba depende de su código de red y de la disponibilidad del servidor. Si el servidor no está funcionando en el momento de la prueba, su prueba fallará, acusando así erróneamente a su método probado de no funcionar.

En este caso, está probando un método de RegistrationViewModel .

RegistrationViewModel depende de la clase NetworkServiceImpl , aunque sabe que su método probado, emailValid , no depende directamente de NetworkServiceImpl .

Al escribir pruebas unitarias, se deben eliminar todas las dependencias externas. Pero, ¿cómo debería eliminar la dependencia de NetworkService sin cambiar la implementación de la clase RegistrationViewModel ?

Hay una solución fácil a este problema, y ​​se llama Simulación de objetos . Si observa detenidamente el RegistrationViewModel , verá que en realidad depende del protocolo NetworkService .

 class RegisterationViewModel { … // It depends on NetworkService. RegistrationViewModel doesn't even care if NetworkServiceImple exists var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } ...

Cuando se inicializa el modelo de vista de RegistrationViewModel , se proporciona (o inyecta) una implementación del protocolo NetworkService al objeto de modelo de vista de RegistrationViewModel .

Este principio se llama inyección de dependencia a través del constructor ( hay más tipos de inyecciones de dependencia ).

Hay muchos artículos interesantes sobre inyección de dependencia en línea, como este artículo en objc.io.

También hay un artículo breve pero interesante que explica la inyección de dependencia de una manera simple y directa aquí.

Además, un gran artículo sobre el principio de responsabilidad única y DI está disponible en el blog de Toptal.

Cuando se crea una instancia de RegistrationViewModel , está inyectando una implementación de protocolo NetworkService en su constructor (de ahí el nombre del principio de inyección de dependencia):

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

Dado que nuestra clase de modelo de vista solo depende del protocolo, no hay nada que nos impida crear nuestra clase de implementación NetworkService personalizada (o simulada) e inyectar la clase simulada en nuestro objeto de modelo de vista.

Vamos a crear nuestra implementación del protocolo NetworkService .

Agregue un nuevo archivo Swift a nuestro objetivo de prueba haciendo clic con el botón derecho en la carpeta TestingIOSTests en Project Navigator, seleccione "Nuevo archivo", seleccione "Archivo Swift" y asígnele el nombre NetworkServiceMock .

Así es como debería verse nuestra clase simulada:

 import Foundation @testable import TestingIOS class NetworkServiceMock: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(false, nil) }) } }

En este punto, no es muy diferente de nuestra implementación real ( NetworkServiceImpl ), pero en una situación real, NetworkServiceImpl real tendría un código de red, manejo de respuestas y una funcionalidad similar.

Nuestra clase simulada no hace nada, que es el objetivo de una clase simulada. Si no hace nada entonces no interferirá con nuestras pruebas.

Para solucionar el primer problema de nuestra prueba, actualicemos nuestro método de prueba reemplazando:

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

con:

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())

Problema 2: está creando una instancia de registrationVM en el cuerpo del método de prueba

Existen los setUp de instalación y tearDown por una razón.

Esos métodos se utilizan para iniciar o configurar todos los objetos requeridos en una prueba. Debe usar esos métodos para evitar la duplicación de código escribiendo los mismos métodos de inicio o configuración en cada método de prueba. No usar los métodos de configuración y desmontaje no siempre es un gran problema, especialmente si tiene una configuración realmente específica para un método de prueba específico.

Dado que nuestra inicialización de la clase RegistrationViewModel es bastante simple, refactorizará su clase de prueba para usar los métodos de configuración y eliminación.

RegistrationViewModelTests debería verse así:

 class RegistrationViewModelTests: XCTestCase { var registrationVM: RegisterationViewModel! override func setUp() { super.setUp() registrationVM = RegisterationViewModel(networkService: NetworkServiceMock()) } override func tearDown() { registrationVM = nil super.tearDown() } func testEmailValid() { registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") ... } }

Problema 3: tiene varias afirmaciones en un método de prueba

Aunque esto no es un gran problema, hay algunos defensores de tener una aserción por método.

El principal razonamiento de este principio es la detección de errores.

Si un método de prueba tiene varias aserciones y la primera falla, todo el método de prueba se marcará como fallido. Otras afirmaciones ni siquiera serán probadas.

De esta manera, descubriría solo un error a la vez. No sabría si otras afirmaciones fallarían o tendrían éxito.

No siempre es malo tener múltiples aserciones en un método porque solo puede corregir un error a la vez, por lo que detectar un error a la vez puede no ser un problema tan grande.

En nuestro caso, se prueba la validez de un formato de correo electrónico. Dado que esta es solo una función, podría ser más lógico agrupar todas las afirmaciones en un método para que la prueba sea más fácil de leer y comprender.

Como este problema no es realmente un gran problema y algunos incluso podrían argumentar que no es un problema en absoluto, mantendrá su método de prueba tal como está.

Cuando escribe sus propias pruebas unitarias, depende de usted decidir qué ruta desea tomar para cada método de prueba. Lo más probable es que encuentre que hay lugares en los que la filosofía de una afirmación por prueba tiene sentido y otros en los que no.

Métodos de prueba con llamadas asíncronas

No importa cuán simple sea la aplicación, existe una alta probabilidad de que haya un método que deba ejecutarse en otro subproceso de forma asincrónica, especialmente porque generalmente le gusta que la interfaz de usuario se ejecute en su propio subproceso.

El problema principal con las pruebas unitarias y las llamadas asíncronas es que una llamada asíncrona tarda en finalizar, pero la prueba unitaria no esperará hasta que finalice. Debido a que la prueba unitaria finaliza antes de que se ejecute el código dentro de un bloque asíncrono, nuestra prueba siempre terminará con el mismo resultado (sin importar lo que escriba en su bloque asíncrono).

Para demostrar esto, creemos una prueba para el método checkEmailAvailability .

 func testCheckEmailAvailability() { registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled") } }

Aquí desea probar si una variable registrationEnabled se establecerá en falso después de que nuestro método le indique que el correo electrónico no está disponible (ya tomado por otro usuario).

Si ejecuta esta prueba, pasará. Pero prueba una cosa más. Cambie su afirmación a:

 XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")

Si vuelve a ejecutar la prueba, vuelve a pasar.

Esto se debe a que nuestra afirmación ni siquiera fue afirmada. La prueba unitaria finalizó antes de que se ejecutara el bloque de devolución de llamada (recuerde, en nuestra implementación de servicio de red simulada, está configurado para esperar un segundo antes de regresar).

Afortunadamente, con Xcode 6, Apple ha agregado expectativas de prueba al marco XCTest como la clase XCTestExpectation . La clase XCTestExpectation funciona así:

  1. Al comienzo de la prueba, establece su expectativa de prueba, con un texto simple que describe lo que esperaba de la prueba.
  2. En un bloque asíncrono después de ejecutar su código de prueba, cumple con la expectativa.
  3. Al final de la prueba, debe configurar el bloque waitForExpectationWithTimer . Se ejecutará cuando se cumpla la expectativa o si se agota el tiempo, lo que ocurra primero.
  4. Ahora, la prueba unitaria no finalizará hasta que se cumpla la expectativa o hasta que se agote el temporizador de expectativa.

Reescribamos nuestra prueba para usar la clase XCTestExpectation .

 func testCheckEmailAvailability() { // 1. Setting the expectation let exp = expectation(description: "Check email availability") registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled") // 2. Fulfilling the expectation exp.fulfill() } // 3. Waiting for expectation to fulfill waitForExpectations(timeout: 3.0) { error in if let _ = error { XCTAssert(false, "Timeout while checking email availability") } } }

Si ejecuta la prueba ahora, fallará, como debería. Arreglemos la prueba para que pase. Cambie la aserción a:

 XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled")

Vuelva a ejecutar la prueba para ver si pasa. Puede intentar cambiar el tiempo de retraso en la implementación simulada del servicio de red para ver qué sucede si se agota el temporizador de expectativa.

Métodos de prueba con llamadas asincrónicas sin devolución de llamada

Nuestro método de proyecto de ejemplo attemptUserRegistration usa el método NetworkService.attemptRegistration que incluye código que se ejecuta de forma asíncrona. El método intenta registrar un usuario con el servicio de backend.

En nuestra aplicación de demostración, el método solo esperará un segundo para simular una llamada de red y simular un registro exitoso. Si el registro fue exitoso, el valor loginSuccessful se establecerá en verdadero. Hagamos una prueba unitaria para verificar este comportamiento.

 func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.attemptUserRegistration() XCTAssert(registrationVM.loginSuccessful.value, "Login must be successful") }

Si se ejecuta, esta prueba fallará porque el valor loginSuccessful no se establecerá en verdadero hasta que finalice el método asincrónico networkService.attemptRegistration .

Dado que ha creado un NetworkServiceImpl simulado donde el método attemptRegistration esperará un segundo antes de devolver un registro exitoso, puede usar Grand Central Dispatch (GCD) y utilizar el método asyncAfter para verificar su afirmación después de un segundo. Después de agregar el asyncAfter de GCD, nuestro código de prueba se verá así:

 func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") } }

Si ha prestado atención, sabrá que esto aún no funcionará porque el método de prueba se ejecutará antes de que se ejecute el bloque asyncAfter y, como resultado, el método siempre pasará con éxito. Afortunadamente, existe la clase XCTestException .

Reescribamos nuestro método para usar la clase XCTestException :

 func testAttemptRegistration() { let exp = expectation(description: "Check registration attempt") registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") exp.fulfill() } waitForExpectations(timeout: 4.0) { error in if let _ = error { XCTAssert(false, "Timeout while attempting a registration") } } }

Con las pruebas unitarias que cubren nuestro RegistrationViewModel , ahora puede estar más seguro de que agregar una funcionalidad nueva o actualizar la existente no romperá nada.

Nota importante: Las pruebas unitarias perderán su valor si no se actualizan cuando cambia la funcionalidad de los métodos que cubren. Escribir pruebas unitarias es un proceso que tiene que mantenerse al día con el resto de la aplicación.

Consejo: no posponga las pruebas de escritura hasta el final. Escribir pruebas durante el desarrollo. De esta manera, tendrá una mejor comprensión de lo que debe probarse y cuáles son los casos límite.

Escribir pruebas de interfaz de usuario

Después de que todas las pruebas unitarias se hayan desarrollado por completo y se hayan ejecutado con éxito, puede estar seguro de que cada unidad de código funciona correctamente, pero ¿significa eso que su aplicación en su conjunto funciona según lo previsto?

Ahí es donde entran las pruebas de integración, de las cuales las pruebas de UI son un componente esencial.

Antes de comenzar con las pruebas de la interfaz de usuario, es necesario que haya algunos elementos e interacciones de la interfaz de usuario (o historias de usuario) para probar. Vamos a crear una vista simple y su controlador de vista.

  1. Abra Main.storyboard y cree un controlador de vista simple que se verá como el de la imagen a continuación.

Imagen: Creando una vista simple y su controlador de vista.

Establezca la etiqueta del campo de texto del correo electrónico en 100, la etiqueta del campo de texto de la contraseña en 101 y la etiqueta de confirmación de la contraseña en 102.

  1. Agregue un nuevo archivo de controlador de vista RegistrationViewController.swift y conecte todos los puntos de venta con el guión gráfico.
 import UIKit class RegistrationViewController: UIViewController, UITextFieldDelegate { @IBOutlet weak var emailTextField: UITextField! @IBOutlet weak var passwordTextField: UITextField! @IBOutlet weak var passwordConfirmationTextField: UITextField! @IBOutlet weak var registerButton: UIButton! private struct TextFieldTags { static let emailTextField = 100 static let passwordTextField = 101 static let confirmPasswordTextField = 102 } var viewModel: RegisterationViewModel? override func viewDidLoad() { super.viewDidLoad() emailTextField.delegate = self passwordTextField.delegate = self passwordConfirmationTextField.delegate = self bindViewModel() } }

Aquí está agregando IBOutlets y una estructura TextFieldTags a la clase.

Esto le permitirá identificar qué campo de texto se está editando. Para hacer uso de las propiedades dinámicas en el modelo de vista, debe 'vincular' las propiedades dinámicas en el controlador de vista. Puedes hacerlo en el método bindViewModel :

 fileprivate func bindViewModel() { if let viewModel = viewModel { viewModel.registrationEnabled.bindAndFire { self.registerButton.isEnabled = $0 } } }

Ahora agreguemos un método de delegado de campo de texto para realizar un seguimiento de cuándo se actualiza cualquiera de los campos de texto:

 func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { guard let viewModel = viewModel else { return true } let newString = (textField.text! as NSString).replacingCharacters(in: range, with: string) switch textField.tag { case TextFieldTags.emailTextField: viewModel.emailAddress = newString case TextFieldTags.passwordTextField: viewModel.password = newString case TextFieldTags.confirmPasswordTextField: viewModel.passwordConfirmation = newString default: break } return true }
  1. Actualice AppDelegate para vincular el controlador de vista al modelo de vista apropiado (tenga en cuenta que este paso es un requisito de la arquitectura MVVM). El código actualizado de AppDelegate debería verse así:
 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { initializeStartingView() return true } fileprivate func initializeStartingView() { if let rootViewController = window?.rootViewController as? RegistrationViewController { let networkService = NetworkServiceImpl() let viewModel = RegisterationViewModel(networkService: networkService) rootViewController.viewModel = viewModel } }

El archivo del guión gráfico y el RegistrationViewController son realmente simples, pero son adecuados para demostrar cómo funcionan las pruebas de IU automatizadas.

Si todo está configurado correctamente, el botón de registro debe desactivarse cuando se inicia la aplicación. Cuando, y solo cuando, todos los campos estén llenos y sean válidos, el botón de registro debe estar habilitado.

Una vez que esté configurado, puede crear su primera prueba de IU.

Nuestra prueba de IU debe verificar si el botón Registrar se habilitará si y solo si se han ingresado una dirección de correo electrónico válida, una contraseña válida y una confirmación de contraseña válida. Así es como se configura esto:

  1. Abra el archivo TestingIOSUITests.swift .
  2. Elimine el método testExample() y agregue un método testRegistrationButtonEnabled() .
  3. Coloque el cursor en el método testRegistrationButtonEnabled como si fuera a escribir algo allí.
  4. Presione el botón Grabar prueba de IU (círculo rojo en la parte inferior de la pantalla).

Imagen: Captura de pantalla que muestra el botón Grabar prueba de IU.

  1. Cuando se presiona el botón Grabar, se iniciará la aplicación.
  2. Después de iniciar la aplicación, toque el campo de texto del correo electrónico y escriba '[email protected]'. Notará que el código aparece automáticamente dentro del cuerpo del método de prueba.

Puede registrar todas las instrucciones de la interfaz de usuario con esta función, pero es posible que le resulte mucho más rápido escribir instrucciones sencillas de forma manual.

Este es un ejemplo de una instrucción de grabadora para tocar un campo de texto de contraseña e ingresar una dirección de correo electrónico '[email protected]'

 let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]")
  1. Después de que se hayan grabado las interacciones de la interfaz de usuario que desea probar, presione el botón detener nuevamente (la etiqueta del botón de grabación cambió a detenerse cuando comenzó a grabar) para detener la grabación.
  2. Una vez que tenga su registrador de interacciones de la interfaz de usuario, ahora puede agregar varios XCTAsserts para probar varios estados de la aplicación o elementos de la interfaz de usuario.

Imagen: Animación que muestra una instrucción de la grabadora para tocar un campo de contraseña.

Las instrucciones grabadas no siempre se explican por sí mismas e incluso pueden hacer que todo el método de prueba sea un poco difícil de leer y comprender. Afortunadamente, puede ingresar manualmente las instrucciones de la interfaz de usuario.

Let's create the following UI instructions manually:

  1. User taps on the password text field.
  2. User enters a 'password'.

To reference a UI element, you can use a placeholder identifier. A placeholder identifier can be set in the storyboard in the Identity Inspector pane under Accessibility. Set the password text field's accessibility identifier to 'passwordTextField'.

The password UI interaction can now be written as:

 let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password")

There is one more UI interaction left: the confirm password input interaction. This time, you'll reference the confirm password text field by its placeholder. Go to storyboard and add the 'Confirm Password' placeholder for the confirm password text field. The user interaction can now be written like this:

 let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password")

Now, when you have all required UI interactions, all that is left is to write a simple XCTAssert (the same as you did in unit testing) to verify if the Register button's isEnabled state is set to true. The register button can be referenced using its title. Assert to check a button's isEnabled property looks like this:

 let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled")

The whole UI test should now look like this:

 func testRegistrationButtonEnabled() { // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password") let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }

If the test is run, Xcode will start the simulator and launch our test application. After the application is launched, our UI interaction instructions will be run one by one and at the end the assert will be successfully asserted.

To improve the test, let's also test that the isEnabled property of the register button is false whenever any of the required fields have not been not entered correctly.

The complete test method should now look like this:

 func testRegistrationButtonEnabled() { let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("pass") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") confirmPasswordTextField.typeText("word") // the whole confirm password word will now be "password" XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }

Tip: The preferred way to identify UI elements is by using accessibility identifiers. If names, placeholders, or some other property that can be localized is used, the element won't be found if a different language is used in which case the test would fail.

The example UI test is very simple, but it demonstrates the power of automated UI testing.

The best way to discover all possibilities (and there are many) of the UI testing framework included in Xcode is to start writing UI tests in your projects. Start with simple user stories, like the one shown, and slowly move to more complex stories and tests.

Become a Better Developer by Writing Good Tests

From my experience, learning and trying to write good tests will make you think about other aspects of development. It will help you become a better iOS developer altogether.

To write good tests, you will have to learn how to better organize your code.

Organized, modular, well-written code is the main requirement for successful and stress-free unit and UI testing.

In some cases, it is even impossible to write tests when code is not organized well.

When thinking about application structure and code organization, you'll realize that by using MVVM, MVP, VIPER, or other such patterns, your code will be better structured, modular, and easy to test (you will also avoid Massive View Controller issues).

When writing tests, you will undoubtedly, at some point, have to create a mocked class. It will make you think and learn about the dependency injection principle and protocol-oriented coding practices. Knowing and using those principles will notably increase your future projects' code quality.

Once you begin writing tests, you will probably notice yourself thinking more about corner cases and edge conditions as you write your code. This will help you eliminate possible bugs before they become bugs. Thinking about possible issues and negative outcomes of methods, you won't only test positive outcomes, but you will also start to test negative outcomes too.

As you can see, unit tests can have impact on different development aspects, and by writing good unit and UI tests, you will likely become a better and happier developer (and you won't have to spend as much time fixing bugs).

Start writing automated tests, and eventually you'll see the benefits of automated testing. When you see it for yourself, you'll become its strongest advocate.