← Volver al blog

Construyendo una Pokédex en iOS — Parte 9: Pruebas unitarias con Swift Testing

Parte 9 de la serie PokeTracker: escribimos pruebas unitarias con el nuevo framework Swift Testing usando Mocks, @Suite y #expect.

Las pruebas unitarias son el punto donde la arquitectura de las capas anteriores se justifica. Los protocolos que definimos en el Repository, el APIClient y los Use Cases no existen solo para la estructura — existen para que cada pieza se pueda probar de forma aislada, sin red, sin base de datos, sin UI.

En este post usamos Swift Testing, el framework de pruebas de Apple disponible desde Xcode 16. Es más expresivo que XCTest y tiene mejor integración con Swift concurrencia.


Swift Testing vs XCTest

La diferencia más visible es la sintaxis:

// XCTest
func testFetchPokemonList() async throws {
    let result = try await repository.fetchPokemonList(limit: 20, offset: 0)
    XCTAssertEqual(result.count, 1302)
    XCTAssertEqual(result.results.first?.name, "bulbasaur")
}

// Swift Testing
@Test("Fetch pokemon list returns valid data")
func fetchPokemonListReturnsValidData() async throws {
    let result = try await repository.fetchPokemonList(limit: 20, offset: 0)
    #expect(result.count == 1302)
    #expect(result.results.first?.name == "bulbasaur")
}

#expect acepta cualquier expresión booleana y cuando falla muestra exactamente qué valores tenía cada operando. @Test con una descripción legible hace que los reportes de pruebas sean comprensibles sin adivinar qué hace testFetchPokemonList_returns_valid_data_for_list_endpoint.


TestData

Los tests necesitan datos de prueba consistentes. En lugar de construir DTOs en cada test, centralizamos los datos de prueba. Crea PokeTrackerTests/Mocks/TestData.swift:

import Foundation
@testable import PokeTracker

enum TestData {

    static var pokemonListDTO: PokemonListDTO {
        PokemonListDTO(
            count: 1302,
            next: "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20",
            previous: nil,
            results: [
                PokemonListItemDTO(name: "bulbasaur",  url: "https://pokeapi.co/api/v2/pokemon/1/"),
                PokemonListItemDTO(name: "ivysaur",    url: "https://pokeapi.co/api/v2/pokemon/2/"),
                PokemonListItemDTO(name: "venusaur",   url: "https://pokeapi.co/api/v2/pokemon/3/"),
            ]
        )
    }

    static var pokemonListDTOLastPage: PokemonListDTO {
        PokemonListDTO(
            count: 1302,
            next: nil, // sin next → es la última página
            previous: "https://pokeapi.co/api/v2/pokemon?offset=1260&limit=20",
            results: [
                PokemonListItemDTO(name: "pecharunt", url: "https://pokeapi.co/api/v2/pokemon/1025/"),
            ]
        )
    }

    static var pokemonDetailDTO: PokemonDetailDTO {
        PokemonDetailDTO(
            id: 1,
            name: "bulbasaur",
            baseExperience: 64,
            height: 7,
            weight: 69,
            sprites: .init(frontDefault: "https://example.com/1.png", other: nil),
            stats: [
                .init(baseStat: 45, stat: NamedResourceDTO(name: "hp",      url: "")),
                .init(baseStat: 49, stat: NamedResourceDTO(name: "attack",  url: "")),
                .init(baseStat: 49, stat: NamedResourceDTO(name: "defense", url: "")),
                .init(baseStat: 65, stat: NamedResourceDTO(name: "special-attack",  url: "")),
                .init(baseStat: 65, stat: NamedResourceDTO(name: "special-defense", url: "")),
                .init(baseStat: 45, stat: NamedResourceDTO(name: "speed",   url: "")),
            ],
            types: [
                .init(slot: 1, type: NamedResourceDTO(name: "grass",  url: "")),
                .init(slot: 2, type: NamedResourceDTO(name: "poison", url: "")),
            ],
            abilities: [
                .init(ability: NamedResourceDTO(name: "overgrow",       url: ""), isHidden: false),
                .init(ability: NamedResourceDTO(name: "chlorophyll",    url: ""), isHidden: true),
            ],
            moves: [
                .init(move: NamedResourceDTO(name: "razor-wind", url: "")),
            ]
        )
    }

