App de notas completa (SwiftUI) con Tests UNITARIOS, Mocks y Tests de INTEGRACIÓN (Parte 6)
En este post resolvemos los ejercicios de los anteriores videos y completamos todos los Unit Tests y Integration Tests necesarios para nuestra app. Creamos los UseCases de Actualizar una nota y Borrar una nota
Tabla de contenido
Hoy en SwiftBeta vamos a continuar con nuestra serie de Tests, y vamos a resolver los ejercicios que propuse en los anteriores videos, que eran:
- Crear el UseCase de Actualizar una nota
- Crear el UseCase de Borrar una nota
- Crear el Integration Test de Actualizar y Borrar nota
- Arreglar los Unit Tests de Actualizar y Borrar nota
Vamos a ir punto por punto,
UseCase de Actualizar nota
El primero de todos es crear el UseCase de Actualizar nota. Vamos a la carpeta que creamos en el anterior video llamada UseCases, y pulsamos COMMAND+N. Vamos a llamar a nuestro nuevo tipo UpdateNoteUseCase.
Una vez creado, como con los anteriores casos de uso, vamos a crear una propiedad que tenga una referencia a nuestra base de datos, al tipo NotesDatabase.
struct UpdateNoteUseCase {
var notesDatabase: NotesDatabaseProtocol
init(notesDatabase: NotesDatabaseProtocol = NotesDatabase.shared) {
self.notesDatabase = notesDatabase
}
}
Y a continuación creamos el método update, el método que se va a encargar de llamar a notesDatabase para actualizar nuestra nota, este método no lo tenemos creado, por eso recibiremos un error. Pero no te preocupes que lo creamos en los próximos minutos:
func updateNoteWith(identifier: UUID, title: String, text: String?) throws {
try notesDatabase.update(identifier: identifier, title: title, text: text)
}
Muy sencillo, hemos creado un método que lo único que hace es llamar a la base de datos para actualizar la nota. Ahora, como vimos en el anterior video, vamos a crear un protocolo para poder mockear este UseCase cuando creemos el Test Unitario, de esta manera simularemos que hemos actualizado correctamente la nota sin la necesidad de interactuar con la base de datos. Pues creamos un protocolo llamado UpdateNoteProtocol:
protocol UpdateNoteProtocol {
func updateNoteWith(identifier: UUID, title: String, text: String?) throws
}
Y una vez hecho, lo conformamos en nuestro tipo UpdateNoteUseCase:
struct UpdateNoteUseCase: UpdateNoteProtocol
Fíjate que el compilador nos está avisando del error que te comentaba hace un momento. Debemos ir a nuestro tipo NotesDatabase y crear el método update, el método que se va a encargar de ir a la base de datos para actualizar la nota. Y ¿cómo vamos a saber qué nota actualizar? para hacerlo primero vamos hacer una búsqueda a partir del identificador que le estamos pasando, este identificador nos va a ayudar a encontrar la nota que queremos actualizar, y una vez encontrada la nota vamos a actualizarla con la nueva información. Pues lo siguiente que vamos hacer es crear la firma de nuestro nuevo método, y lo vamos hacer debajo del método fetchAll:
@MainActor
func update(identifier: UUID,
title: String,
text: String?) throws {
A continuación, vamos a crear un Predicate que nos va a servir para encontrar la nota con el identificador que estamos pasando como parámetro, vamos a crear el predicate, que sería como ir preparando nuestra query:
@MainActor
func update(identifier: UUID,
title: String,
text: String?) throws {
let notePredicate = #Predicate<Note> {
$0.identifier == identifier
}
Lo siguiente es añadir nuestro predicate al tipo fetchdescriptor, y también vamos a especificar que solo queremos recibir un resultado:
@MainActor
func update(identifier: UUID,
title: String,
text: String?) throws {
let notePredicate = #Predicate<Note> {
$0.identifier == identifier
}
var fetchDescriptor = FetchDescriptor<Note>(predicate: notePredicate)
fetchDescriptor.fetchLimit = 1
Y ahora vamos hacer el fetch con nuestra query. Si todo va bien obtendremos la nota y la podremos actualizar para guardarla otra vez en nuestra base de datos.
Todo esto lo hacemos dentro de un try catch:
@MainActor
func update(identifier: UUID,
title: String,
text: String?) throws {
let notePredicate = #Predicate<Note> {
$0.identifier == identifier
}
var fetchDescriptor = FetchDescriptor<Note>(predicate: notePredicate)
fetchDescriptor.fetchLimit = 1
do {
guard let updateNote = try container.mainContext.fetch(fetchDescriptor).first else {
throw DatabaseError.errorUpdate
}
updateNote.title = title
updateNote.text = text
try container.mainContext.save()
} catch {
print("Error guardando información")
throw DatabaseError.errorUpdate
}
}
Una vez finalizada la implementación, no te olvides de añadir el nuevo método al protocolo que tenemos más arriba, el NotesDatabaseProtocol
Perfecto, hemos creado el caso de uso para actualizar una nota, y hemos creado el método de la base de datos que se encarga de buscar la nota que queremos actualizar y actualizarla con la nueva información. Ahora vamos hacer exactamente lo mismo para borrar una nota. Ya que estamos en NotesDatabase, vamos a crear el método llamado remove que se va a encargar de borrar una nota a partir de un identificador, vamos a copiar el método del update y vamos a modificar algunas líneas, primero de todo cambiamos la firma del método y la renombramos a remove:
func remove(identifier: UUID) throws {
A continuación, en lugar de actualizar el title y el text, estas lineas las borramos. Y justo antes de borrar, vamos a llamar contexto para eliminar la nota que hemos encontrado:
container.mainContext.delete(deleteNote)
try container.mainContext.save()
Y a continuación, vamos a renombrar updateNote por deleteNote. Y también vamos a lanzar el error DatabaseError.errorRemove en lugar del errorUpdate.
Deberíamos tener el siguiente método:
@MainActor
func remove(identifier: UUID) throws {
let notePredicate = #Predicate<Note> {
$0.identifier == identifier
}
var fetchDescriptor = FetchDescriptor<Note>(predicate: notePredicate)
fetchDescriptor.fetchLimit = 1
do {
guard let deleteNote = try container.mainContext.fetch(fetchDescriptor).first else {
throw DatabaseError.errorRemove
}
container.mainContext.delete(deleteNote)
try container.mainContext.save()
} catch {
print("Error borrando información")
throw DatabaseError.errorRemove
}
}
Al acabar la implementación del nuevo método, ya podemos insertar su firma dentro del protocolo NotesDatabaseProtocol, y nos debería de quedar el siguiente contrado:
protocol NotesDatabaseProtocol {
func insert(note: Note) throws
func fetchAll() throws -> [Note]
func update(identifier: UUID, title: String, text: String?) throws
func remove(identifier: UUID) throws
}
Ahora ya podemos decir que hemos acabado la implementación de nuestra base de datos, ahora vamos a crear el use case de borrar una nota que llamará al método que acabamos de crear.
UseCase Borrar Nota
Vamos a la carpeta de UseCases y Pulsamos COMMAND+N y vamos a darle el nombre de RemoveNoteUseCase.
Como siempre, vamos a crear una propiedad que tenga una referencia a la base de datos, y vamos a crear un método que va a llamar a la implementación de borrar una nota de la base de datos:
import Foundation
struct RemoveNoteUseCase {
var notesDatabase: NotesDatabaseProtocol
init(notesDatabase: NotesDatabaseProtocol = NotesDatabase.shared) {
self.notesDatabase = notesDatabase
}
func removeNoteWith(identifier: UUID) throws {
try notesDatabase.remove(identifier: identifier)
}
}
Y ahora creamos el protocolo llamado RemoteNoteProtocol y lo conformamos en el tipo que acabamos de crear. De esta manera el ViewModel podrá usar la abstracción de la interfaz del protocolo, en lugar de inyectarle el tipo RemoveNoteUseCase:
protocol RemoveNoteProtocol{
func removeNoteWith(identifier: UUID) throws
}
struct RemoveNoteUseCase: RemoveNoteProtocol {
Si pulsamos COMMAND+B debería de funcionar perfectamente. Y así es, podemos continuar. Lo siguiente que vamos hacer es ir al ViewModel, y añadir estos 2 casos de uso que acabamos de crear.
Aquí en el ViewModel vamos a crear 2 propiedades nuevas, una para el use case de update, y la otra para el use case de remove, deberíamos tener el siguiente código:
var createNoteUseCase: CreateNoteProtocol
var fetchAllNotesUseCase: FetchAllNotesProtocol
var updateNoteUseCase: UpdateNoteProtocol
var removeNoteUseCase: RemoveNoteProtocol
init(notes: [Note] = [],
createNoteUseCase: CreateNoteProtocol = CreateNoteUseCase(),
fetchAllNotesUseCase: FetchAllNotesProtocol = FetchAllNotesUseCase(),
updateNoteUseCase: UpdateNoteProtocol = UpdateNoteUseCase(),
removeNoteUseCase: RemoveNoteProtocol = RemoveNoteUseCase()) {
self.notes = notes
self.createNoteUseCase = createNoteUseCase
self.fetchAllNotesUseCase = fetchAllNotesUseCase
self.updateNoteUseCase = updateNoteUseCase
self.removeNoteUseCase = removeNoteUseCase
fetchAllNotes()
}
Una vez tenemos referencias a los UseCases que hemos inyectado, ahora ya estamos listos para usarlos en los métodos correspondientes. Vamos al método del update y usamos la propiedad updateNoteUseCase, borramos todo el código de la implementación del método y escribimos el siguiente:
func updateNoteWith(identifier: UUID, newTitle: String, newText: String?) {
do {
try updateNoteUseCase.updateNoteWith(identifier: identifier, title: newTitle, text: newText)
} catch {
print("Error \(error.localizedDescription)")
}
}
Y lo mismo hacemos para el método remove:
func removeNoteWith(identifier: UUID) {
do {
try removeNoteUseCase.removeNoteWith(identifier: identifier)
fetchAllNotes()
} catch {
print("Error \(error.localizedDescription)")
}
}
Tremendo! ahora vamos a probar todo lo que hemos hecho hasta ahora, vamos a compilar nuestra aplicación. Ahora mismo debe de tener toda la funcionalidad, es decir, debemos ser capaces de crear notas, actualizarlas y eliminarlas. Compilamos la aplicación y vamos a probarla.
Perfecto! nuestra app ya tiene toda la funcionalidad completa. Ahora vamos a arreglar nuestro Tests.
¿Por cuales quieres empezar por los tests unitarios o los tests de integración? Vamos a empezar por los unitarios.
Vamos a nuestro ViewModelTests
Creamos los Tests Unitarios de Update y Remove Note
Si te acuerdas del anterior video, lo primero que necesitamos es crear un mock para retornar la información que nosotros queremos de nuestros UseCases. De esta manera no bajamos varias capas en nuestra arquitectura hasta llegar la base de datos para hacer operaciones, sino que simulamos un comportamiento. También, para poder comunicar las diferentes operaciones que hacíamos dentro de un mismo tests, creamos una variable llamada mockDatabase para ir almacenando las notas. En este caso quiero que al actualizar una nota, se busque dentro de esta propiedad y se actualice, así que voy a crear el mock de mi UpdateNoteUseCase, pero antes voy a recopilar todos los mocks y los voy a añadir en una carpeta, lo suyo sería crear un fichero por cada mock, pero por rapidez voy a añadirlos en un mismo fichero.
Creamos la carpeta mocks, y aquí dentro pulsamos COMMAND+N para crear el nuevo fichero. Pegamos los mocks CreateNoteUseCaseMock y FetchAllNotesUseCaseMock:
@testable import Notas
struct CreateNoteUseCaseMock: CreateNoteProtocol {
func createNoteWith(title: String, text: String) throws {
let note = Note(title: title, text: text, createdAt: .now)
mockDatabase.append(note)
}
}
struct FetchAllNotesUseCaseMock: FetchAllNotesProtocol {
func fetchAll() throws -> [Notas.Note] {
return mockDatabase
}
}
Y justo debajo añadimos el mock del UpdateNoteUseCase:
struct UpdateNoteUseCaseMock: UpdateNoteProtocol {
func updateNoteWith(identifier: UUID, title: String, text: String?) throws {
if let index = mockDatabase.firstIndex(where: { $0.identifier == identifier }) {
mockDatabase[index].title = title
mockDatabase[index].text = text
}
}
}
Fíjate que como base de datos, estamos usando una propiedad llamada mockDatabase. Esto lo hacemos para almacenar lo valores de nuestros tests y simular la persistencia.
Y una vez tengo creado el mock, ya puedo inyectarselo a ViewModel, así que vamos a ViewModelTests. Sé que lo repito mucho, pero al crear el inicializador del ViewModel para que tenga en cuenta abstracciones en lugar de implementaciones concretas de un tipo, aquí podemos añadirle cualquier tipo que conforme el protocolo UpdateNoteProtocol, de esta manera podemos inyectar un comportamiento específico.
override func setUpWithError() throws {
viewModel = ViewModel(createNoteUseCase: CreateNoteUseCaseMock(),
fetchAllNotesUseCase: FetchAllNotesUseCaseMock(),
updateNoteUseCase: UpdateNoteUseCaseMock())
}
Y justo debajo, donde comentamos el método testUpdateNote, vamos a descomentarlo para ver si pasa. Y pasamos el tests.
¡Nos sale en verde! acaba de pasar. Ahora vamos a hacerlo mismo para el caso de pasar el test unitario del testRemoveNote, y para hacerlo primero vamos a crear un mock del RemoveNoteUseCase, así que nos vamos al fichero llamado mocks que hemos creado hace un momento y creamos el siguiente mock:
struct RemoveNoteUseCaseMock: RemoveNoteProtocol {
func removeNoteWith(identifier: UUID) throws {
if let index = mockDatabase.firstIndex(where: { $0.identifier == identifier }) {
mockDatabase.remove(at: index)
}
}
}
Fíjate que siempre tomamos como fuente de la verdad, el array mockDatabase.
Y a continuación inyectamos el mock a nuestro ViewModel:
override func setUpWithError() throws {
viewModel = ViewModel(createNoteUseCase: CreateNoteUseCaseMock(),
fetchAllNotesUseCase: FetchAllNotesUseCaseMock(),
updateNoteUseCase: UpdateNoteUseCaseMock(),
removeNoteUseCase: RemoveNoteUseCaseMock())
}
Y finalmente, descomentamos el test llamado testRemoveNote. Y pasamos el test para ver si funciona. ¡Y así es!
Para asegurarnos, vamos arriba del todo y vamos a pasar todos los tests de nuestro tipo, y al hacerlo funciona perfectamente.
Nuestro fichero de mocks quedaría así:
import Foundation
@testable import Notas
struct CreateNoteUseCaseMock: CreateNoteProtocol {
func createNoteWith(title: String, text: String) throws {
let note = Note(title: title, text: text, createdAt: .now)
mockDatabase.append(note)
}
}
struct FetchAllNotesUseCaseMock: FetchAllNotesProtocol {
func fetchAll() throws -> [Notas.Note] {
return mockDatabase
}
}
struct UpdateNoteUseCaseMock: UpdateNoteProtocol {
func updateNoteWith(identifier: UUID, title: String, text: String?) throws {
if let index = mockDatabase.firstIndex(where: { $0.identifier == identifier }) {
mockDatabase[index].title = title
mockDatabase[index].text = text
}
}
}
struct RemoveNoteUseCaseMock: RemoveNoteProtocol {
func removeNoteWith(identifier: UUID) throws {
if let index = mockDatabase.firstIndex(where: { $0.identifier == identifier }) {
mockDatabase.remove(at: index)
}
}
}
Creamos los Tests de Integración de Update y Remove Note
¡Qué bonito y limpio está quedando todo! pulsamos COMMAND+U y vemos que pasan todos los tests. Ahora en nuestro fichero ViewModelIntegrationTest, vamos a crear 2 tests, uno para comprobar que la integración con la base de datos funciona y por lo tanto actualiza la nota, y el otro test para ver que se borra la nota de la base de datos. En los tests de integración sí usamos una base de datos real, pero usamos una configuración específica para persistir la información en memoria. Si te acuerdas de anteriores videos, usamos los Tests de Integración para testear el comportamiento de varios componentes, en este caso testeamos las capas del ViewModel, UseCase y Base de Datos.
Primero voy a crear el test para ver que la nota se crea y se actualiza correctamente cuando la obtengo de la base de datos:
func testUpdateNote() {
sut.createNoteWith(title: "Note 1", text: "text 1")
guard let note = sut.notes.first else {
XCTFail()
return
}
sut.updateNoteWith(identifier: note.identifier, newTitle: "SwiftBeta", newText: "New Text")
sut.fetchAllNotes()
XCTAssertEqual(sut.notes.count, 1, "Debería haber 1 nota en la base de datos")
XCTAssertEqual(sut.notes[0].title, "SwiftBeta")
XCTAssertEqual(sut.notes[0].text, "New Text")
}
Vamos a ver si pasa el nuevo test, y así es! funciona perfectamente. Y ahora vamos a crear el test para ver si añado 3 notas y borro una, ver si de verdad la integración con la base de datos funciona:
func testRemoveNote() {
sut.createNoteWith(title: "Note 1", text: "text 1")
sut.createNoteWith(title: "Note 2", text: "text 2")
sut.createNoteWith(title: "Note 3", text: "text 3")
guard let note = sut.notes.last else {
XCTFail()
return
}
sut.removeNoteWith(identifier: note.identifier)
XCTAssertEqual(sut.notes.count, 2, "Debería haber dos notas en la base de datos")
}
Ahora vamos a ver si pasa el test, y otra vez vuelve a funcionar. Vamos a pulsar COMMAND+U para pasar toda la suit de test de nuestro target de testing. Y todos pasan.
Vamos a repasar todo lo que hemos visto hasta ahora:
- Hemos creado la implementación que faltaba en nuestra base de datos para poder actualizar y elimianar una nota.
- Hemos creado los casos de uso para poderlo inyectar al ViewModel
- Al crear el init del ViewModel basándonos en protocolos, en abstracciones. Dentro de nuestro target de testing hemos podido crear mocks de nuestros casos de uso para poder especificar qué comportamiento queremos tener y así testear nuestro código, sin tener que involucrar a la base de datos
- Finalente, hemos creado tests de integración que involucran nuestra base de datos en memoria. Así podemos comprobar que la integración de distintas piezas de nuestro código funcionan y se comportan como deberían.
Antes de acabar este video, me gustaría poder enseñarte como manejar cuando lanzamos errores y así poderlos capturar. Es decir, ahora mismo si vamos a nuestro ViewModel, en caso de obtener un error lo único que hacemos es printarlo por consola, pero estaría bien guardar una referencia del error y así ver que nuestro código se comporta tal y como debería. Vamos a ver un ejemplo muy sencillo, ¿qué ocurre si intentamos borrar una nota que no existe en nuestra base de datos? ¿este error lo tenemos contemplado en nuestro código? ¿cómo reaccionaría nuestra aplicación? pues vamos a verlo. Vamos a nuestro ViewModel, y lo primero de todo es crear una propiedad de tipo DatabaseError debajo del Array notes:
var databaseError: DatabaseError?
Ahora que tenemos una propiedad llamada databaseError, cada vez que recibamos un error de capas inferiores (en este caso UseCase y NotesDatabase), podemos almacenar el error en esta nueva propiedad. Vamos a simular el comportamiento que te comentaba antes, qué ocurre si intento borrar una nota que no existe en la base de datos? Pues vamos a nuestro método removeNoteWith, y allí asignamos el error a nuestra nueva propiedad:
func removeNoteWith(id: UUID) {
do {
try removeNoteUseCase.removeNoteWith(identifier: id)
fetchAllNotes()
} catch let error as DatabaseError {
print("Error \(error.localizedDescription)")
databaseError = error
} catch {
print("Error \(error.localizedDescription)")
}
}
Y ahora desde nuestro test de integración vamos a probarlo:
func testRemoveNoteNotInDatabaseShouldThrowError() {
sut.removeNoteWith(id: UUID())
XCTAssertEqual(sut.notes.count, 0, "Debería haber 0 notas en la base de datos")
XCTAssertNotNil(sut.databaseError)
XCTAssertEqual(sut.databaseError, DatabaseError.errorRemove, "Error lanzado ya que no hay ningún elemento en la base de datos")
}
Al ejecutar nuestro test podemos comprobar el comportamiento que tendría nuestro código si intentamos eliminar una nota que no está en la base de datos. Este tipo de tests son muy potentes porque nos permiten testear nuestro código sin hacerlo manualmente, al crear este test podemos ver de un simple vistazo como se comportaría la app.
Este es un ejemplo, pero quería dejarte claro que no solo puedes crear tests de tu happy path, debes crear tests del comportamiento que esperar que siga tu código. Y aquí estamos usando una base de datos, pero podrías crear tests de un endpoint, de una llamada a tu backend donde esperas recibir un JSON (esto es tema para otro video).
Conclusión
Hoy hemos completado los ejercicios del anterior video, de una manera muy sencilla hemos ido paso a paso creando:
- El UseCase de Actualizar una nota
- El UseCase de Borrar una nota
Y luego hemos pasado a los tests, hemos creado
- El Integration Test de Actualizar y Borrar nota
- Arreglar los Unit Tests de Actualizar y Borrar nota (aquí los arreglamos añadiendo los mocks de cada UseCase)