Programación en Swift y SwiftUI para iOS Developers

Cómo escribir tests unitarios en Swift

En el competitivo mundo del desarrollo móvil, la diferencia entre una aplicación “buena” y una aplicación “excelente” a menudo reside en su estabilidad y fiabilidad. Para un iOS developer, dominar los tests unitarios en Swift no es solo una habilidad deseable, es un requisito fundamental para escalar proyectos y mantener una base de código saludable.

Con la llegada de Swift y SwiftUI, la arquitectura de nuestras aplicaciones ha evolucionado, y con ella, la forma en que abordamos el testing. Si estás buscando mejorar tu programación Swift y asegurar que tu lógica de negocio sea a prueba de balas tanto en iOS como en macOS y watchOS, has llegado al lugar correcto.

Este tutorial te guiará desde los conceptos básicos hasta técnicas avanzadas de inyección de dependencias, utilizando el framework nativo XCTest.


¿Por qué son vitales los Tests Unitarios en la era de SwiftUI?

Antes de abrir Xcode, debemos entender el porqué. En el paradigma imperativo antiguo (UIKit), a menudo mezclábamos lógica y vista. Con Swift y SwiftUI, la separación de intereses es más natural, especialmente si utilizamos patrones como MVVM (Model-View-ViewModel).

Los tests unitarios se centran en verificar que pequeñas piezas de código (funciones, métodos, clases) funcionen exactamente como se espera de forma aislada.

Beneficios clave para el desarrollador:

  1. Detección temprana de errores: Encuentra bugs antes de que lleguen a QA o, peor aún, a producción.
  2. Refactorización segura: Puedes mejorar tu código con la tranquilidad de que, si rompes algo, los tests te avisarán.
  3. Documentación viva: Los tests explican cómo se supone que debe funcionar tu código mejor que cualquier comentario.
  4. Diseño modular: Escribir código testeable te obliga a escribir mejor código (desacoplado y limpio).

Configurando el Entorno en Xcode

Para comenzar con tests unitarios en Swift, necesitas un proyecto con un “Unit Testing Bundle”.

Paso 1: Crear o modificar el proyecto

Si estás creando una app nueva en Xcode:

  1. Ve a File > New > Project.
  2. Selecciona App.
  3. En las opciones, asegúrate de marcar la casilla “Include Tests”.

Si ya tienes una app existente:

  1. Ve al navegador de proyectos (icono de la carpeta azul).
  2. Selecciona el target de tu proyecto principal.
  3. Abajo a la izquierda, pulsa el botón + y busca “Unit Testing Bundle”.

Esto creará una carpeta nueva en tu proyecto con un archivo TuAppTests.swift.


La Anatomía de un Test en Swift

XCTest es el framework nativo de Apple. Una clase de test típica se ve así:

import XCTest
@testable import MiAppIncreible

final class MiAppIncreibleTests: XCTestCase {

    override func setUpWithError() throws {
        // Se ejecuta ANTES de cada test.
        // Ideal para reiniciar estados o crear objetos.
    }

    override func tearDownWithError() throws {
        // Se ejecuta DESPUÉS de cada test.
        // Ideal para limpiar memoria o cerrar conexiones.
    }

    func testEjemplo() throws {
        // 1. Arrange (Preparar)
        let valorA = 10
        let valorB = 20
        
        // 2. Act (Actuar)
        let resultado = valorA + valorB
        
        // 3. Assert (Verificar)
        XCTAssertEqual(resultado, 30, "La suma debería ser 30")
    }
}

Palabras clave importantes:

  • @testable import: Permite acceder a clases y métodos internal de tu módulo principal sin tener que hacerlos public.
  • XCTAssert...: Son las aserciones. Si la condición es falsa, el test falla.

Estrategia de Testing en SwiftUI: El patrón MVVM

Aquí es donde convergen Swift y SwiftUI. No deberías intentar testear las View de SwiftUI con tests unitarios (para eso existen los UI Tests o Snapshot Tests). Tu objetivo es testear la lógica de estado, la cual debe residir en tu ViewModel o en tus servicios de datos.

Vamos a construir un escenario realista. Imagina una app de gestión de tareas (To-Do List).

El Modelo y el ViewModel

Primero, definamos la lógica en nuestra app (Target principal):

import Foundation

struct Tarea: Identifiable, Equatable {
    let id: UUID
    let titulo: String
    var estaCompletada: Bool
}

class GestorTareasViewModel: ObservableObject {
    @Published var tareas: [Tarea] = []
    