    static var pokemonSpeciesDTO: PokemonSpeciesDTO {
        PokemonSpeciesDTO(
            id: 1,
            name: "bulbasaur",
            flavorTextEntries: [
                .init(
                    flavorText: "A strange seed was planted on its back at birth.",
                    language: NamedResourceDTO(name: "en", url: "")
                ),
            ],
            genera: [
                .init(genus: "Seed Pokémon", language: NamedResourceDTO(name: "en", url: "")),
            ],
            evolutionChain: .init(url: "https://pokeapi.co/api/v2/evolution-chain/1/")
        )
    }
}

MockAPIClient

Crea PokeTrackerTests/Mocks/MockAPIClient.swift:

import Foundation
@testable import PokeTracker

final class MockAPIClient: APIClientProtocol, @unchecked Sendable {

    var mockResult: Any?
    var mockError: Error?
    var requestCallCount = 0
    var lastRequestedEndpoint: Endpoint?

    func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        requestCallCount += 1
        lastRequestedEndpoint = endpoint

        if let error = mockError { throw error }

        guard let result = mockResult as? T else {
            throw NetworkError.invalidData
        }

        return result
    }

    func fetchData(from url: URL) async throws -> Data {
        if let error = mockError { throw error }
        return Data()
    }
}

@unchecked Sendable es necesario porque mockResult es de tipo Any — el compilador no puede verificar su seguridad de concurrencia automáticamente. En un mock de tests esto es aceptable.

Los contadores requestCallCount y lastRequestedEndpoint permiten verificar no solo el resultado sino también cómo se usó el mock — cuántas veces se llamó y con qué argumentos.


MockPokemonRepository

Crea PokeTrackerTests/Mocks/MockPokemonRepository.swift:

import Foundation
@testable import PokeTracker

final class MockPokemonRepository: PokemonRepositoryProtocol, @unchecked Sendable {

    var mockListResult: PokemonListDTO?
    var mockDetailResult: PokemonDetailDTO?
    var mockSpeciesResult: PokemonSpeciesDTO?
    var mockError: Error?

    func fetchPokemonList(limit: Int, offset: Int) async throws -> PokemonListDTO {
        if let error = mockError { throw error }
        return mockListResult ?? TestData.pokemonListDTO
    }

    func fetchPokemonDetail(idOrName: String) async throws -> PokemonDetailDTO {
        if let error = mockError { throw error }
        return mockDetailResult ?? TestData.pokemonDetailDTO
    }

    func fetchPokemonSpecies(idOrName: String) async throws -> PokemonSpeciesDTO {
        if let error = mockError { throw error }
        return mockSpeciesResult ?? TestData.pokemonSpeciesDTO
    }

    func fetchEvolutionChain(id: Int) async throws -> EvolutionChainDTO {
        if let error = mockError { throw error }
        return EvolutionChainDTO(id: id, chain: .init(species: NamedResourceDTO(name: "bulbasaur", url: ""), evolvesTo: []))
    }
}

PokemonRepositoryTests

Crea PokeTrackerTests/PokemonRepositoryTests.swift:

import Testing
@testable import PokeTracker

@Suite("Pokemon Repository Tests")
struct PokemonRepositoryTests {

    @Test("Fetch pokemon list returns valid data")
    func fetchPokemonListReturnsValidData() async throws {
        let mockAPIClient = MockAPIClient()
        mockAPIClient.mockResult = TestData.pokemonListDTO

        let repository = PokemonRepository(apiClient: mockAPIClient)
        let result = try await repository.fetchPokemonList(limit: 20, offset: 0)

        #expect(result.count == 1302)
        #expect(result.results.count == 3)
        #expect(result.results.first?.name == "bulbasaur")
        #expect(result.next != nil)
    }

    @Test("Fetch pokemon list throws error on failure")
    func fetchPokemonListThrowsError() async {
        let mockAPIClient = MockAPIClient()
        mockAPIClient.mockError = NetworkError.noConnection

        let repository = PokemonRepository(apiClient: mockAPIClient)

        await #expect(throws: NetworkError.self) {
            _ = try await repository.fetchPokemonList(limit: 20, offset: 0)
        }
    }

    @Test("Fetch pokemon detail returns valid data")
    func fetchPokemonDetailReturnsValidData() async throws {
        let mockAPIClient = MockAPIClient()
        mockAPIClient.mockResult = TestData.pokemonDetailDTO

        let repository = PokemonRepository(apiClient: mockAPIClient)
        let result = try await repository.fetchPokemonDetail(idOrName: "bulbasaur")

        #expect(result.id == 1)
        #expect(result.name == "bulbasaur")
        #expect(result.types.count == 2)
        #expect(result.stats.count == 6)
    }
}

