Esencialmente, una prueba unitaria o unit test es un método que crea una instancia de una pequeña porción de código de nuestra aplicación y comprueba su correcto comportamiento, independientemente de otras partes.

El Unit Testing es una herramienta esencial para el desarrollador de software, sin embargo, a veces puede ser un poco complicado de entender e implementar. Esto se debe a que usualmente cuando se desarrolla una solución a algún problema, dicha solución puede ser excesivamente compleja, dando como resultado un código pobremente diseñado, y por ende, difícil de probar.

Tal vez tengas una aplicación “funcional”, sin embargo, la corrección de errores, bugs, reutilización de código y constante escalabilidad del mismo pueden llegar a complicar el desarrollo a futuro. El Unit Testing nos puede ayudar a evitar éste tipo de problemas.

En éste blog trataré de explicar de manera breve y concisa en qué consiste el Unit Testing y cómo puedes empezar a implementarlo en el desarrollo de tus aplicaciones.

¿Estás listo?

Con motivos didácticos, procederé primero a explicar algunos conceptos importantes de las pruebas unitarias, así como una descripción del proceso de las mismas, pero descuida, continuaré con un breve tutorial para desarrollar tu primer prueba unitaria desde cero.

Muy bien, ¡empecemos!

Buenas prácticas

El acrónimo FIRST describe un conciso set de criterios para una efectiva prueba unitaria, éstos son:

  • Fast: Las pruebas no deben tardar en terminar de ejecutarse.
  • Independent/Isolated: Las pruebas no deben compartir o depender de algún estado de otra prueba.
  • Repeatable: Debes obtener los mismos resultados cada vez que ejecutes la misma prueba, a menos que cambies alguna característica específica, ajena a datos externos que no dependan de tí.
  • Self-validating: Las pruebas deben ser completamente automatizadas, el output debe ser solamente “pasó” o “falló”, en lugar de tener que ser interpretado por el programador.
  • Timely: Idealmente, las pruebas deben ser escritas siempre antes del código en producción. Esto quiere decir, cuando se va a desarrollar una característica de la aplicación, primero se escriben las pruebas necesarias, y después, basándonos en éstas, implementar la característica deseada. De esta forma, las pruebas no serán obsoletas, además, las pruebas nos obligan a implementar las buenas prácticas en nuestro código.

Flujo de las pruebas unitarias

Esencialmente la prueba unitaria consta de 3 fases:

  • Primero: Se inicializa una pequeña parte del código de la aplicación que se quiere probar.
  • Luego: Se aplica un estímulo (ejecutando dicha parte de código con ciertas características).
  • Finalmente: Se observa el resultado final de la prueba.

iOS Unit Testing Bundle

Las pruebas unitarias se ejecutan en un Target diferente al principal, dicho Target es llamado iOS Unit Testing Bundle.

Para poder hacer uso de los métodos y variables de nuestro Target principal se debe agregar un @testable import de dicho Target, sin embargo, los componentes privados seguirán siendo inaccesibles.

En el lado izquierdo vemos un ejemplo de nuestro Target para pruebas unitarias, dentro del cual se agregará el mencionado @testable import. En el lado derecho vemos un ejemplo de nuestro Target principal, al cual tendremos acceso sólo a la los componentes que no estén declarados como privados.

Red - Green - Refactor

El ciclo Red - Green - Refactor se refiere al proceso que deben seguir las pruebas unitarias cuando se están implementando, cumpliendo 3 requerimientos:

  • La prueba debe poder fallar: Si una prueba nunca puede fallar, porque no existe algún criterio que pueda hacerla fallar, no es necesario implementarla. (Red)
  • La prueba debe poder pasar: Si una prueba tiene criterios imposibles de cumplir, y por ende nunca podrá pasar, no es necesario utilizarla. (Green)
  • La prueba debe mantenerse simple: Se debe eliminar el código redundante y realizar cada prueba de forma independiente y simple. (Refactor)

Creación de un proyecto de prueba

Muy bien, ha llegado el momento que has esperado, el momento de escribir código.

Primero, crearemos un proyecto nuevo en Xcode:

De tipo App:

La interfaz será de tipo Storyboard utilizando Swift como lenguaje.
Por defecto, el checkbox para incluir los Tests está activado, sin embargo, lo desactivaremos para crearlos manualmente:

Dentro del proyecto crearemos una clase llamara Cart para simular el comportamiento de un carrito de compras. Comportamiento que evaluaremos en las pruebas unitarias:

