Aprende a crear tus primeros tests unitarios en Swift
Aprende a crear tus primeros tests unitarios en Swift

Introducción a UNIT TESTS y CODE COVERAGE en SWIFT (Parte 3)

Los Tests Unitarios en Swift nos sirven para testear el correcto funcionamiento de nuestro tipo. En este caso queremos hacer tests pequeños, independientes y que sean rápidos de ejecutar. En el post de hoy testeamos nuestro modelo y también nuestro ViewModel

SwiftBeta

Tabla de contenido


👇 SÍGUEME PARA APRENDER SWIFTUI, SWIFT, XCODE, etc 👇

Hoy en SwiftBeta vamos a crear nuestros primeros Tests Unitarios en Swift. En el anterior video creamos una aplicación en SwiftUI muy útil para crear notas. Utilizamos la arquitectura MVVM, y dentro del ViewModel creamos diferentes métodos para:

  • Crear notas
  • Actualizar notas
  • Eliminar notas

Hoy vamos a testear nuestro modelo y nuestro ViewModel para comprobar que tiene el comportamiento correcto. Pero, ¿cómo podemos hacerlo? hasta ahora en el canal hemos visto a como crear código de producción dentro de nuestro Target. Código de producción es el código que acaba siendo empaquetado en nuestra aplicación y subido al App Store. Podría decirse que es el código que acaba usando los usuarios que se descargan nuestra aplicación del App Store.

Creamos Test Target: NotasTests

Para crear el código de Test de nuestra aplicación debemos crear un nuevo Target. Si te acuerdas, cuando creamos el proyecto en el anterior video no marcamos la opción de testing. Pero si no seguiste este mismo paso en el anterior video, te debería apartece el target de test ya creado. Vamos a continuar, nos vamos a File -> New -> Target y aquí vamos al buscador y buscamos Test. En este caso vemos 2 resultados, para este video vamos a crear un nuevo target de Unit Testing Bundle, para uno de los próximos videos de esta serie haremos lo mismo para UI Testing Bundle.

Seleccionamos Unit Testing Bundle y le damos a Next. En este caso vamos a usar el nombre que nos ha sugerido Xcode, NotasTests (que es el nombre de nuestra app seguido de Tests), nos fijamos que todo esté marcado tal y como yo lo tengo y le damos a finish.

Una vez creado, vemos que en el listado de ficheros aparece una nueva carpeta llamada NotasTests, y dentro hay un fichero. Piensa en este nuevo target como una sección nueva dentro de tu desarrollo pero que está separada de tu código de producción. Es decir, el código que crea tu app está aquí arriba, y el código que testea tu app está aquí abajo. Cuando nosotros creamos una aplicación y generamos el archivo final que se acaba subiendo al App Store, en ese archivo está todo el contenido de producción y no hay nada de tests. Esto te lo quería comentar para que veas que el código de testing nunca se empaqueta con el código de producción (código de producción es el código que se crea para crear la aplicación y que al final es el que acaban usando tus usuarios. No tendría sentido incluir los tests en el empaquetado final de tu aplicación)

Curso de Testing en Swift
Curso de Testing en Swift

Estructura de un XCTestCase

