Pruebas de integración con SwiftData y ViewModel

Resumen

Las pruebas de integración con SwiftData te permiten validar que la lógica de tu ViewModel funciona correctamente cuando se conecta directamente con la base de datos real, sin depender de mocks ni stubs. Esto es clave para desarrolladores iOS que ya cubrieron pruebas unitarias y quieren un siguiente nivel de confianza en su código.

Antes de escribir el primer test, conviene revisar la capa de servicios. En el proyecto Gastify existe una carpeta services con SDDatabaseService, que contiene las funciones de guardar, actualizar, eliminar y obtener totales. Esa implementación real es la que vamos a probar ahora, en lugar de simularla.

¿Cómo configurar el archivo de pruebas de integración?

Dentro del target de tests creas un nuevo archivo desde Unit Test Template y lo nombras HomeViewModelIntegrationTest. Eliminas el código del template y agregas @testable import Gastify para tener acceso a las clases internas del proyecto.

A partir de ahí defines la estructura básica con setup y teardown, y declaras dos propiedades: el ViewModel y el servicio real de base de datos.

  • override func setUp() para preparar el entorno antes de cada test.
  • override func tearDown() para limpiar después de cada test.
  • Una propiedad para el viewModel y otra para databaseService. [02:14]

¿Qué diferencia hay entre una prueba unitaria y una de integración? La prueba unitaria aísla la lógica usando mocks, mientras que la de integración conecta el ViewModel con el servicio real para verificar que ambos funcionan juntos.

¿Por qué necesito expectations al inicializar SwiftData?

Como SDDatabaseService está marcado con @MainActor, debe inicializarse dentro de una tarea que corra en el actor principal. Esto obliga a usar expectations en el setUp para esperar la finalización de un proceso asíncrono.

El flujo dentro del setup queda así:

  1. Crear un expectation llamado initializeDatabaseService.
  2. Lanzar un Task { @MainActor in ... } donde se instancian el servicio y el ViewModel.
  3. Llamar a expectation.fulfill() cuando termine la inicialización.
  4. Usar wait(for:timeout:) con un timeout de 2 segundos como margen prudente. [03:30]

Si pasan los 2 segundos sin que se cumpla el expectation, eso indica un error en la implementación o un problema en tiempo de ejecución.

¿Cómo garantizar la integridad entre tests con clearDatabase?

Uno de los riesgos más grandes en pruebas de integración es que un test contamine al siguiente. Si el test A guarda registros y el test B asume base vacía, el resultado se vuelve impredecible.

La solución es una función auxiliar privada llamada clearDatabase que elimina todos los registros antes de cada test. Se llama justo después de inicializar el databaseService dentro del setUp, usando await self.clearDatabase() porque cada eliminación es asíncrona.

En el tearDown también limpias instancias asignando viewModel = nil y databaseService = nil para liberar memoria entre ejecuciones. [05:12]

¿Por qué limpiar la base de datos en cada setup? Porque cada test debe iniciar con un estado conocido y predecible. Si dejas datos residuales, un test puede pasar o fallar dependiendo del orden en que se ejecute.

¿Qué lógica del ViewModel tiene sentido probar en integración?

La única lógica relevante en pruebas de integración es la que toca la base de datos. Aquí el foco son dos funciones del ViewModel:

  • getRecords para validar la obtención de registros.
  • getTotals para validar la obtención de totales.

¿Cómo escribir el test de getRecords paso a paso?

El patrón es muy similar al de pruebas unitarias, pero ya no necesitas inicializar el ViewModel dentro del test porque eso ocurrió en el setUp.

  1. Crear un expectation para esperar la respuesta asíncrona.
  2. Lanzar un Task que llame a viewModel.getRecords().
  3. Hacer expectation.fulfill() al terminar.
  4. Esperar con wait(for:timeout:) hasta 2 segundos.
  5. Validar con un assert que la lista de records no sea nula. [07:48]

Un detalle importante surge al correr el test la primera vez: si agregas un XCTAssertFalse(records.isEmpty) el test fallará. Y tiene sentido, porque clearDatabase se ejecuta antes de cada test, así que la base siempre arranca vacía. Ese assert específico no aplica para este caso y debe eliminarse.

¿Cómo interpreto un test que falla en Xcode? En el reporte de tests, los que salen en rojo te llevan al archivo y línea exacta del fallo, mostrando el mensaje del assert que no se cumplió. Eso te da la pista directa para corregir.

Después de eliminar el assert incorrecto, vuelves al test plan, verificas que HomeViewModelIntegrationTest esté incluido, presionas Command + U y los tests pasan satisfactoriamente.

Habilidades y conceptos clave que se trabajan

En esta práctica se ponen en juego varias destrezas concretas del desarrollo iOS moderno:

  • Pruebas de integración: validan la interacción real entre componentes, en este caso ViewModel y SDDatabaseService [00:08].
  • SwiftData: el framework de persistencia de Apple usado para guardar, actualizar, eliminar y consultar registros [00:35].
  • @MainActor: atributo que obliga a ejecutar código en el actor principal, lo que cambia cómo inicializas servicios en los tests [03:10].
  • XCTestExpectation: mecanismo de XCTest para esperar resultados asíncronos antes de hacer asserts [03:30].
  • setUp y tearDown: métodos que preparan y limpian el entorno antes y después de cada test, garantizando aislamiento [02:14].
  • @testable import: directiva que expone símbolos internos del módulo al target de pruebas [01:50].

La tarea final es implementar el test de getTotals siguiendo el mismo patrón. Cuéntame en los comentarios cómo te fue con esa implementación y si encontraste alguna diferencia respecto a getRecords.