Saltar al contenido
K
iOS con SwiftUIPaso a paso37 min de lectura · 20 min de máquina Básico

Tus primeras pruebas en iOS

Al terminar: Escribir tus primeras pruebas unitarias y de interfaz de la app de iOS.

Al terminar tendrás en MarcadorApp tres pruebas en verde: una unitaria, que comprueba que el paquete Torneo llega a la app, y dos de interfaz, que arrancan la app y miran su pantalla. Las pasarás desde Xcode (Cmd+U) y desde la Terminal con xcodebuild, y sabrás leer lo que imprime.

Importa porque de aquí en adelante cada lección que toque la app añade pruebas, y todas usan las mismas piezas: las de esta lección.

Temas: prueba unitaria frente a prueba de interfaz, XCTest, herencia (la mínima para leer una prueba), xcodebuild, -only-testing y la tabla de piezas de XCUITest, a la que volverás desde las lecciones siguientes.

Dos grupos de pruebas

La prueba unitaria

El asistente creó MarcadorAppTests.swift con un example() vacío, escrito con Swift Testing (el de La regla de los puntos y tu primer test): @Test marca la prueba y #expect(…) la falla si su condición es falsa. Se llama unitaria porque comprueba una pieza de código por separado. El target MarcadorAppTests corre dentro de la app, así que puede ver su código con @testable import MarcadorApp (@testable deja ver también lo que no es público).

La prueba de interfaz

Una prueba de interfaz (XCUITest) arranca la app en otro proceso (otro programa en marcha, aparte del de la prueba) y la maneja como lo haría una persona: busca textos y botones en pantalla, toca, espera. La prueba no ve el código de la app, solo lo que hay en pantalla. Eso las hace lentas (arrancan el simulador y la app) y muy realistas: comprueban lo que de verdad ve el usuario.

Se escriben con XCTest, el marco de pruebas de Apple anterior a Swift Testing. Las pruebas de interfaz solo existen en XCTest: Tests en iOS compara los dos marcos más adelante.

Antes de leer el código, una idea de Swift que sale ya: la herencia. Una clase puede partir de otra: class Mia: XCTestCase dice «Mia es un XCTestCase con lo que yo añada»; la clase de la que parte es la clase madre y la nueva, la subclase. De momento solo tienes que reconocer tres palabras al leer las pruebas: override («redefino algo que heredé»; Swift obliga a escribirlo), final (esta clase ya no admite subclases) y class var (una propiedad de la clase entera, no de cada objeto). Un UIViewController en SwiftUI, unas lecciones más adelante, explica la herencia a fondo.

Tabla de piezas de XCUITest

Esta tabla es la referencia de todas las pruebas de interfaz del curso: las demás lecciones enlazan aquí en vez de repetirla. Si una lección usa una pieza que no está en la tabla, la explica ella en el sitio. MarcadorAppUITests.swift ya trae una clase con dos pruebas del asistente, y las piezas para leerlas son estas:

PiezaQué hace
final class … : XCTestCaseUna clase de pruebas de XCTest. Cada método cuyo nombre empieza por test es una prueba
setUpWithError()Se ejecuta antes de cada prueba (lleva override, porque la redefine). Con continueAfterFailure = false, ante el primer fallo la prueba se para: los pasos siguientes ya no tienen sentido
XCUIApplication(), app.launch() y app.terminate()XCUIApplication representa la app que se prueba; launch() la arranca y terminate() la cierra (para probar un arranque en frío)
app.buttons["Gol Rayo FC"] y .tap()Busca un botón por su título; tap() lo toca
app.staticTexts["Marcador"] y .labelBusca un texto en pantalla (un Text de SwiftUI es un «static text»). Acepta el texto que se ve y también el identificador de accesibilidad, una etiqueta que la app le pone a un elemento para que se pueda encontrar sin depender de su texto; label es el texto que muestra en ese momento
app.tabBars.buttons["Laboratorio"] y .isSelectedUn botón de la barra de pestañas; isSelected dice si es la pestaña activa
app.navigationBars.buttons.firstMatchEl primer botón de la barra de navegación de arriba: el de «atrás»
.exists y .waitForExistence(timeout: 5)exists pregunta si el elemento está ya en pantalla; waitForExistence espera hasta 5 segundos a que aparezca y devuelve true o false (la interfaz tarda un momento en dibujarse)
XCTAssertTrue(…)Falla la prueba si la condición es falsa
@MainActor y throwsLa interfaz solo se maneja desde el hilo principal; throws deja que una prueba pare con un error