import Foundation

class Cart {
  var products = 0
  
  func addProduct() {
    products += 1
  }
  
  func removeProduct() {
    products = (products > 0) ? products - 1 : products
  }
  
  func removeAllProducts() {
    products = 0
  }
} 

Listo, ya tenemos con qué trabajar, ahora continuamos con el Target para las pruebas unitarias.

Creación de una prueba unitaria

Crearemos un Target para nuestras pruebas unitarias:

Recuerda que mencionamos que las pruebas se ejecutan en un Target diferente, comunicándose al Target principal ejecutando pequeñas porciones de código.

Por convención, el nombre del Target será el mismo que el principal seguido de la palabra Tests:

Al momento de crearlo, notarás en los archivos que se ha creado una carpeta llamada UnitTestingTests, dentro de la carpeta raíz, con una clase llamada UnitTestingTests.swift. Eliminaremos esta clase y crearemos una carpeta llamada Cases en su lugar, dentro de la cual crearemos nuestras clases para pruebas. La jerarquía de archivos quedará de esta manera:

Crearemos una clase de Swift para albergar nuestra primera prueba, el tipo de clase se llama Unit Test Case:

Le damos el nombre de CartTests ya que haremos pruebas de la clase Cart y por convención debe ir la palabra Tests al final:

Verificamos que el Target de las pruebas sea el único activado:

Se creará la clase con código por defecto, el cuál borraremos, y añadiremos el siguiente código:

import XCTest
@testable import UnitTesting

class CartTests: XCTestCase {

  func testAddProduct() {
    let cart = Cart()
    cart.addProduct()
    cart.addProduct()
    cart.addProduct()
    XCTAssertEqual(cart.products, 2, "Add product failed")
  }
  
  func testRemoveProduct() {
    let cart = Cart()
    cart.addProduct()
    cart.addProduct()
    cart.addProduct()
    cart.removeProduct()
    XCTAssertEqual(cart.products, 3, "Remove product failed")
  }
  
  func testRemoveAllProducts() {
    let cart = Cart()
    cart.addProduct()
    cart.addProduct()
    cart.addProduct()
    cart.removeAllProducts()
    XCTAssertEqual(cart.products, 0, "Remove products failed")
  }
}

Las aserciones, como lo mencioné, son declaraciones de diferentes tipos en los cuales puedes evaluar si un elemento es igual a otro (en éste caso), o que un elemento no es nulo, que es mayor a, que es TRUE o FALSE etc.

Existen diversas aserciones, dependiendo de lo que necesites probar, puedes escribir XCTAssert y dejar que se complete el código para que te muestre las opciones disponibles.

En una clase pueden existir muchas funciones (pruebas) que comprueben el funcionamiento de algo independiente, asimismo, en cada función pueden existir diferentes aserciones.

La clase no extiende de un ViewController, como lo notarás, sino que de XCTestCase, razón por la cual podemos hacer aserciones.

Ejecución de una prueba unitaria

Ahora bien, ya tenemos lista la prueba, ¿Cómo se ejecuta? Bueno, hay diferentes formas, pero primero quiero explicarte qué sucederá cuando se ejecutan las pruebas unitarias.

La aplicación se ejecutará en el teléfono, pero no aparecerá el flujo de la misma, en lugar de eso, se quedará estática en la primera vista, las pruebas se ejecutarán, al terminar, la ejecución se detendrá, la aplicación en el teléfono se cerrará, y Xcode te mostrará las pruebas que han pasado y las que no.

Como comenté, existen varias formas de ejecutar las pruebas, ya sea todas las que existen en el proyecto, por clase, o individualmente (un sólo método).

Prueba general

Se ejecuta desde Product>Test.
Ésta opción ejecutará todas las pruebas existentes en nuestro proyecto, una por una. Es importante recalcar que Xcode no garantiza el orden de ejecución de las mismas, es por eso que cada una debe seguir los principios de FIRST que vimos anteriormente.

Pruebas de una clase

Notarás que en la clase existen unos “botones” en forma de diamante en la declaración de la misma y en la declaración de la función. Si presionas el diamante de la clase, se ejecutarán todas las pruebas de esa sola clase.

Prueba única

Si presionas el diamante de la función, únicamente se ejecutará esa prueba.

Bien! Por esta vez ejecutaremos una sola prueba presionando el diamante de la función testAddProduct, ¿qué sucede?

