Aprende a testear apps en Swift y SwiftUI
Aprende a testear apps en Swift y SwiftUI

Introducción CURSO TESTING en Swift - Creamos nuestra app de notas en SwiftUI (Parte 2)

Testear una app en Swift y SwiftUI es muy sencillo, pero antes de poder testear tenemos que tener código de producción que podamos testear. En el post de hoy creamos una app de notas en SwiftUI con 3 funcionalidades: crear nota, actualizar nota y borrar nota.

SwiftBeta

Tabla de contenido


👇 SÍGUEME PARA APRENDER SWIFTUI, SWIFT, XCODE, etc 👇
Aprende a testear apps en Swift y SwiftUI
Aprende a testear apps en Swift y SwiftUI

Hoy en SwiftBeta vamos a crear nuetra aplicación de Notas usando SwiftUI. Vas a ver que es un video muy sencillo en el que vamos a crear 3 funcionalidades:

  • Crear notas
  • Actualizar nota
  • Eliminar nota

Creamos proyecto en Xcode

Lo primero de todo que vamos hacer es abrir Xcode 15, ya que vamos a usar el nuevo framework de base de datos llamado SwiftData. Creamos el proyecto y le vamos a dar el nombre de Notas, a parte muy importante marcar en Interface SwiftUI, y en Storage SwiftData. En esta primera parte nos vamos a centrar en crear la aplicación y así en el próximo video nos centraremos en los Tests Unitarios, y poco a poco iremos explorando temas super interesantes de los Tests. Desmarcamos la opción de Include Tests (esto lo haremos manualmente en el siguiente video)

Creamos el proyecto y aparece la vista ContentView que es la vista por defecto que siempre nos crea Xcode. Fíjate que cuando hemos creado el proyecto, hemos especificado que queríamos usar SwiftData y es por eso que al crear el proyecto, Xcode nos ha añadido el código para mostrar un listado de items (es código de ejemplo), si vamos al Canvas y pulsamos en el + vemos automáticamente como se van añadiendo elementos (estos elementos que aparecen en el Canvas están en memoria, es decir no se están persistiendo en la base de datos. Podemos ir a la preview, justo abajo del todo y ver como el container está en memoria, es decir no se están persistiendo los elementos del Canvas, cualquier cambio que haga en la Preview eliminará todos los datos. Si añadi un enter justo debajo de ContentView, vemos que la Preview se refresca y los datos que había han desparecido, esto te lo comento porque es un enfoque muy interesante para los tests que veremos en los próximos videos).

Creamos Modelo Notes

Vamos a continuar, nuestro propósito del video es crear una app de notas para poderla testear con nuestro Test Unitarios, lo primero de todo que necesitamos es el modelo Note, este modelo va a contener toda la información necesaria de nuestra nota, prácticamente es lo básico para el correcto funcionamiento de nuestra app. Así que creamos una struct llamada Note, pulsamos COMMAND+N y añadimos los siguientes campos:

struct Note: Identifiable, Hashable {
    let id: UUID
    let title: String
    let text: String?
    let createdAt: Date
}

También, voy a crear un helper para retornarme el texto de la nota:

var getText: String {
    text ?? ""
}

Y finalmente voy a crear un inicializador:

init(id: UUID = UUID(), title: String, text: String?, createdAt: Date) {
    self.id = id
    self.title = title
    self.text = text
    self.createdAt = createdAt
}

ModelEste inicializador lo llamaremos cuando creemos nuestra primera nota, y será en los próximos minutos. Para dejarlo más ordenado, vamos a añadir nuestro nuevo tipo Note en una carpeta llamada Model

Creamos el ViewModel

Lo siguiente que vamos hacer es crear el ViewModel, este va a ser el encargado de tener toda esta lógica de implementación, es decir lo llamaremos para que hagas las operaciones de crear nota, actualizar nota y eliminar nota. Lo que vamos hacer es pulsar COMMAND+N y vamos a crear un fichero llamado ViewModel. Dentro de este fichero vamos a crear una propiedad llamada notes.

class ViewModel {
    var notes: [Note]
    
    init(notes: [Note] = []) {
        self.notes = notes
    }
}

Lo siguiente va a ser crear los 3 métodos que te comentaba, crear, actualizar y eliminar nota. Vamos a empezar por el de crear nota:

func createNoteWith(title: String, text: String) {
    let note: Note = .init(title: title,
                           text: text,
                           createdAt: .now)
    notes.append(note)
}
    