GetPokemonListUseCaseTests

Crea PokeTrackerTests/GetPokemonListUseCaseTests.swift:

import Testing
@testable import PokeTracker

@Suite("GetPokemonListUseCase Tests")
struct GetPokemonListUseCaseTests {

    @Test("Execute maps DTOs to domain models")
    func executeMappedToDomainModels() async throws {
        let mockRepository = MockPokemonRepository()
        mockRepository.mockListResult = TestData.pokemonListDTO

        let useCase = GetPokemonListUseCase(repository: mockRepository)
        let result = try await useCase.execute(limit: 20, offset: 0)

        #expect(result.pokemons.count == 3)
        #expect(result.hasMore == true)
        #expect(result.total == 1302)
        #expect(result.pokemons.first?.id == 1)
        #expect(result.pokemons.first?.displayName == "Bulbasaur")
    }

    @Test("Execute returns hasMore false on last page")
    func executeHasMoreFalseOnLastPage() async throws {
        let mockRepository = MockPokemonRepository()
        mockRepository.mockListResult = TestData.pokemonListDTOLastPage

        let useCase = GetPokemonListUseCase(repository: mockRepository)
        let result = try await useCase.execute(limit: 20, offset: 1280)

        #expect(result.hasMore == false)
    }

    @Test("Execute propagates repository errors")
    func executePropagatesErrors() async {
        let mockRepository = MockPokemonRepository()
        mockRepository.mockError = NetworkError.serverError(statusCode: 500)

        let useCase = GetPokemonListUseCase(repository: mockRepository)

        await #expect(throws: NetworkError.self) {
            _ = try await useCase.execute(limit: 20, offset: 0)
        }
    }

    @Test("Pokemon formattedID uses three digits")
    func pokemonFormattedIDUseThreeDigits() {
        #expect(Pokemon(id: 1,   name: "bulbasaur", spriteURL: nil).formattedID == "#001")
        #expect(Pokemon(id: 25,  name: "pikachu",   spriteURL: nil).formattedID == "#025")
        #expect(Pokemon(id: 150, name: "mewtwo",    spriteURL: nil).formattedID == "#150")
    }
}

EndpointTests

Crea PokeTrackerTests/EndpointTests.swift. Probar que los endpoints generan las URLs correctas es simple pero protege contra regresiones:

import Testing
@testable import PokeTracker

@Suite("Endpoint Tests")
struct EndpointTests {

    @Test("Pokemon list URL includes limit and offset")
    func pokemonListURLIncludesParameters() {
        let endpoint = Endpoint.pokemonList(limit: 20, offset: 40)
        let url = endpoint.url

        #expect(url != nil)
        #expect(url?.absoluteString.contains("limit=20") == true)
        #expect(url?.absoluteString.contains("offset=40") == true)
    }

    @Test("Pokemon detail URL includes the name")
    func pokemonDetailURLIncludesName() {
        let endpoint = Endpoint.pokemonDetail(idOrName: "pikachu")
        #expect(endpoint.url?.absoluteString.contains("pikachu") == true)
    }

    @Test("All endpoints use GET method")
    func allEndpointsUseGET() {
        let endpoints: [Endpoint] = [
            .pokemonList(limit: 20, offset: 0),
            .pokemonDetail(idOrName: "1"),
            .pokemonSpecies(idOrName: "1"),
            .evolutionChain(id: 1),
        ]
        for endpoint in endpoints {
            #expect(endpoint.method == .get)
        }
    }
}

Correr las pruebas

⌘U en Xcode corre todo el target de tests. Los resultados aparecen en el panel de pruebas con los nombres que pusiste en @Test(...). Si falla un #expect, Xcode muestra exactamente qué valor esperabas y qué valor obtuvo.


Qué viene en el último post

En el Post 10 — el final de la serie — configuramos el pipeline de CI/CD con GitHub Actions y revelamos el repositorio completo.