Para más adelante. Estas piezas no las usas hoy. Cada lección que necesita una la enlaza aquí o la explica en el sitio; vuelve a esta tabla cuando la veas:

PiezaQué hace
app.buttons["Gol"].staticTexts["Rayo FC"]Búsqueda encadenada: busca un texto dentro de un botón (o de otro elemento) ya encontrado
app.otherElements["partido-1"]Los elementos que no son botón, texto ni otro tipo con nombre propio, como un contenedor; también se buscan por texto o por identificador de accesibilidad
elemento.value as? Stringvalue es el valor de un elemento (en un interruptor, «1» si está activado y «0» si no) y llega como un dato sin tipo; as? String intenta leerlo como texto y da nil si no lo es
elemento.frameEl rectángulo que ocupa el elemento en pantalla; su width es el ancho
XCTAssertEqual(a, b) y XCTAssertNotEqual(a, b)Fallan si a y b son distintos (o iguales, la segunda)
XCTAssertGreaterThan(a, b) y XCTAssertGreaterThanOrEqual(a, b)Fallan si a no es mayor que b (o mayor o igual, la segunda)
XCTAssertNil(x)Falla si x tiene un valor
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))Un punto de la pantalla dado en proporciones de 0 a 1: dx: 0.5 es la mitad del ancho y dy: 0.98, casi abajo del todo. CGVector es solo un par de números con nombre
punto.press(forDuration: 0.1, thenDragTo: otroPunto)Pulsa en un punto y arrastra hasta otro; sirve, por ejemplo, para deslizar una hoja hacia abajo
NSPredicate, expectation(for:evaluatedWith:) y waitForExpectations(timeout:)Para esperar a algo más que «existe». Un NSPredicate es una condición sobre un objeto: NSPredicate { objeto, _ in … } la escribe como una closure que devuelve true o false, y NSPredicate(format: "label BEGINSWITH 'Redibujar'"), como un texto. expectation(for:evaluatedWith:) dice «espero a que esta condición se cumpla para este elemento» y waitForExpectations espera hasta el tiempo dado y falla si no llega

Un ejemplo completo, con lo que hace cada línea:

ejemplo mínimo: una prueba de interfaz (con comentarios)
let app = XCUIApplication()        // la app que se prueba
app.launch()                       // la arranca
// espera hasta 5 s a que haya un texto «Marcador» en pantalla; si no sale, falla
XCTAssertTrue(app.staticTexts["Marcador"].waitForExistence(timeout: 5))

Una prueba que se escribe así pasa o falla: no imprime nada por sí sola. Quien imprime es la herramienta que las ejecuta.

xcodebuild: Xcode desde la Terminal

xcodebuild es Xcode sin ventanas: compila y ejecuta las pruebas desde la Terminal, lo que sirve para automatizarlas (por ejemplo, en un servidor que las pasa solo). Necesita tres datos: qué proyecto (la carpeta donde lo lanzas), qué scheme y en qué destino. El destino es un simulador, que eliges por su nombre en la lista que da xcrun simctl list devices available.

ejemplo mínimo: la orden de xcodebuild
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -parallel-testing-enabled NO build test

Pieza a pieza:

  • -scheme MarcadorApp: el scheme que se usa, el de tu proyecto.
  • -destination 'platform=iOS Simulator,name=iPhone 17': dónde se ejecuta, un simulador por su nombre. La guía usa iPhone 17; si el tuyo se llama distinto, cambia el nombre.
  • -parallel-testing-enabled NO: las pruebas se ejecutan una tras otra en un solo simulador. Según cómo esté el scheme, Xcode puede repartirlas en varios simuladores copia a la vez (en la salida aparece Clone 1 of iPhone 16) y entonces faltan algunas de las líneas que citará esta lección; con la opción, la salida es la que verás aquí. El resultado final es el mismo.
  • build test: compila y ejecuta las pruebas.

Termina con ** TEST SUCCEEDED ** si todo pasó, o con ** TEST FAILED ** si falló alguna. Tiene que ejecutarse dentro de ~/Marcador-Swift/MarcadorApp, la carpeta del proyecto.

Un proyecto tiene varios grupos de pruebas, y a veces solo interesa uno: la opción -only-testing: ejecuta solo lo que le digas, con tres niveles posibles: -only-testing:MarcadorAppTests (el grupo entero, que es un target de pruebas), -only-testing:MarcadorAppUITests/MarcadorAppUITests (una clase de ese grupo) y -only-testing:MarcadorAppUITests/MarcadorAppUITests/testArrancaYMuestraElMarcador (una sola prueba, en la forma Objetivo/Clase/método).

Reto · Ejercicio: solo las unitarias

Desde la Terminal, en ~/Marcador-Swift/MarcadorApp, pasa solo las pruebas del grupo MarcadorAppTests, sin las de interfaz. Busca en la salida cuántas pruebas se ejecutan. Lo que debe salir al final, con las pruebas una tras otra como se explicó arriba (las cifras de segundos cambian):

✔ Test example() passed after 0.001 seconds.
✔ Suite MarcadorAppTests passed after 0.001 seconds.
✔ Test run with 1 test in 1 suite passed after 0.001 seconds.
** TEST SUCCEEDED **

La primera vez, compilar tarda unos minutos: es normal.

Ver solución
cd ~/Marcador-Swift/MarcadorApp
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -parallel-testing-enabled NO test -only-testing:MarcadorAppTests

test compila lo que falte y ejecuta las pruebas, y -only-testing:MarcadorAppTests deja fuera al grupo de interfaz. En la salida, además de las cuatro líneas del enunciado, aparece una que asusta:

	 Executed 0 tests, with 0 failures (0 unexpected) in 0.000 (0.000) seconds

No es un fallo. Es la cuenta de XCTest, el marco de pruebas antiguo: en MarcadorAppTests no hay ninguna prueba escrita con XCTest (las unitarias usan Swift Testing), así que cuenta cero. Las de Swift Testing se cuentan aparte, en las líneas de debajo, tras Test run started, con su marca ✔ y su Test run with 1 test in 1 suite passed. La cifra que cuenta es esa.

Paso 1 · Escribe las pruebas

Objetivo: una prueba unitaria que comprueba que el paquete llega a la app, y una prueba de interfaz que arranca la app y comprueba que se ve el título.

MarcadorApp
MarcadorAppTests
MarcadorAppTests.swiftse modifica
MarcadorAppUITests
MarcadorAppUITests.swiftse modifica
MarcadorAppUITestsLaunchTests.swiftse modifica
Tres ficheros que ya existen se modifican.

La prueba unitaria

Sustituye el example() por una prueba, elPaqueteTorneoLlegaALaApp, que compruebe que el marcador del partido de ejemplo del paquete es 2-1 (el fichero también importa Torneo). Intenta escribirla tú; cuando termines, abre el bloque.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppTests.swift: la prueba del paquete
import Testing
import Torneo   // la prueba también habla con el paquete
@testable import MarcadorApp   // deja ver el código de la app desde la prueba
 
struct MarcadorAppTests {
 
    // Comprueba que el paquete `Torneo` llega hasta el target de pruebas de la app.
    @Test func elPaqueteTorneoLlegaALaApp() {
        let partido = Ejemplo.rayoContraToros
        // `#expect` falla la prueba si la condición es falsa.
        #expect(partido.marcador() == Marcador(local: 2, visitante: 1))
    }
}

La prueba de interfaz

Haz tres cambios en MarcadorAppUITests.swift:

  1. Renombra testExample a testArrancaYMuestraElMarcador y, dentro, tras arrancar la app, comprueba que en menos de 5 segundos aparece en pantalla el texto «Marcador».
  2. Borra testLaunchPerformance: mide el tiempo de arranque, tarda y no aporta nada aquí.
  3. Borra los comentarios del asistente si quieres: no cambian nada.

Intenta escribirlo tú; cuando termines, abre el bloque.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
MarcadorAppUITests.swift: la prueba de arranque
import XCTest   // el marco de pruebas de interfaz
 
final class MarcadorAppUITests: XCTestCase {
 
    // Se ejecuta antes de cada prueba.
    override func setUpWithError() throws {
        // Ante el primer fallo, para: los pasos siguientes ya no tienen sentido.
        continueAfterFailure = false
    }
 
    // `@MainActor`: la interfaz se maneja desde el hilo principal. El nombre empieza por `test`.
    @MainActor
    func testArrancaYMuestraElMarcador() throws {
        let app = XCUIApplication()   // la app que se prueba
        app.launch()                  // la arranca en otro proceso
        // Espera hasta 5 segundos a ver el título; si no sale, la prueba falla.
        XCTAssertTrue(app.staticTexts["Marcador"].waitForExistence(timeout: 5))
    }
}

La prueba de arranque del asistente

MarcadorAppUITestsLaunchTests.swift arranca la app y adjunta una captura de pantalla. Trae una opción, runsForEachTargetApplicationUIConfiguration, en true: con ella el asistente repite la prueba por cada apariencia (clara, oscura) y orientación, y tarda minutos sin aportar nada a esta app. Ábrelo y cámbiala a false:

MarcadorAppUITestsLaunchTests.swift: la línea que cambia
    // `false`: una sola vuelta, en vez de una por cada apariencia y orientación.
    override class var runsForEachTargetApplicationUIConfiguration: Bool {
        false
    }

El resto del fichero no cambia.

Paso 2 · Compila y prueba

Objetivo: pasar las tres pruebas desde Xcode y desde la Terminal.

  1. En Xcode, pulsa Cmd+U (Product → Test): compila y ejecuta las tres pruebas (una unitaria y dos de interfaz). Verás el simulador arrancar y abrirse la app sola. El resultado sale en el Test navigator (Cmd+6) con una marca verde junto a cada prueba.
  2. Desde la Terminal, en la carpeta del proyecto, ejecuta la orden completa (la del ejemplo de arriba, con el nombre de tu simulador; recuerda -parallel-testing-enabled NO, que hace que la salida sea la de abajo):
cd ~/Marcador-Swift/MarcadorApp
xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -parallel-testing-enabled NO build test

La primera vez es la más lenta (de uno a tres minutos, según el Mac); termina con ** TEST SUCCEEDED **. Entre el mucho texto que imprime, estas son las líneas que importan (las cifras de segundos cambian):

Resolved source packages:
  Torneo: …/Marcador-Swift/Torneo @ local
…
	 Executed 0 tests, with 0 failures (0 unexpected) in 0.000 (0.000) seconds
◇ Test run started.
✔ Test elPaqueteTorneoLlegaALaApp() passed after 0.001 seconds.
✔ Test run with 1 test in 1 suite passed after 0.001 seconds.
…
Test Case '-[MarcadorAppUITests.MarcadorAppUITests testArrancaYMuestraElMarcador]' passed (3.500 seconds).
Test Case '-[MarcadorAppUITests.MarcadorAppUITestsLaunchTests testLaunch]' passed (3.969 seconds).
	 Executed 2 tests, with 0 failures (0 unexpected) in 7.468 (7.472) seconds
** TEST SUCCEEDED **

Resolved source packages confirma que Torneo se resolvió desde tu carpeta. El primer Executed 0 tests es el de XCTest sobre las unitarias (el ejercicio lo explicó); la prueba unitaria es la de Swift Testing, y las dos de interfaz son las dos últimas: 1 + 2 = 3 pruebas.

Punto de control

  • xcodebuild -scheme MarcadorApp -destination 'platform=iOS Simulator,name=iPhone 17' -parallel-testing-enabled NO build test, desde ~/Marcador-Swift/MarcadorApp, termina con ** TEST SUCCEEDED **.
  • MarcadorAppTests.swift solo tiene la prueba elPaqueteTorneoLlegaALaApp, y MarcadorAppUITestsLaunchTests.swift lleva runsForEachTargetApplicationUIConfiguration en false.
  • Tras Cmd+U, el Test navigator muestra tres pruebas en verde: una unitaria y dos de interfaz.
  • Con -only-testing:MarcadorAppTests, la salida cuenta una prueba en una suite.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.¿Qué diferencia hay entre una prueba de MarcadorAppTests y una de MarcadorAppUITests?

  2. 2.¿Qué hace -only-testing:MarcadorAppTests en la orden de xcodebuild?

  3. 3.Al pasar las pruebas unitarias, la salida de xcodebuild dice Executed 0 tests y, debajo, Test run with 1 test in 1 suite passed. ¿Cuántas pruebas se han ejecutado?

  4. 4.¿Por qué se pone runsForEachTargetApplicationUIConfiguration a false en MarcadorAppUITestsLaunchTests.swift?

  5. 5.En una prueba de interfaz, app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98)) señala un punto de la pantalla. ¿Cuál?

  6. 6.En una prueba de interfaz, ¿para qué sirve waitForExistence(timeout: 5) en lugar de exists?

Faltan 6 de 6