Aquí creamos una instancia de Note, (fíjate que estamos usando el inicializador que hemos creado hace un momento) y a continuación lo añadimos al array de notas. Muy sencillo, vamos a ver el siguiente método, el método de actualizar una nota:

func updateNoteWith(id: UUID, newTitle: String, newText: String?) {
    if let index = notes.firstIndex(where: { $0.id == id }) {
        let updatedNote = Note(id: id, title: newTitle, text: newText, createdAt: notes[index].createdAt)
        notes[index] = updatedNote
    }
}

De todo el Array de notas, buscamos la que queremos modificar y añadimos los nuevos valores. Y una vez añadidos actualizamos el Array notes del ViewModel para que esté actualizado.

Vamos a ver el tercer y último método, el método que nos va a servir para eliminar una nota:

func removeNoteWith(id: UUID) {
    notes.removeAll(where: { $0.id == id })
}

Este método es el más sencillo ya que buscamos la nota dentro del Array y la eliminamos.

Ya casi hemos acabado con nuestro modelo, falta un último detalle. Todos los cambios que hagamos a nuestro array notes, queremos que sean escuchados desde la vista. Para hacerlo vamos a conformar el ObservableObject en nuestro ViewModel y el Property Wrapper @Published en nuestra propiedad notes, de esta manera cada cambio que ocurra en nuestra propiedad notes será escuchado desde la vista, y por lo tanto se actualizará para mostrar el Array, el listado de notas actualizado.

class ViewModel: ObservableObject {
    @Published var notes: [Note]
    
    init(notes: [Note] = []) {
        self.notes = notes
    }
...
}

Pero esto es como se hacía antes, ahora a partir de Xcode 15 ha habido una mejora y es que podemos omitir mucho código. Ahora solo necesitamos usar la macro @Observable encima de nuestro ViewModel. Para que el compilador deje de quejarse, debemos importar un nuevo framework llamado Observation. Y al hacerlo, podemos borrar el protocolo ObservableObject y el property wrapper @Published (quería enseñarte esta nueva mejora), ya podemos continuar.

Actualizamos ContentView

Una vez hemos creado el Modelo que va a representar nuestra nota, y el ViewModel, lo siguiente que vamos hacer es actualizar la vista ContentView para mostrar las notas que se vayan creando.

Vamos a la vista ContentView y eliminamos todo el código hasta que tengamos el siguiente esqueleto:

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                ForEach(items) { item in
                    
                }
            }
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Item.self, inMemory: true)
}

Y ya que estamos, vamos a borrar el fichero Item, y también si vamos a NotasApp, eliminamos el ViewModifier .modelContainer(for: Item.self). Y una vez hecho esto, volvemos a ContentView, ahora arreglaremos el error, en unos segundos. Dentro de esta vista, necesitamos una instancia del ViewModel para poder acceder al Array notes y así listarlo en nuestro List. Así que lo siguiente que vamos hacer es crear una propiedad llamada ViewModel, de tipo ViewModel y la vamos a instanciar.

struct ContentView: View {
    var viewModel: ViewModel = .init()
    ...
}

Una vez hemos instanciado el ViewModel, ya podemos acceder a su propiedad notes, la vamos a reemplazar por items. Y por cada nota, vamos a crear su vista, pongo el modo rápido y luego lo explico:

struct ContentView: View {
    var viewModel: ViewModel = .init()
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(viewModel.notes) { note in
                    NavigationLink(value: note) {
                        VStack(alignment: .leading) {
                            Text(note.title)
                                .foregroundStyle(.primary)
                            Text(note.getText)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
            }
        }
    }
}

Muy sencillo, por cada nota, hemos creado un VStack y dentro del VStack hay un text para el título de la nota y otro text para el texto de la nota. Vamos a ver cómo se vería en el Canvas, para ellos vamos abajo del todo y en la sección de la preview vamos a inyectarle 2 notas:

#Preview {
    ContentView(viewModel: .init(notes: [
        .init(title: "SwiftBeta", text: "Texto 1", createdAt: .now),
        .init(title: "SwiftBeta 2", text: "Texto 2", createdAt: .now)
    ]))
}

Ahora en el Canvas deberíamos ver cómo queda. Deberían aparecer 2 notas en nuestro List. Ya que hemos inyectado un Array inventado por nosotros de tipo Note, para poderlo ver en el Canvas. Otra palabra importante muy usada en el Testing, inyección. Aquí estamos inyectando una dependencia que necesita el ViewModel para poder compilar.

Una vez hemos llegado hasta aquí, ahora necesitamos una Vista para poder crear nuestras notas. Es decir, vamos a crear una vista con un formulario muy sencillo para poder crear una nota y almacenarla en el Array de notas de nuestro ViewModel.