    func agregarTarea(titulo: String) {
        guard !titulo.isEmpty else { return }
        let nuevaTarea = Tarea(id: UUID(), titulo: titulo, estaCompletada: false)
        tareas.append(nuevaTarea)
    }
    
    func completarTarea(id: UUID) {
        if let index = tareas.firstIndex(where: { $0.id == id }) {
            tareas[index].estaCompletada = true
        }
    }
    
    func borrarTodo() {
        tareas.removeAll()
    }
}

Este código es pura programación Swift. No importa si la UI está en iOS, macOS o watchOS; la lógica es compartida.


Escribiendo tu Primer Test Unitario Real

Ahora, vamos al target de Tests (TuAppTests) y escribamos pruebas para validar este ViewModel.

1. Test de Estado Inicial

import XCTest
@testable import MiAppDeTareas

final class GestorTareasTests: XCTestCase {
    
    var viewModel: GestorTareasViewModel!

    override func setUpWithError() throws {
        // Inicializamos el SUT (System Under Test) antes de cada prueba
        viewModel = GestorTareasViewModel()
    }

    override func tearDownWithError() throws {
        viewModel = nil
    }

    func test_GestorTareas_IniciaVacio() {
        // Assert
        XCTAssertTrue(viewModel.tareas.isEmpty, "El gestor debería iniciar sin tareas")
        XCTAssertEqual(viewModel.tareas.count, 0)
    }
}

2. Test de Lógica de Negocio (Agregar)

Aquí probamos que la función agregarTarea realmente funcione y maneje casos borde (como títulos vacíos).

func test_GestorTareas_AgregarTarea_DeberiaAumentarContador() {
        // Arrange
        let titulo = "Aprender XCTest"
        
        // Act
        viewModel.agregarTarea(titulo: titulo)
        
        // Assert
        XCTAssertEqual(viewModel.tareas.count, 1)
        XCTAssertEqual(viewModel.tareas.first?.titulo, titulo)
        XCTAssertFalse(viewModel.tareas.first!.estaCompletada)
    }
    
    func test_GestorTareas_AgregarTareaVacia_NoDeberiaAgregar() {
        // Act
        viewModel.agregarTarea(titulo: "")
        
        // Assert
        XCTAssertTrue(viewModel.tareas.isEmpty, "No se deben permitir tareas sin título")
    }

3. Test de Modificación de Estado

func test_GestorTareas_CompletarTarea_DeberiaCambiarEstado() {
        // Arrange
        let titulo = "Testear SwiftUI"
        viewModel.agregarTarea(titulo: titulo)
        let idTarea = viewModel.tareas.first!.id
        
        // Act
        viewModel.completarTarea(id: idTarea)
        
        // Assert
        XCTAssertTrue(viewModel.tareas.first!.estaCompletada)
    }

Nivel Avanzado: Inyección de Dependencias y Mocking

Un ios developer senior sabe que las apps reales no guardan todo en memoria; se conectan a APIs o bases de datos. Testear contra una API real es mala práctica (es lento, inestable y depende de internet).

Aquí es donde entra el Mocking. Para hacer esto testable, debemos usar Protocolos.

Paso 1: Definir el Protocolo

Refactoricemos el código para que el ViewModel no dependa de una implementación concreta, sino de una abstracción.

// En tu código principal

protocol ServicioDatosProtocol {
    func descargarItems() async throws -> [String]
}

class ServicioDatosReal: ServicioDatosProtocol {
    func descargarItems() async throws -> [String] {
        // Simulación de llamada a red
        try await Task.sleep(nanoseconds: 1_000_000_000) 
        return ["Manzanas", "Peras", "Naranjas"]
    }
}

class ListaComprasViewModel: ObservableObject {
    @Published var items: [String] = []
    let servicio: ServicioDatosProtocol
    
    // Inyección de Dependencias
    init(servicio: ServicioDatosProtocol) {
        self.servicio = servicio
    }
    
    func cargarDatos() async {
        do {
            let nuevosItems = try await servicio.descargarItems()
            DispatchQueue.main.async {
                self.items = nuevosItems
            }
        } catch {
            print("Error")
        }
    }
}

Paso 2: Crear el Mock para Tests

En tu carpeta de Tests (no en la app), crea un objeto simulado.

class MockServicioDatos: ServicioDatosProtocol {
    
    var itemsAEnviar: [String] = []
    var debeFallar: Bool = false
    
    func descargarItems() async throws -> [String] {
        if debeFallar {
            throw URLError(.badServerResponse)
        }
        return itemsAEnviar
    }
}

Paso 3: Testear con Async/Await

Desde Swift 5.5, testear código asíncrono es mucho más limpio. Ya no dependemos tanto de XCTestExpectation para tareas simples.

