Building a Pokédex on iOS — Part 9: Unit testing with Swift Testing
Part 9 of the PokeTracker series: we write unit tests with the new Swift Testing framework using Mocks, @Suite, and #expect.
Unit tests are the point where the architecture from previous layers justifies itself. The protocols we defined in the Repository, the APIClient, and the Use Cases don’t exist just for structure — they exist so each piece can be tested in isolation, without network, without a database, without UI.
In this post we use Swift Testing, Apple’s testing framework available since Xcode 16. It’s more expressive than XCTest and has better integration with Swift concurrency.
Swift Testing vs XCTest
The most visible difference is the syntax:
// 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 accepts any boolean expression and when it fails it shows exactly what value each operand had. @Test with a readable description makes test reports understandable without having to guess what testFetchPokemonList_returns_valid_data_for_list_endpoint does.
TestData
Tests need consistent test data. Instead of building DTOs in every test, we centralize the test data. Create 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, // no next → this is the last page
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
Create 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 is necessary because mockResult is of type Any — the compiler cannot automatically verify its concurrency safety. In a test mock this is acceptable.
The counters requestCallCount and lastRequestedEndpoint allow you to verify not just the result but also how the mock was used — how many times it was called and with what arguments.
MockPokemonRepository
Create 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
Create 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
Create 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
Create PokeTrackerTests/EndpointTests.swift. Testing that endpoints generate the correct URLs is simple but protects against regressions:
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)
}
}
}
Running the tests
⌘U in Xcode runs the entire test target. Results appear in the test panel with the names you set in @Test(...). If a #expect fails, Xcode shows exactly what value you expected and what value it got.
What’s next in the final post
In Post 10 — the last in the series — we configure the CI/CD pipeline with GitHub Actions and reveal the complete repository.