Creamos CreateNoteView

Pulsamos COMMAND+N y creamos una nueva vista en SwiftUI llamada CreateNoteView. Una vez creada la vista, vamos a añadir las siguiente propiedades:

struct CreateNoteView: View {
    var viewModel: ViewModel
    @State var title: String = ""
    @State var text: String = ""
    
    @Environment(\.dismiss) private var dismiss
...
}
  • Creamos una propiedad para tener una referencia del ViewModel. Esta referencia se la pasaremos en unos minutos desde ContentView
  • title y text lo usaremos en nuestro Form que vamos a crear ahora
  • Y el Property Wrapper Environment lo usaremos para dismisear la vista una vez creamos una nueva nota. De esta manera volvemos al listado y vemos la nueva nota creada.

Lo siguiente que vamos a añadir es un NavigationStack con un Form dentro. El Form va a tener una única sección con un footer:

struct CreateNoteView: View {
    var viewModel: ViewModel
    @State var title: String = ""
    @State var text: String = ""
    
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        NavigationStack {
            Form {
                Section {
                    TextField("", text: $title, prompt: Text("*Título"), axis: .vertical)
                    TextField("", text: $text, prompt: Text("Texto"), axis: .vertical)
                } footer: {
                    Text("*El título es obligatorio")
                }
            }
            .navigationTitle("Nueva Nota")
            .navigationBarTitleDisplayMode(.large)
        }
    }
}

Al añadir el Form vamos a verlo en el Canvas, pero primero debemos arreglar la preview. Añadimos valores por defecto a nuestras propiedades:

#Preview {
    CreateNoteView(viewModel: .init(),
                   title: "",
                   text: "")
}

Aquí vemos perfectamente en el Canvas el Formulario con 2 campos. Uno para el título y otro para el texto de la nota. Si escribimos vemos como el campo de texto se hace más grande.

Para finalizar con nuestra vista, vamos a añadir una toolbar con 2 ToolBarItems. Uno nos va a servir para cerrar la vista de Crear Note, y el otro nos va a servir para Crear la nota. Así que debajo de las llaves que cierran el Form añadimos el toolbar el siguiente toolbar:

.toolbar {
    ToolbarItem(placement: .topBarLeading) {
        Button {
            dismiss()
        } label: {
            Text("Cerrar")
        }
    }
    ToolbarItem {
        Button {
            viewModel.createNoteWith(title: title,
                                     text: text)
            dismiss()
        } label: {
            Text("Crear Nota")
                .bold()
        }
    }
}

Al añadir el toolbar, vemos en la Preview del Canvas como ha quedado nuestra vista. Un formulario con dos campos, y 2 buttons, uno para cerrar la vista y otro para crear la nota.

Añadimos CreateNoteView a ContentView

Lo siguiente que vamos hacer es conectar la vista que acabamos de crear, la de CreateNoteView, con ContentView. Volvemos a ContentView y aquí vamos a crear una nueva propiedad llamada showCreateNote de tipo Bool, y le vamos a dar el valor inicial de false.

@State var showCreateNote: Bool = false

A continuación, vamos a añadir un toolbar en esta vista también. De esta manera podremos añadir un Button para poder lanzar la acción de mostrar la vista CreateNoteView, pero esta vez vamos a añadir el Button en la parte inferior de la pantalla. Nos posicionamos debajo del List y añadimos el siguiente código:

.toolbar {
    ToolbarItem(placement: .status) {
        Button(action: {
            showCreateNote.toggle()
        }, label: {
            Label("Crear Nota", systemImage: "square.and.pencil")
                .labelStyle(TitleAndIconLabelStyle())
        })
        .buttonStyle(.bordered)
        .tint(.blue)
        .bold()
    }
}

Para que quede mejor nuestra vista ContentView, vamos a añadir un title a nuestra navigation, debajo de la toolbar usamos el modificador navigationTitle:

.navigationTitle("Notas")

Y por último, vamos a usar el ViewModifier fullScreenCover para que cuando el valor de nuestra propiedad showCreateNote sea modificado a true, se muestre la vista CreateNoteView, así que debajo de navigationTitle, añadimos el siguiente código:

.fullScreenCover(isPresented: $showCreateNote, content: {
    CreateNoteView(viewModel: viewModel)
})

Ahora nuestra aplicación ya está lista para que creemos notas y se vayan listando en ContentView. Vamos a compilar y vamos a probarlo en el simulador, vemos que si añadimos notas, éstas se muestran en nuestro listado.