final class ListaComprasTests: XCTestCase {
    
    var viewModel: ListaComprasViewModel!
    var mockServicio: MockServicioDatos!
    
    override func setUp() {
        mockServicio = MockServicioDatos()
        viewModel = ListaComprasViewModel(servicio: mockServicio)
    }
    
    func test_CargarDatos_Exito_DeberiaPoblarArray() async {
        // Arrange
        mockServicio.itemsAEnviar = ["Test 1", "Test 2"]
        
        // Act
        await viewModel.cargarDatos()
        
        // Assert
        // Nota: Como la actualización del @Published ocurre en MainActor, 
        // a veces necesitamos esperar un ciclo. En tests unitarios puros
        // de lógica asíncrona, validamos el resultado directo si es posible.
        // Para este ejemplo simple, asumimos que el await espera la ejecución.
        
        XCTAssertEqual(viewModel.items.count, 2)
        XCTAssertEqual(viewModel.items.first, "Test 1")
    }
}

Testing Multiplataforma: iOS, macOS, watchOS

La belleza de desarrollar con swift y swiftui es la portabilidad.

Cuando creas un paquete de tests, este suele estar vinculado a un Target (por ejemplo, tu App de iOS). ¿Qué pasa si tu lógica está en un Framework compartido o si tienes una App Multiplataforma?

  1. Swift Packages: La mejor práctica moderna es mover tu lógica de negocio (Modelos y ViewModels) a un Swift Package local.
  2. Targets de Test: Los Swift Packages tienen sus propios targets de test que pueden ejecutarse en cualquier plataforma (Mac, iPhone, Watch) simplemente seleccionando el destino en la barra superior de Xcode.

Si mantienes tu lógica desacoplada de UIKit o SwiftUI (importando solo Foundation o Combine donde sea posible), tus tests unitarios funcionarán idénticos en watchOS que en iOS.


Métricas de Calidad: Code Coverage

Xcode incluye una herramienta fantástica para ver cuánto de tu código está siendo probado.

  1. Ve a Product > Scheme > Edit Scheme.
  2. Selecciona “Test” en la barra lateral.
  3. Ve a la pestaña “Options”.
  4. Activa la casilla “Gather coverage for all targets”.

Ahora, cuando ejecutes tus tests (Cmd + U), puedes ir al “Report Navigator” (el último icono a la derecha en el panel izquierdo), seleccionar el último test y ver la pestaña “Coverage”. Verás un porcentaje.

Consejo Pro: No te obsesiones con el 100%. Un 80% suele ser un estándar excelente. Enfócate en testear la lógica compleja y crítica, no los setters y getters simples.


Mejores Prácticas para el iOS Developer Moderno

Para cerrar este tutorial, aquí tienes un resumen de las reglas de oro para la programación swift orientada a tests:

1. Naming Conventions (Nomenclatura)

El nombre del test debe contar una historia. Usa el formato: test_Objeto_Accion_ResultadoEsperado

  • ❌ test1()
  • ✅ test_Calculadora_AlSumarDosMasDos_DeberiaRetornarCuatro()

2. Principio FIRST

  • Fast (Rápidos): Deben correr en milisegundos.
  • Isolated (Aislados): Un test no debe depender del resultado de otro.
  • Repeatable (Repetibles): Deben dar el mismo resultado siempre.
  • Self-validating (Auto-validables): Pasan o fallan, sin interpretación manual.
  • Timely (Oportunos): Se escriben antes o durante el desarrollo, no meses después.

3. Evita la Lógica en los Tests

Tus tests no deberían tener if o for complejos. Si tu test necesita lógica compleja, ¿quién testea al test? Manténlos lineales: Prepara, Actúa, Verifica.


Conclusión

Implementar tests unitarios en Swift puede parecer una tarea lenta al principio, pero es la única forma de garantizar velocidad a largo plazo. Al separar tu lógica de la vista usando Swift y SwiftUI con MVVM, y al utilizar inyección de dependencias, creas un código robusto que puede sobrevivir a grandes cambios y refactorizaciones.

Como iOS developer, tu responsabilidad no es solo escribir código que funcione hoy, sino código que pueda ser mantenido mañana. Los tests unitarios son tu red de seguridad en iOS, macOS y watchOS.

Si tienes cualquier duda sobre este artículo, contacta conmigo y estaré encantado de ayudarte 🙂. Puedes contactar conmigo en mi perfil de X o en mi perfil de Instagram

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Qué es y cómo usar @AppStorage en SwiftUI

Next Article

Qué es Swift Testing y cómo usarlo en Xcode

Related Posts