La prueba se ejecuta, y al terminar, nos remarca con rojo la aserción fallida, mostrándonos por qué falló, el resultado esperado y el mensaje (Espera que los productos sean iguales a 2, pero nosotros agregamos 3, por lo tanto la aserción es falsa). De igual manera, en nuestra consola nos muestra todo el proceso de las pruebas en orden, mostrándonos el output de la prueba misma, la clase, el target, el tiempo de ejecución, etc.

¿Recuerdas el ciclo red - green - refactor? Bueno, acabamos de terminar la fase RED, en donde nos aseguramos que la prueba puede fallar.

Ahora cambiemos el resultado esperado en el segundo parámetro de la aserción a 3 (que es el número esperado de productos agregados ) y ejecutamos de nuevo la prueba, ¿qué sucede?

Se muestran los diamantes en verde, señal de que la prueba fue exitosa! Acabamos de terminar la fase GREEN, donde nos aseguramos que la prueba puede pasar.

Puedes realizar la misma acción con las otras dos pruebas, al principio, las dos te darán un error, intenta corregirlo para que terminen la fase GREEN!

Ahora procederemos la última fase, REFACTOR, en donde simplificaremos el código. Dado que no tenemos mucho código y tampoco tenemos más pruebas, lo organizaremos de tal forma que pueda ser escalable a la hora de agregarlas. Cambia el código de tal forma que se vea así:

class CartTests: XCTestCase {
  
  var cart: Cart? // 1
  
  override func setUp() { // 2
    super.setUp()
    cart = Cart() // 3
  }

  func testAddProduct() {
    cart?.addProduct()
    cart?.addProduct()
    cart?.addProduct()
    XCTAssertEqual(cart?.products, 3, "Add product failed")
  }
  
  func testRemoveProduct() {
    cart?.addProduct()
    cart?.addProduct()
    cart?.addProduct()
    cart?.removeProduct()
    XCTAssertEqual(cart?.products, 2, "Remove product failed")
  }
  
  func testRemoveAllProducts() {
    cart?.addProduct()
    cart?.addProduct()
    cart?.addProduct()
    cart?.removeAllProducts()
    XCTAssertEqual(cart?.products, 0, "Remove products failed")
  }
  
  override func tearDown() { // 4
    super.tearDown()
    cart = nil // 5
  }
}
  • //1: Movemos la variable de Cart afuera de las pruebas y la declaramos como opcional.
  • //2: Sobreescribimos el método setUp(), el cuál se ejecutará antes de cada prueba, esto quiere decir, cuando ejecutemos las 3 pruebas de la clase, setUp() se ejecutará también 3 veces.
    Aquí se hacen las inicializaciones necesarias.
  • //3: Inicializamos nuestra clase Cart.
  • //4: Sobreescribimos el método tearDown(), el cuál se ejecutará después de cada prueba, ésto quiere decir, al igual que el método setUp(), se ejecutará 3 veces. Aquí se regresan las variables usadas a su estado inicial, con el fin de que cada prueba se ejecute utilizando las mismas con un estado limpio.
  • //5: Seteamos la variable de cart en nil, para regresar a su estado normal en setUp().

Ahora, cuando se ejecuten las pruebas de la clase, se ejecutará primero el método setUp, inicializando el objeto Cart para su uso, de ésta forma englobamos su inicialización, declarándola una sola vez, en lugar de declararla dentro de cada prueba. 
Posteriormente, se ejecutará testAddProduct (recuerda que el orden no está garantizado) realizando las aserciones requeridas. 
Después se ejecutará el método tearDown, reseteando el estado del objeto Cart.
 Luego se volverá ejecutar el método setUp, y después la prueba siguiente, repitiéndose el proceso dependiendo del número de pruebas.

Así finalizamos la introducción al Unit Testing en Xcode con Swift.

¿Qué sigue?

Las pruebas unitarias son sólo uno de varios aspectos que engloba la programación enfocada a las pruebas (Test Driven Development) o TDD, existe un gran camino por recorrer para convertirte en un experto.

Temas como UI Testing, Unit Testing de elementos asíncronos (como llamadas a una API REST) y mejores prácticas de programación para hacer de las pruebas una actividad natural y sencilla, sin convertirse en un dolor de cabeza o trabajo doble, tales como el uso normalizado de inyección de dependencias, Protocolos y Delegados, entre otros, que espero poder cubrir en blogs posteriores.

Links de mucha ayuda: