Matteo Manferdini

Muchos desarrolladores encuentran las pruebas unitarias confusas. Esto se agrava por las técnicas avanzadas que se necesitan para probar las clases o el código asíncrono, como el de las peticiones de red.

En realidad, en la base de las pruebas unitarias, hay simples conceptos fundamentales. Una vez que los comprendes, las pruebas unitarias se vuelven repentinamente más accesibles.

Contenidos

  • Las pruebas unitarias explicadas en términos sencillos, términos comprensibles
  • Las pruebas unitarias te permiten comprobar casos límite difíciles de alcanzar y prevenir bugs
  • Escribir pruebas unitarias en Xcode
  • Una buena arquitectura hace que tu código sea más fácil de probar
  • Tratando los tipos de valor como funciones puras para simplificar las pruebas unitarias
  • .

  • Verificar tu código usando las funciones de aserción del framework XCTest
  • Medir la cobertura de código de una prueba en Xcode
  • Por qué aspirar a una cobertura de código específica no tiene sentido y qué deberías hacer en su lugar

Pruebas unitarias explicadas en términos simples, términos comprensibles

Cuando escribes una aplicación, pasas por un ciclo de escribir código, ejecutarlo y comprobar si funciona como se pretende. Pero, a medida que tu código crece, se hace más difícil probar toda tu aplicación manualmente.

Las pruebas automatizadas pueden realizar esas pruebas por ti. Así que, en términos prácticos, las pruebas unitarias son código para asegurarse de que el código de su aplicación es correcto.

La otra razón por la que las pruebas unitarias pueden ser difíciles de entender es que hay muchos conceptos que las rodean: las pruebas en Xcode, la arquitectura de la aplicación, las pruebas dobles, la inyección de dependencias, y así sucesivamente.

Pero, en su esencia, la idea de las pruebas unitarias es bastante sencilla. Puedes escribir pruebas unitarias simples en Swift. No necesitas ninguna función especial de Xcode ni ninguna técnica sofisticada, e incluso puedes ejecutarlas dentro de un Swift Playground.

Veamos un ejemplo. La función de abajo calcula el factorial de un entero, que es el producto de todos los enteros positivos menores o iguales a la entrada.

1
2
3
4
5
6
7

func factorial(de número: Int) -> Int {
var resultado = 1
para factor en 1…número {
resultado = resultado * factor
}
return resultado
}

Para probar esta función manualmente, le darías un par de números y comprobarías que el resultado es el esperado. Así, por ejemplo, lo probarías con cuatro y comprobarías que el resultado es 24.

Puedes automatizarlo, escribiendo una función Swift que haga la prueba por ti. ¡Por ejemplo:

1
2
3
4
5

func testFactorial() {
if factorial(of: 4) != 24 {
print(«El factorial de 3 está mal»)
}
}

Este es un código Swift sencillo que todo el mundo puede entender. Todo lo que hace es comprobar la salida de la función e imprimir un mensaje si no es correcta.

Eso es la prueba unitaria en pocas palabras. Todo lo demás son campanas y silbatos añadidos por Xcode para hacer las pruebas más sencillas.

O al menos, ese sería el caso si el código de nuestras aplicaciones fuera tan simple como la función factorial que acabamos de escribir. Por eso necesitas el apoyo de Xcode y técnicas avanzadas como los dobles de prueba.

No obstante, esta idea es útil tanto para pruebas sencillas como complejas.

Las pruebas unitarias te permiten comprobar casos límite difíciles de alcanzar y prevenir bugs

Puedes obtener varios beneficios de las pruebas unitarias.

El más obvio es que no tienes que probar continuamente tu código de forma manual. Puede ser tedioso ejecutar su aplicación y llegar a un lugar específico con la funcionalidad que desea probar.

Pero eso no es todo. Las pruebas unitarias también te permiten probar casos de borde que podrían ser difíciles de crear en una aplicación en ejecución. Y los casos de borde es donde la mayoría de los errores viven.

Por ejemplo, ¿qué sucede si alimentamos cero o un número negativo a nuestra función factorial(of:) de arriba?¡

1
2
3
4
5

func testFactorialOfZero() {
if factorial(of: 0) != 1 {
print(«El factorial de 0 está mal»)
}
}

Nuestro código se rompe:

En una aplicación real, este código provocaría un fallo. El cero está fuera del rango en el bucle for.

Pero nuestro código no falla por una limitación de los rangos de Swift. Falla porque hemos olvidado considerar los enteros no positivos en nuestro código. Por definición, el factorial de un entero negativo no existe, mientras que el factorial de 0 es 1.

1
2
3
4
5
6
7
8
9
10
11
12
13

func factorial(de número: Int) -> Int? {
if (number < 0) {
return nil
}
if (number == 0) {
return 1
}
var resultado = 1
for factor in 1…number {
result = result * factor
}
return resultado
}

Ahora podemos volver a realizar nuestras pruebas e incluso añadir una comprobación para números negativos.¡

1
2
3
4
5

func testFactorialOfNegativeInteger() {
if factorial(of: -1) != nil {
print(«El factorial de -1 está mal»)
}
}

Aquí ves el último y, en mi opinión, más importante beneficio de las pruebas unitarias. Las pruebas que escribimos anteriormente aseguran que, a medida que actualizamos nuestra función factorial(of:), no rompemos su código existente.

Esto es crucial en aplicaciones complejas, donde tienes que añadir, actualizar y eliminar código continuamente. Las pruebas unitarias le dan la confianza de que sus cambios no rompieron el código que estaba funcionando bien.

Escribiendo pruebas unitarias en Xcode

Con una comprensión de las pruebas unitarias, ahora podemos ver las características de prueba de Xcode.

Cuando usted crea un nuevo proyecto de Xcode, tiene la oportunidad de añadir pruebas unitarias de inmediato. Utilizaré, como ejemplo, una aplicación para hacer un seguimiento de las calorías. Puedes encontrar el proyecto completo de Xcode aquí.

Cuando marcas esa opción, tu proyecto de plantilla incluirá un objetivo de prueba ya configurado. Siempre puedes añadir uno después, pero yo suelo marcar la opción por defecto. Incluso si no voy a escribir pruebas de inmediato, todo estará configurado cuando decida hacerlo.

El objetivo de prueba ya comienza con algo de código de plantilla para nuestras pruebas.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
24
25
26

import XCTest
@testable import Calories
class CaloriesTests: XCTestCase {
override func setUpWithError() throws {
// Poner código de configuración aquí. Este método se llama antes de la invocación de cada método de prueba en la clase.
}
override func tearDownWithError() throws {
// Poner código de desmontaje aquí. Este método se llama después de la invocación de cada método de prueba en la clase.
}
func testExample() throws {
// Este es un ejemplo de un caso de prueba funcional.
// Utilice XCTAssert y funciones relacionadas para verificar que sus pruebas producen los resultados correctos.
}
func testPerformanceExample() throws {
// Este es un ejemplo de un caso de prueba de rendimiento.
self.measure {
// Pon el código del que quieres medir el tiempo aquí.
}
}
}

  • La línea @testable import Calories te da acceso a todos los tipos del objetivo principal de tu proyecto. El código de un módulo Swift no es accesible desde el exterior a menos que todos los tipos y métodos sean declarados explícitamente como públicos. La palabra clave @testable le permite sortear esta limitación y acceder incluso al código privado.
  • La clase CaloriesTests representa un caso de prueba, es decir, una agrupación de pruebas relacionadas. Una clase de caso de prueba necesita descender del XCTestCase.
  • El método setUpWithError() permite configurar un entorno estándar para todas las pruebas de un caso de prueba. Si necesitas hacer algo de limpieza después de las pruebas, utiliza tearDownWithError(). Estos métodos se ejecutan antes y después de cada prueba en un caso de prueba. No los necesitaremos, así que puedes eliminarlos.
  • El método testExample() es una prueba como las que escribimos para nuestro ejemplo factorial. Puedes nombrar los métodos de prueba como quieras, pero tienen que empezar con el prefijo test para que Xcode los reconozca.
  • Y finalmente, el método testPerformanceExample() te muestra cómo medir el rendimiento de tu código. Las pruebas de rendimiento son menos estándar que las pruebas unitarias, y se utilizan sólo para el código crucial que necesita ser rápido. Tampoco usaremos este método.

Puedes encontrar todos tus casos de prueba y pruebas relativas listadas en el navegador de pruebas de Xcode.

Puedes añadir nuevos casos de prueba a tu proyecto simplemente creando nuevos archivos .swift con código como el de arriba, pero es más fácil utilizar el menú de añadir en la parte inferior del navegador de pruebas.

Una buena arquitectura hace que su código sea más fácil de probar

En un proyecto real, no suele tener funciones sueltas como en nuestro ejemplo del factorial. En su lugar, tienes estructuras, enumeraciones y clases que representan las distintas partes de tu aplicación.

Es crucial organizar tu código según un patrón de diseño como MVC o MVVM. Tener un código bien diseñado no sólo hace que tu código esté bien organizado y sea más fácil de mantener. También hace que las pruebas unitarias sean más fáciles.

Esto también nos ayuda a responder a una pregunta común: ¿qué código deberías probar primero?

La respuesta es: empieza por tus tipos de modelo.

Hay varias razones para ello:

  • Si sigues el patrón MVC o uno de sus derivados, tus tipos de modelo contendrán la lógica de negocio del dominio. Toda su aplicación depende de la corrección de dicho código, por lo que incluso unas pocas pruebas tienen un impacto significativo.
  • Los tipos de modelo son a menudo estructuras, que son tipos de valor. Estos son mucho más fáciles de probar que los tipos de referencia (veremos por qué en un momento).
  • Los tipos de referencia, es decir, las clases, tienen dependencias y causan efectos secundarios. Para escribir pruebas unitarias para estos, se necesitan dobles de prueba (dummies, stubs, fakes, spies y mocks) y utilizar técnicas sofisticadas.
  • Los controladores (en términos MVC) o modelos de vista (en términos MVVM) a menudo están conectados a recursos como el almacenamiento en disco, una base de datos, la red y los sensores de dispositivos. También pueden ejecutar código paralelo. Esto hace que las pruebas unitarias sean más difíciles.
  • El código de la vista es el que cambia con más frecuencia, produciendo pruebas frágiles que se rompen fácilmente. Y a nadie le gusta arreglar pruebas rotas.

Tratando los tipos de valores como funciones puras para simplificar las pruebas unitarias

Así que vamos a añadir algunos tipos de modelos a nuestra aplicación.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

struct FoodItem {
let name: String
let caloriesFor100Grams: Int
let gramos: Int
}
struct Meal {
private(set) var items: =
var calories: Int {
var calories = 0
for item in items {
calories += (item.caloriesFor100Grams / 100) * item.grams
}
return calorías
}
func mutante add(_ item: FoodItem) {
items.append(item)
}
}

La estructura Meal contiene algo de lógica simple para añadir elementos de comida y calcular el total de calorías de los alimentos que contiene.

Las pruebas de unidad tienen ese nombre porque debes probar tu código en unidades separadas, eliminando todas las dependencias (probar las dependencias juntas se llama en cambio pruebas de integración). Cada unidad se trata entonces como una caja negra. La alimentas con alguna entrada y pruebas su salida para comprobar si es lo que esperas.

Eso era fácil en el caso de nuestro ejemplo del factorial porque era una función pura, es decir, una función en la que la salida depende sólo de la entrada, y que no crea ningún efecto secundario.

Pero ese no parece ser el caso de nuestro tipo Meal, que contiene estado.

El valor devuelto por la propiedad computada calories no tiene entrada. Depende únicamente del contenido del array items. El método add(_:), en cambio, no devuelve ningún valor y sólo cambia el estado interno de una comida.

Pero las estructuras son tipos de valor, y podemos considerarlas como funciones puras. Como no tienen dependencias, puedes considerar el estado inicial de una estructura como la entrada de un método y su estado después de llamar al método como la salida.

(Esta es una de las razones por las que debes abstenerte de poner tipos de referencia dentro de tipos de valor).

Verificando tu código usando las funciones de aserción del framework XCTest

Ahora tenemos algo de código para probar, y un lugar en nuestro proyecto de Xcode donde escribir las pruebas.

Nos falta un último ingrediente: una forma de expresar si nuestro código es correcto o no.

En el ejemplo del principio de este artículo, he usado simples sentencias print. Eso no es práctico para las pruebas unitarias reales. No quieres perder el tiempo rebuscando en los registros para identificar qué pruebas han fallado. Necesitamos una forma que apunte a las pruebas que fallan directamente.

En el framework XCTest, encuentras una serie de funciones de aserción que hacen que Xcode apunte a las pruebas que no pasan.

Con eso, podemos escribir nuestra primera prueba.

Como he mencionado anteriormente, las pruebas unitarias son útiles para comprobar los casos de borde, así que vamos a empezar a asegurarnos de que una comida vacía no tiene calorías.

1
2
3
4
5
6

class CaloriesTests: XCTestCase {
func testEmptyMeal() throws {
let meal = Meal()
XCTAssertEqual(meal.calories, 0, «Una comida vacía debe tener 0 calorías»)
}
}

El XCTest ofrece una función genérica XCTAssert que permite afirmar cualquier condición. Podrías usar eso para cualquier prueba que escribas, pero es mejor usar funciones de aserción más especializadas cuando sea posible, como XCTAssertEqual (arriba), XCTAssertNil, y otras. Estos producen mejores errores en Xcode que XCTAssert.

Puede ejecutar una sola prueba haciendo clic en el diamante junto a su nombre en el canalón del editor de código.

Cuando una prueba pasa, se vuelve verde, mientras que las pruebas que fallan se marcan en rojo. Puede cambiar el 0 de la prueba por cualquier otro número para ver el aspecto de una prueba fallida.

Puede ejecutar todas las pruebas de un caso de prueba haciendo clic en el diamante situado junto a la declaración de la clase. También puedes utilizar el navegador de pruebas que vimos anteriormente para ejecutar pruebas individuales o casos de prueba.

A menudo quieres ejecutar todas las pruebas de tu proyecto a la vez, lo que puedes hacer rápidamente pulsando cmd+U en tu teclado o seleccionando la opción Prueba en el menú Producto de Xcode.

Medir la cobertura de código de una prueba en Xcode

La siguiente pregunta más común sobre las pruebas unitarias es: ¿qué parte de tu código deberías probar?

Esto se suele medir por la cobertura de código, es decir, el porcentaje de código cubierto por sus pruebas. Así que, antes de responder a esa pregunta, vamos a ver cómo puedes medir la cobertura de código de tus pruebas en Xcode.

En primer lugar, tienes que dejar que Xcode recoja la cobertura de tu objetivo de pruebas. Haga clic en el botón con el nombre del proyecto en el control segmentado en la barra de herramientas de Xcode (al lado del botón de parada). A continuación, seleccione Editar esquema… en el menú emergente.

Ahí, seleccione Prueba en el esquema, luego vaya a la pestaña Opciones. Y, por último, seleccione Reunir cobertura para. Puedes dejar su opción a todos los objetivos.

Ahora puedes volver a ejecutar tus pruebas, lo que permitirá a Xcode recopilar los datos de cobertura. Encontrarás el resultado en el navegador de informes, seleccionando el elemento Cobertura de código en el esquema.

Aquí puedes comprobar los porcentajes de código cubierto por tus pruebas para todo el proyecto y para cada archivo.

Estos números no son tan significativos para nuestro ejemplo, ya que apenas hemos escrito código. Aun así, podemos ver que el método add(_:) del tipo Meal tiene una cobertura del 0%, lo que significa que aún no lo hemos probado.

La propiedad calculada de las calorías tiene, en cambio, una cobertura del 85,7%, lo que significa que hay algunas rutas de ejecución en nuestro código que nuestra prueba no activó.

En nuestro sencillo ejemplo, es fácil entender de qué ruta se trata. Sólo probamos las calorías de una comida vacía, por lo que el código en el bucle for no se ejecutó.

En métodos más sofisticados, sin embargo, podría no ser tan sencillo.

Para ello, puedes sacar la tira de cobertura de código en el editor de Xcode. Puedes encontrarlo en el menú Ajustar opciones del editor, en la esquina superior derecha.

Esto revelará una tira que muestra la cobertura de cada línea de código.

Los números te indican cuántas veces se ejecutó cada línea durante las pruebas. En rojo, puede ver qué líneas no se ejecutaron en absoluto (completas) o se ejecutaron sólo parcialmente (rayadas).

¿Por qué aspirar a una cobertura de código específica no tiene sentido y qué debería hacer en su lugar

Entonces, ¿qué porcentaje es un buen porcentaje de cobertura de código?

Las opiniones son muy diferentes. Algunos desarrolladores piensan que el 20% es suficiente. Otros van por números más altos, como el 70% o el 80%. Incluso hay desarrolladores que creen que sólo el 100% es aceptable.

En mi opinión, la cobertura de código es una métrica sin sentido. Puedes usarla para informar tus decisiones sobre las pruebas, pero no deberías tratarla como un objetivo que tienes que alcanzar.