Vamos a continuar, si abres el fichero de NotasTests vemos varios puntos interesantes:

  • Importamos el framework XCTest, este framework de Apple nos permite acceder a todas las clases y funciones necesarias para escribir el y ejecutar tests.
  • Después vemos una clase llamada NotasTests, que hereda de XTestCase. Y ¿qué heredamos? En este caso los métodos setupWithError y teardownWithError. Estos dos métodos tal y como indican aquí, nos sirven para ser invocados antes y después de cada test. Están integrados dentro del ciclo de vida de un test, es decir, antes de ejecutar un test se llama el método setupWithError, y esto nos permite inicializar nuestro test, dar unos valores iniciales. Y cuando un test a acabado de ejecutarse, automáticamente se llama el siguiente método teardownWithError, este método nos sirve para limpiar o restablecer cualquier estado que se haya configurado durante la prueba.

    ¿No acaba de quedar claro? Te hago un pequeño spoiler, en el próximo video usaremos SwiftData para persistir los datos dentro de nuestra aplicación. Dentro del setupWithError crearemos nuestra base de datos, entonces se ejecutará un test para verificar y comprobar que se insertan correctamente las notas en la base de datos, y antes de pasar al siguiente tests, debemos limpiar el estado de nuestra base de datos (ya que lo test unitarios deben ser independientes, no deben depender unos de otros), entonces llamaremos al método setupWithError para limpiar la base de datos y eliminar todas las notas. De esta manera el siguiente test estará listo para ejecutarse.

    En realidad te he explicado este ejemplo, pero cuando creemos los Test de Integración en el próximo video, seremos más inteligentes y crearemos una base de datos en memoria (no persistiremos nada en disco). Vamos a continuar.
  • El siguiente método que vemos, es el test que queremos ejecutar. Es decir, aquí dentro irá la comprobación que queremos hacer para saber que nuestro código se comporta tal y como queremos. Lo veremos en los próximos minutos, pero un ejemplo es testear nuestro ViewModel para comprobar que cada vez que creamos una nota, la nota es insertada correctamente en el Array notes, en la propiedad notes de nuestro ViewModel.
  • Y el último método lo podemos borrar ya que no vamos a tenerlo en cuenta en la serie de videos sobre testing.

Pasamos nuestro primer Test vacío

Ya que estamos aquí, fíjate que tenemos un único test que podemos lanzar. Aunque está vacío, vamos a lanzarlo. Para hacerlo es tan sencillo como clickar el icono que aparece justo al lado del método.

Si pulsamos, vemos que aparece en verde indicando que el test ha pasado. Incluso vemos en verde el tipo NotasTests indicando que todos sus tests de esta suite han pasado.

Pero ¿cómo vemos que un test ha fallado? Voy a crear un test que va a fallar siempre que se ejecute solo para ver la diferencia. Creo el método textFailExample()

func testFailExample() {
    XCTFail()
}

Si ahora lanzo el test, vamos a ver qué ocurre. En este caso pulsamos en el icono que aparece al lado de nuestro nuevo test.

Al hacerlo vemos que el test falla (ya que he puesto una instrucción específica para que falle cada vez que se ejecute). En este caso vemos que también aparece el icono de error al lado de nuestro tipo NotasTests indicando que algún test de nuestra suite ha fallado.

Ya hemos visto la representación visual que nos da Xcode para saber cuando un test pasa y cuando un test falla. Antes de continuar, si queremos lanzar todos los tests de golpe, podemos pulsar el icono que aparece justo al lado del tipo NotasTests o en este caso podemos pulsar el atajo de teclado COMMAND+U

Creamos nuestro primer Unit Test

Una vez hemos entendio la estructura de nuestro Test y hemos aprendido a como lanzarlos, vamos a crear un Test que nos va a permitir verificar que la struct Note hace lo que tiene que hacer. Serán unos tests muy sencillos, pero vamos a ir viendo más conceptos interesantes sobre Testing.

Lo primero que vamos hacer es renombrar el tipo, en lugar de llamarse NotasTests, lo vamos a llamar NoteTest, ya que note es el tipo que vamos a testear. De esta manera nos va ayudar en el futuro a identificar y buscar este Test (y ya que estamos modificamos el name del fichero).

A continuación, voy a borrar todo el contenido de NoteTests y vamos a crear nuestro primer test. Vamos a validar que cuando inicializamos Note, el contenido de Note sea el que esperamos. es decir, que el title sea correcto, el text y la fecha.

Voy a llamar a mi test testNoteInitialization(), preparemos el Test

func testNoteInitialization() {
    let title = "Test Title"
    let text = "Test Text"
    let date = Date()
}

Una vez tenemos los datos creamos la Nota:

func testNoteInitialization() {
    let title = "Test Title"
    let text = "Test Text"
    let date = Date()
    
    let note = Note(title: title, text: text, createdAt: date)
}

Y una vez está creada la nota, lo que hacemos es comprobar que el contenido de sus propiedades es el que esperamos. Pero ¿cómo lo hacemos? usamos unas funciones que nos permiten comprobarlo. En este caso vamos a usar la función XCTAssertEqual, pero hay muchas más. Puedes pulsar COMMAND+CLICK para ver otros ejemplos como XCTAssertFalse, GreatherThan, LeassThan, Nil, etc. Para usar XCTAssertEqual tan solo debemos poner en el primer parámetro un valor y en el segundo otro valor, y lo que hará la función es comparar para ver si son iguales. En caso de ser iguales la comprobación será correcta y en caso de ser diferentes la comprobación fallará, vamos a verlo con las propiedades de Note:

func testNoteInitialization() {
    let title = "Test Title"
    let text = "Test Text"
    let date = Date()
    
    let note = Note(title: title, text: text, createdAt: date)
    
    XCTAssertEqual(note.title, title)
    XCTAssertEqual(note.text, text)
    XCTAssertEqual(note.createdAt, date)
}

Fíjate que nuestro tests está bien diferenciado en 3 partes diferentes. Hemos seguido una técnica llamada Given, When and Then o también llamado Arrange, Act and Assert. Vamos a verlo:

func testNoteInitialization() {
    // Given or Arrange
    let title = "Test Title"
    let text = "Test Text"
    let date = Date()
    
    // When or Act
    let note = Note(title: title, text: text, createdAt: date)
    
    // Then or Assert
    XCTAssertEqual(note.title, title)
    XCTAssertEqual(note.text, text)
    XCTAssertEqual(note.createdAt, date)
}

Given o Arrange, es la parte de nuestro Test donde preparamos los datos. El estado inicial

When o Act, es la parte de nuestro Test donde ocurre la acción. En este caso estammos creando una instancia de Note.

Then o Assert, es la parte de nuestro Test donde se especifica el resultado o cambio esperado de la acción o evento. En nuestro caso comprobar que las propiedades de Note han sido correctamente seteadas.

Estructurar los tests de esta manera nos ayuda a comprenderlos mejor. A tener todos nuestro código con unos pasos lógicos y claros.

@testable import Notas

Antes de pasar nuestro Test, tenemos que corregir el error que nos está dando el compilador. Este error es completamente normal ya que se está quejando del tipo Note, no lo puede encontrar en el Scope de Testing ya que pertenece al Scope de la aplicación principal. Como te decía estos 2 targets son dos secciones diferentes, por lo tanto tenemos que permitir que nuestro Target de Testing pueda ver los tipos de nuetra aplicación, o lo que es lo mismo que NotasTests pueda ver los tipos de Notas. Para hacerlo vamos a utilizar el siguiente import:

import Notas

Pero, ¿qué ocurre? en el caso de importar la app principal en nuestro target de Tests es diferente. En este caso debemos especificar @testable, para que nos permita acceder a los access levels de tipos que son internal de nuestra aplicación Notas.

@testable import Notas

Al hacerlo vemos que el error del compilador desaparece. Vamos a lanzar nuestro primer test, damos al button que está al lado de la firma de nuestro test.

Y el resultado es Test Succeed

Aquí podemos escoger cualquier simulador que queramos. Es decir, es indiferente si escogemos un iPhone 12, 15, o 20.

Ejemplo, cambios init de Note hacen fallar los Tests

Fíjate también que aparecen unos logs en la consola de Xcode indicando cuándo empieza el tests, si pasa o no, y cuánto dura la ejecución de cada test. También podemos ver en una de las secciones del panel izquierdo de Xcode, un listado de todos los tests y su resultado (la sección que es un rombo con un check). Esta sección es muy útil cuando nuestra aplicación tiene cientos o miles de tests, abajo tenemos un buscador y podemos buscar tests con nombres específicos.

Aquí podrías pensar, pero ¿qué sentido tiene testear la creación de una nota? Por muy sencillo que parezca, imagina que en un futuro haces un cambio en el init.

Imagina que ahora en tu init tu o un compañero de trabajo, viene y concatena el siguiente valor, es decir cambia su implementación:

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

Ahora si intentamos pasar el test vamos a ver un error. Pulsamos COMMAND+U y efectivamente vemos un error. El test nos indica que la comprobación ha fallado ya que Title: Test Title, no es igual a Test Title.

Nosotros podemos mejorar y mostrar más información de por qué ha fallado el Test. Podemos añadir otro parámetro a la función XCTAssertEqual para mostrar un texto más descriptivo:

XCTAssertEqual(note.title, title, "Title should be equal to Test Title")

Si ahora volvemos a pasar el Test, pulsamos COMMAND+U, vemos que el mensaje aparece en el error.

Volvemos a Note y vamos a dejar el init como lo teníamos, borramos la concatenación de Strings.

Antes de testear el ViewModel, vamos a crear un último test en Note. Quiero comprobar que si una nota no tiene texto, al llamar a getText obtengo una cadena vacía. En este momento quiero que pares el video y pienses como crearías este test justo debajo del que acabamos de crear.

Creamos nuevo Test en Note testNoteEmptyText()

Primero vamos a darle un nombre a nuestro test, lo vamos a llamar textNoteEmptyText(), y dentro del test vamos a crear las 3 partes que te mencionaba antes, vamos a dividir el test en Given, When, Then.

func testNoteEmptyText() {
    // Given
    let title = "Test Title"
    let date = Date()
    
    // When
    let note = Note(title: title, text: nil, createdAt: date)

    // Then
    XCTAssertEqual(note.getText, "")
}

No es obligatorio seguir estos pasos, pero te ayudarán a estructurar mejor tus tests. Aquí lo que estamos haciendo es que si inicializo una Note pasándole el parámetro text a nil. Cuando llame a la propiedad computada getText debe retornar cadena vacía. Vamos a pasar este test y vemos que pasa correctamente.

Una nota importante, si pulsamos COMMAND+U lanzamos todos los tests de la suite de NoteTests, es decir todas las funciones que empiezan por test. Esto es interesante, qué ocurre si creo un método que no empieza por test? vamos a eliminar test de testNoteEmptyText y guardamos. Fíjate que el button que aparecía al lado de la firma ha desparecido, Xcode detecta todos aquellos métodos que empiezan por test para detectar que es un test y por lo tanto que lo deben de ejecutar, por eso es muy importante que todos tus tests empiezen por test (si lo añades al final no funciona)

Vamos a dejarlo como lo teníamos y vamos ver cómo testeamos nuestro ViewModel.

Creamos ViewModelTests

Una vez hemos aprendido conceptos básicos sobre testing, ahora vamos a crear un nuevo fichero para testear nuestro ViewModel. Pulsamos COMMAND+N y buscamos y seleccionamos Unit Test Case Class. Vamos a llamar a nuestro nuevo fichero ViewModelTests, y muy importante que sea una subclase de XCTestCase, le damos a Next y creamos el nuevo fichero justo en el mismo target que NoteTests (Fíjte que está marcado en la sección de Targets NotasTests).

Una vez creado el nuevo fichero, voy a eliminar las 2 últimas funciones y vamos a nuestro ViewModel. Aquí tenemos 3 funciones y cada función tiene su propia implementación. Vamos a testear que el comportamiento de estas 3 funciones corresponde con el comportamiento esperado, el primer comportamiento que vamos a testear es que al crear una nueva nota, esta nota se añade al Array notes de nuestro ViewModel. ¿Por qué testeamos por funciones? En este caso queremos testear comportamientos que tienen un scope reducido, como crear una nota, actualizar nota, y borrar nota. Cuánto más pequeño sea un test mucho mejor.

Volvemos a ViewModelTests y vamos a crear nuestro tests. Cada vez que se ejecute un test, quiero tener una instancia limpia de ViewModel, de esta manera todo estado anterior será borrado y esto no me ocasionará problemas en la ejecución de mis tests.

Lo primero de todo, creo una propiedad llamada viewModel de tipo ViewModel y la instancio en setupWithError:

final class ViewModelTests: XCTestCase {
    var viewModel: ViewModel!

    override func setUpWithError() throws {
        viewModel = ViewModel()
    }

    override func tearDownWithError() throws { }
}

En este momento tenemos un error, mi test no está encontrando el tipo ViewModel. Vamos a importar la aplicación principal como hemos visto hace unos minutos:

@testable import Notas

Perfecto, el error ha desparecido. Lo siguiente que vamos hacer es crear el nombre de mi test, lo voy a llamar testCreateNote:

func testCreateNote() {
    // TODO:
}

Aquí dentro vamos a seguir la estructura de Given, When, Then, primero necesitamos tener los datos y luego llamar al viewModel para crear la nota:

func testCreateNote() {
    // Given
    let title = "Test Title"
    let text = "Test Text"

    // When
    viewModel.createNoteWith(title: title, text: text)
    
    // Then
    ...
}

En este paso, qué asserts añadimos para comprobar que el comportamiento de crear la nota es el que esperamos? Muy sencillo, al crear la nota podemos comprobar ciertas acciones que han ocurrido:

  • Podemos comprobar que el número de elementos del Array de Notes es 1. Al crear una instancia de cada ViewModel en cada tests, podemos estar tranquilos que ningún otro tests esté interfiriendo en el nuestro, ya que empezamos de un estado en que nuestro ViewModel no tiene ninguna nota.
  • Una vez creada la nota, podemos extraer el valor de la primera y única nota y compararla con el valor que esperamos encontrar en el title y text

Pues una vez lo tenemos claro, vamos a hacerlo, y nuestro tests quedaría de la siguiente manera:

func testCreateNote() {
    let title = "Test Title"
    let text = "Test Text"
    
    viewModel.createNoteWith(title: title, text: text)

    XCTAssertEqual(viewModel.notes.count, 1)
    XCTAssertEqual(viewModel.notes.first?.title, title)
    XCTAssertEqual(viewModel.notes.first?.text, text)
}

Vamos a lanzar nuestro tests a ver si passa. Y efectivamente funciona, perfecto.

Ahora por ejemplo podríamos hacer lo mismo y crear 3 notas para ver si es el comportamiento que esperamos:

func testCreateThreeNotes() {
    // Given
    let title1 = "Test Title 1"
    let text1 = "Test Text 1"
    
    let title2 = "Test Title 2"
    let text2 = "Test Text 2"
    
    let title3 = "Test Title 3"
    let text3 = "Test Text 3"
    
    // When
    viewModel.createNoteWith(title: title1, text: text1)
    viewModel.createNoteWith(title: title2, text: text2)
    viewModel.createNoteWith(title: title3, text: text3)

    // Then
    XCTAssertEqual(viewModel.notes.count, 3)
    XCTAssertEqual(viewModel.notes.first?.title, title1)
    XCTAssertEqual(viewModel.notes.first?.text, text1)
    XCTAssertEqual(viewModel.notes.last?.title, title3)
    XCTAssertEqual(viewModel.notes.last?.text, text3)
}

En lugar de comprobar el valor de todas las notas, por simplicidad he comprobado el primer y último valor. Vamos a lanzar el tests para ver si se comporta tal y como esperamos. Y funciona.

Muy bien ya tenemos 2 tests creados, ahora volvemos al ViewModel y vamos a ver el siguiente método que queremos testear su comportamiento. Ahora vamos a testear que el método de actualizar una nota funciona correctamente, es decir, dentro de nuestra aplicación podemos clickar en una nota que ya existe para modifier el valor del title y text, y al guardar vemos que los datos se han actualizado.

Perfecto, vamos a nuestro ViewModeTests y vamos a crear un nuevo método llamado testUpdateNote, dentro de este tests vamos a crear la parte de Given

func testUpdateNote() {
    // Given
    let title = "Test Title"
    let text = "Test Text"
    
    viewModel.createNoteWith(title: title, text: text)
    
    let newTitle = "New Title"
    let newText = "New Text"
    ...
}

En este caso hemos creado una nota, y hemos proporcionado los datos de los nuevos valores. En este caso vamos a dar el valor de newTitle y newText. Ahora vamos hacer la siguiente parte, la parte del when, que es justo cuando llamamos al método update de nuestro viewModel:

func testUpdateNote() {
    // Given
    let title = "Test Title"
    let text = "Test Text"
    
    viewModel.createNoteWith(title: title, text: text)
    
    let newTitle = "New Title"
    let newText = "New Text"
    
    if let id = viewModel.notes.first?.id {
        // When
        viewModel.updateNoteWith(id: id, newTitle: newTitle, newText: newText)
        ...
    } else {
        XCTFail("No note was created.")
    }
}

Y una vez hemos actualizado el valor, cómo sabemos que el método ha realizado la modificación de valores de nuestra nota? vamos a comprobarlo en la sección del Then con los asserts:

func testUpdateNote() {
    // Given
    let title = "Test Title"
    let text = "Test Text"
    
    viewModel.createNoteWith(title: title, text: text)
    
    let newTitle = "New Title"
    let newText = "New Text"
    
    if let id = viewModel.notes.first?.id {
        // When
        viewModel.updateNoteWith(id: id, newTitle: newTitle, newText: newText)
        
        // Then
        XCTAssertEqual(viewModel.notes.first?.title, newTitle)
        XCTAssertEqual(viewModel.notes.first?.text, newText)
    } else {
        XCTFail("No note was created.")
    }
}

Pulsamos COMMAND+U y vamos a ver si pasan todos los tests. Perfecto! podemos continuar con el último método, volvemos a nuestro ViewModel y vamos a ver qué lógica nos falta por comprobar. En este caso es la lógica de borrar una nota, vamos a nuestro ViewModelTests y creamos un nuevo test llamado testRemoveNote(), y creamos el escenario incial de que tenemos una creada:

func testRemoveNote() {
    // Given
    let title = "Test Title"
    let text = "Test Text"
    
    viewModel.createNoteWith(title: title, text: text)

Lo siguiente que vamos hacer es obtener la nota que acabamos de añadir para llamar al método remove del ViewModel:

func testRemoveNote() {
    // Given
    let title = "Test Title"
    let text = "Test Text"
    
    viewModel.createNoteWith(title: title, text: text)
    if let id = viewModel.notes.first?.id {
        // When
        viewModel.removeNoteWith(id: id)
    } else {
        XCTFail("No note was created.")
    }
}

Y lo siguiente vamos a comprobar que la nota se ha borrado del Array de notas del ViewModel:

func testRemoveNote() {
    // Given
    let title = "Test Title"
    let text = "Test Text"
    
    viewModel.createNoteWith(title: title, text: text)
    if let id = viewModel.notes.first?.id {
        // When
        viewModel.removeNoteWith(id: id)
        // Then
        XCTAssertTrue(viewModel.notes.isEmpty)
    } else {
        XCTFail("No note was created.")
    }
}

Pulsamos COMMAND+U y comprobamos que todos los tests pasan. En este caso hemos probado tests unitarios por cada método de nuestro ViewModel, hemos probado porciones pequeñas de nuestro código. Has creado tus primeros 4 Tests Unitarios, ¿a que no ha sido tan dificil?

¿Qué es el Code Coverage?

Antes de acabar con el video, me gustaría comentarte el Test Coverage. El Test Coverage podríamos decir que es una métrica que nos permite saber qué porcentaje de nuestro código de producción, el de la aplicación principal se ha ejecutado cuando hemos pasado los tests. Nos permite saber cuánto de testeado tenemos un tipo (refiriendome a una clase o struct) vamos a verlo rápidamente.

Nos vamos al menu superior de listado de ficheros, y aquí aparecen varias opciones, nos vamos a la última del menu. Aquí aparece un listado de todas las ejecuciones de nuestra aplicación, tests, etc. Clickamos en la más reciente que pone Coverage, si clickamos vemos que aparece Notas.app y si abrimos para ver todos los ficheros, podemos ver el porcentaje de código que se ha llamado cuando hemos pasado nuestro tests. Es decir, si vamos a Note.swift, este tipo está cubierto un 100% ya que hemos creado un test para comprobar que el init se comporta como debería, o por ejemplo, en el ViewModel al testear todas las funciones hemos cubierto el 100% de las líneas, si clickamos y hacemos doble click navegamos directamente al ViewModel y a la derecha del todo podemos ver cuantas veces se han llamado estas líneas desde nuestro tests.

Si volvemos atrás y vamos por ejemplo a algún fichero que no esté al 100%, si clickamos podemos ver que hay partes que están en rojo ya que nuestros tests no han ejecutado esas lineas.

El code coverage es una métrica muy interesante para tus apps y hay que buscar un equilibrio, tener un code coverage bajo en tu aplicación puede hacer que tu app tenga poca calidad en el futuro y tener un codebase al 100% puede que no tenga sentido.

Conclusión

Hoy hemos aprendido a cómo crear Tests Unitarios en Xcode y en Swift. Hemos testado un modelo y luego hemos testeado todas la lógica de un ViewModel con las operaciones de crear nota, actualizar nota y borrar nota.

En el próximo video vamos a ver los Tests de Integración, vamos a usar SwiftData para persistir nuestras notas y vamos a crear casos de uso.

Y hasta aquí el video de hoy!