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:
- Detección temprana de errores: Encuentra bugs antes de que lleguen a QA o, peor aún, a producción.
- Refactorización segura: Puedes mejorar tu código con la tranquilidad de que, si rompes algo, los tests te avisarán.
- Documentación viva: Los tests explican cómo se supone que debe funcionar tu código mejor que cualquier comentario.
- 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:
- Ve a File > New > Project.
- Selecciona App.
- En las opciones, asegúrate de marcar la casilla “Include Tests”.
Si ya tienes una app existente:
- Ve al navegador de proyectos (icono de la carpeta azul).
- Selecciona el target de tu proyecto principal.
- 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étodosinternalde tu módulo principal sin tener que hacerlospublic.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?
- Swift Packages: La mejor práctica moderna es mover tu lógica de negocio (Modelos y ViewModels) a un Swift Package local.
- 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.
- Ve a Product > Scheme > Edit Scheme.
- Selecciona “Test” en la barra lateral.
- Ve a la pestaña “Options”.
- 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
settersygetterssimples.
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