Antes de crear los tests necesarios, vamos a crear una última vista llamada UpdateNoteView

Creamos vista UpdateNoteView

Esta vista nos va a servir para 2 cosas, para actualizar los valores de nuestra nota, y la segunda para eliminar una nota.

Pulsamos COMMAND+N y creamos la vista UpdateNoteView. Vamos a reutilizar mucho código del que hemos creado en CreateNoteView, así que copiamos el contenido de CreateNoteView y lo pegamos en nuestra nueva vista.

Vamos hacer las siguiente modificaciones:

  • Añadimos una nueva propiedad justo debajo de la propiedad viewModel (let id: UUID). Esta propiedad nos va a servir para saber qué nota buscar para actualizar con el nuevo valor.
  • Cambiamos NavigationStack por VStack
  • Eliminamos el navigationBarTitleDisplayMode
  • Modificamos texto del navigationTitle por Modificar Nota
  • Borramos el ToolbarItem de Cerrar, ya no lo necesitamos ya que vamos hacer una navegación Push
  • En la Preview del Canvas inyectamos los valores para que el compilador esté contento
#Preview {
    NavigationStack {
        UpdateNoteView(viewModel: .init(),
                       id: .init(),
                       title: "SwiftBeta 2",
                       text: "jdasdjkdsh asuid hsiu hasui dhasui rheqwss eissaiuhdsdaisuoh iu sadh diuasdhdsaui huidsah sudihdiuse")
    }
}

Al ver la preview vemos algunas vistas que no tienen sentido, vamos a:

  • Borrar el footer de la Section
  • Debajo del VStack añdimos el viewModifier background (.background(Color(uiColor: .systemGroupedBackground)))
  • Debajo del ViewModifier background, movemos el ViewModifier toolbar y aquí vamos a modificar la llamada del ViewModel y el título del Button
.toolbar {
    ToolbarItem {
        Button {
            viewModel.updateNoteWith(id: id, newTitle: title, newText: text)
            dismiss()
        } label: {
            Text("Guardar")
                .bold()
        }
    }
}
.navigationTitle("Modificar Nota")

Y por último, debajo del Form añadimos el Button para borrar la nota:

Button(action: {
    viewModel.removeNoteWith(id: id)
    dismiss()
}, label: {
    Text("Eliminar Nota")
        .foregroundStyle(.gray)
        .underline()
})
.buttonStyle(BorderlessButtonStyle())
Spacer()

Ahora solo nos falta conectar esta nueva vista con ContentView.

Conectamos UpdateNoteView con ContentView

Volvemos a la vista ContentView, y ahora queremos que al pulsar en una Nota, naveguemos a la vista UpdateNoteView para poderla modificar o eliminar del Array de Notes de nuestro ViewModel.

Justo debajo del ViewModifier navigationTitle, añadimos el siguiente código:

.navigationDestination(for: Note.self) { note in
    UpdateNoteView(viewModel: viewModel, id: note.id, title: note.title, text: note.getText)
}

Con este paso hemos añadido la parte final antes de centrarnos en los Tests. Si ahora compilamos podemos ver cómo nuestra app funciona perfectamente. Es decir, podemos crear notas, editarlas y eliminarlas. Vamos a probar que todas estas funcionalidades realmente funcionan.

Te habrás dado cuenta de que de momento nuestra app no tiene persistencia, añadiremos el framework SwiftData en futuros videos (e incluso te enseñaré a cómo testear una app con base de datos), de momento para los Tests Unitarios no la necesitamos. Y ahora te pregunto, ¿cómo sabes que tu aplicación funciona como debería? Ahora para comprobar que nuestra app puede crear notas, modificarlas y borrarla, hemos tenido que hacerlo de forma manual. Imagina que tu aplicación tuviera cientos de funcionalidades diferentes ¿tendrías que probarlas todas a mano consumiéndote muchísimo tiempo? no, ahí es otra ventaja de tener tests automatizados, y en el siguiente video crearemos Tests Unitarios

Para que quede más ordenado, vamos a crear una carpeta Views en nuestro listado de ficheros, y vamos a incluir CreateNoteView y UpdateNoteView. De esta manera tenemos una clara separación entre Views, y Model.

Conclusión

Hoy hemos creado la aplicación principal que nos servirá para crear muchos y diferentes tipos de tests. La idea es cubrir el comportamiento más importante de nuestra aplicación y aportar calidad con nuestro tests, intentando descubrir lo antes posible errores o crashes.

Y hasta aquí el video de hoy!