Para ver por qué escribamos otra prueba para cubrir el código que no probamos todavía.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

class CaloriesTests: XCTestCase {
let banana = FoodItem(name: «Banana», caloriesFor100Grams: 89, grams: 118)
let steak = FoodItem(name: «Steak», caloriesFor100Grams: 271, grams: 225)
let goatCheese = FoodItem(name: «Goat Cheese», caloriesFor100Grams: 364, grams: 28)
func testEmptyMeal() throws {
let meal = Meal()
XCTAssertEqual(meal.calorías, 0, «Una comida vacía debe tener 0 calorías»)
}
func testCalories() {
var meal = Meal()
meal.add(banana)
meal.add(steak)
meal.add(goatCheese)
XCTAssertEqual(meal.items.count, 3)
XCTAssertEqual(meal.calories, 534)
}
}

Si vuelve a ejecutar todas las pruebas, obtendrá una cobertura del 100% para el archivo Model.swift. Tiene buena pinta, ¿no?

Ahora, ve y elimina el método testEmptyMeal(). Si ejecutas el test restante solo, verás que la cobertura para nuestros tipos Meal sigue siendo del 100%.

Esto ya te muestra que el número del 100% puede darte una falsa sensación de seguridad. Usted sabe que ahora no estamos probando todos los casos de borde para la propiedad calculada de calorías. Y sin embargo, nuestra cobertura de pruebas no lo refleja.

Yo añadiría que una cobertura del 100% no sólo es engañosa, sino incluso perjudicial. Parte del código de tu proyecto es propenso a cambios constantes, especialmente el código más nuevo y sobre todo en las vistas.

Cubrir el 100% del mismo significa que sólo tendrás que producir pruebas frágiles que se rompen continuamente, y que tienes que arreglar. Estas pruebas no detectan bugs. Se rompen porque la funcionalidad de tu código cambia.

Y aquí llegamos al punto del que nadie habla.

Escribir pruebas no es divertido, a pesar de lo que afirman algunos desarrolladores. Arreglar pruebas es aún menos divertido. Hazlo demasiado, y odiarás las pruebas unitarias y las dejarás de lado por completo.

La psicología es tan esencial para el desarrollo de software como las mejores prácticas. No somos máquinas.

Entonces, ¿a qué número debe aspirar? Eso requiere una discusión más extensa, pero aquí te daré un resumen de mi opinión.

Escribe pruebas útiles, comenzando con el código más antiguo de tu proyecto que no va a cambiar. Utiliza la cobertura de código sólo para identificar qué código no se ha probado todavía, pero no la utilices como objetivo o para decidir cuándo escribir más pruebas. Un 20% de cobertura es mejor que un 80% si tus pruebas son significativas, y estás probando el código correcto.

Como mencioné anteriormente, los tipos de modelos contienen el código más crítico de tu proyecto, que también tiende a cambiar menos. Por tanto, empieza por ahí.

Normalmente no me molesto en probar cada ruta de ejecución. Por ejemplo, en un proyecto real, no escribiría el método testEmptyMeal() que te mostré arriba. Sí, es crucial probar los casos de borde, pero no son todos importantes.

Típicamente sólo escribiría la prueba testCalories(). Eso me dice que mi código funciona, y me avisará si, más adelante, cometo un error al cambiar este método.

Seguro que no cubre todos los caminos, pero este código es tan sencillo que eso es una pérdida de tiempo. Dedicar tu tiempo a escribir código real para características que ayuden a tus usuarios es más importante que probar cada ruta de ejecución.

Sé que recibiré algunas críticas de algunos desarrolladores por esta opinión, pero no me importa. La cobertura de las pruebas es uno de esos debates que nunca terminarán.

El código que escribes suele ser mayoritariamente correcto. No hay necesidad de ser paranoico. Tener una plétora de pruebas que se rompen cada vez que cambias algo es un lastre, no una ventaja.

Tu tiempo y tu fuerza de voluntad son limitados. Dedícalos a tareas más importantes.

Arquitectura de aplicaciones SwiftUI con MVC y MVVM

Es fácil hacer una aplicación lanzando algo de código. Pero sin las mejores prácticas y una arquitectura robusta, pronto terminas con un código espagueti inmanejable. En esta guía te mostraré cómo estructurar adecuadamente las aplicaciones SwiftUI.

Obtén el libro gratis ahora

Deja una respuesta

Tu dirección de correo electrónico no será publicada.