Home iOS & Swift Books Real-World iOS by Tutorials

5
Building Features - Locating Animals Near You Written by Renan Benatti Dias

Great job getting through Section I of this book! By now, you have a good grasp of how the app’s foundation works and how you’ll leverage those components to build features.

You’ll get to the meat of the app in this chapter by building PetSave’s first feature, Animals Near You. Since this is the app’s main feature, you’ll spend some careful time improving the UI and refactoring some of the code you wrote so far.

More specifically, you’ll learn how to:

  • Use view models to decouple code from your views.

  • Leverage SwiftUI property wrappers to handle the state of your views.

  • Use services to fetch and store animals using asynchronous code.

  • Fetch more animals at the bottom of the list.

  • Polish UI to improve usability.

Additionally, you’ll write unit tests to test the behavior of your new view model. By implementing unit tests as you develop new features, you’ll have more confidence when refactoring them down the line.

Building Animals Near You

Animals Near You is PetSave’s core feature. It helps users browse pets for adoption that might fit their profile.

Using the data you learned how to fetch and store from the previous chapter, you’ll populate a list of animals that users can scroll through to find their perfect pet.

Right now, the app shows a simple list of pets that the user can scroll.

Animals near you screen showing a list of pets
Animals near you screen showing a list of pets

The user can already scroll through animals, but nothing happens when they reach the end of the list. Even though Petfinder’s API has a lot of pets to adopt, the app only shows a handful.

APIs, like Petfinder’s API, paginate their results to give the app small amounts of data to prevent requests from being heavy and slow. That way, the app can request more data on demand.

If the app were to fetch all the data upfront, the user might not even see it, and the app would seem slow or unresponsive. Pagination lets you implement a feature known as infinite scrolling. Your favorite social media app likely does something similar to prevent your device from downloading your entire feed at once!

Also, the list’s row is a little too simple right now. It only shows a picture and the pet’s name. With so little information, users may not feel like opening the details view to check out more data about that pet.

To improve their user experience and make it more likely that they’ll want to learn more about a pet that catches their eye, you’ll add more information to each row by including labels for the pet’s breed, age, gender and a short description.

At the end of this chapter, PetSave will look like this:

Final version of the near you screen
Final version of the near you screen

Using View Models

Right now, animals fetched from the API are stored in CoreData, so AnimalsNearYouView can fetch pets from the locally persisted database to show on the screen. Not only that, but the view also uses an @State variable to show a loading indicator.

As your project grows, you’ll have to add a state property for fetching more animals when the user reaches the bottom of the list and another for keeping track of the API’s page.

Before you know it, your view class could be gigantic. That’s not good. You want to keep your views as loosely coupled as possible, focusing on layout building and user interaction.

You’ll create a view model to handle user events and the view’s state.

Note: Usually, in an MVVM architecture, the view model is responsible for fetching data and handling user interaction as well as transforming and passing data down to the view. However, since you’re using CoreData and Apple has a nice property wrapper, @FetchRequest, that works great with SwiftUI, you’ll only use view models to handle input from the user and fetching data.

Creating your first view model

Open the starter project of this chapter and find AnimalsNearYou group. Inside it, create a new group named viewModels.

Next, inside the new group, create a new file named AnimalsNearYouViewModel.swift. Add the following code to this file:

final class AnimalsNearYouViewModel: ObservableObject {

}

This class will be the view model for AnimalsNearYouView. You’ll use it to store it’s state and handle requests and user interaction.

Marking the class as final provides a couple of benefits. First, it prevents the class from being overridden by disallowing subclassing. Second, it’s a compiler optimization that can speed up build times.

In addition, you made it conform to the ObservableObject protocol.

Understanding the ObservableObject protocol

The protocol ObservableObject lets you store your data outside the view while still updating the UI when that data changes.

AnimalsNearYouViewModel will house the state and handle the events of Animals Near You. It’s the source of truth for AnimalsNearYouView, responsible for data that drives the UI. Any changes to this object updates the view.

Refactoring code from the view to the view model

Now that you have a view model for AnimalsNearYouView, you must remove the state property and the code that fetches and stores animals.

Back inside AnimalsNearYouView.swift, remove these two properties:

@State var isLoading = true
private let requestManager = RequestManager()

You’ll add a variable for loading and a RequestManager inside the view model later.

Next, remove:

func fetchAnimals() async {
  do {
    let animalsContainer: AnimalsContainer = try await requestManager.perform(
      AnimalsRequest.getAnimalsWith(
        page: 1,
        latitude: nil,
        longitude: nil
      )
    )
    for var animal in animalsContainer.animals {
      animal.toManagedObject()
    }
    await stopLoading()
  } catch {
    print("Error fetching animals...\(error)")
  }
}

@MainActor
func stopLoading() {
  self.isLoading = false
}

You’ll see a couple of compilation errors in Xcode. Don’t worry about them. You’ll fix them shortly.

Go back to AnimalsNearYouViewModel.swift and add this to the class:

// 1
@Published var isLoading: Bool

// 2
private let requestManager = RequestManager()

// 3
init(isLoading: Bool = true) {
  self.isLoading = isLoading
}

This code:

  1. Adds isLoading, a published property for tracking if the view is loading.
  2. Adds requestManager, you’ll use it to request data to the API.
  3. Creates a initializer that sets isLoading to true by default.

You’ll use these properties to add and remove a loading indicator and populate the local database with animals.

Updating the UI with Published properties

You may have noticed the @Published annotation on the isLoading property. SwiftUI uses this property wrapper to observe changes in an ObservableObject. You can use @Published on any type, even custom types. Whenever published properties change, observers of the object receive a notification of that change. SwiftUI uses this to update any view that is dependent on that property.

@Published and @ObservableObject use Swift’s Combine framework under the hood to publish changes to that property’s observers.

Here, you’ll use @Published to publish the view’s state changes to update the AnimalsNearYouView UI.

AnimalsNearYouView observes changes of the property isLoading inside AnimalsNearYouViewModel and adds a loading indicator when the value is true.

Fetching and storing animals inside the view model

Still inside AnimalsNearYouViewModel.swift, add:

// 1
func fetchAnimals() async {
  do {
    // 2
    let animalsContainer: AnimalsContainer = try await
      requestManager.perform(
        AnimalsRequest.getAnimalsWith(
          page: 1,
          latitude: nil,
          longitude: nil
        )
      )

    // 3
    for var animal in animalsContainer.animals {
      animal.toManagedObject()
    }

    // 4
    isLoading = false
  } catch {
    // 5
    print("Error fetching animals...\(error.localizedDescription)")
  }
}

Here, you:

  1. Define a method called fetchAnimals() to fetch and store animals. The async keyword indicates this method is asynchronous.
  2. Start an asynchronous HTTP request using requestManager to fetch animals from the API.
  3. Iterate over the objects from the response and convert them to entities to save in CoreData.
  4. Update the property isLoading to false to remove the loading indicator.
  5. Catch any error that fetching from the API may throw and print a localized description.

Now that your view model is ready to fetch and store animals, it’s time to update the views to use the view model.

In AnimalsNearYouView.swift, add:

@ObservedObject var viewModel: AnimalsNearYouViewModel

This line adds a property to the view for observing changes to the view model’s state.

Understanding @ObservedObject

Since the view model conforms to @ObservableObject, you use @ObservedObjectto tell SwiftUI it should observe changes to the object’s state and updates the view where it uses that data. It lets the view stay up to date with its source of truth. In this case, that’s AnimalsNearYouViewModel.

Updating the body to use the view model

Next, you’ll update the body property of the view to use the view model’s properties.

Still inside AnimalsNearYouView.swift, find:

await fetchAnimals()

And replace it with:

await viewModel.fetchAnimals()

Then, find this line:

if isLoading && animals.isEmpty {

And change it to:

if viewModel.isLoading && animals.isEmpty {

Now, AnimalsNearYouView uses the state of AnimalsNearYouViewModel to show the loading indicator and fetch animals when the view appears.

Also inside ContentView.swift, update:

AnimalsNearYouView()

To:

AnimalsNearYouView(viewModel: AnimalsNearYouViewModel())

This updates ContentView to pass a new instance of AnimalsNearYouViewModel to the view.

Updating the preview

Before you build and rerun the app, you have to update the preview code for AnimalsNearYouView to use the view model.

Inside AnimalsNearYouView.swift, at the bottom of the file, find:

AnimalsNearYouView(isLoading: false)

Update it with:

AnimalsNearYouView(viewModel: AnimalsNearYouViewModel())

In Xcode, resume the preview by clicking resume at the top right corner of the preview canvas. Or use the shortcut Command-Shift-P to resume the preview.

Note: If you don’t see the preview canvas at the right or bottom of Xcode, at the top right, click Adjust Editor Options and select Canvas. Or, use the shortcut Command-Option-Return to open the canvas.

Animals near you screen showing a list of pets in Xcode Preview
Animals near you screen showing a list of pets in Xcode Preview

Fantastic! To see the app running on the simulator, build and run.

Animals near you screen showing a list of pets
Animals near you screen showing a list of pets

Success! The app still works as expected, but you removed the code to fetch and handle state from the view.

However, you may notice a purple warning on Xcode’s issue navigator.

Xcode's issue navigator showing some warnings
Xcode's issue navigator showing some warnings

Publishing changes and threads

When you call perform(:_), Swift suspends fetchAnimals() and starts an HTTP request using URLSession. By default, URLSession uses a background thread to make HTTP requests.

fetchAnimals System Background Thread Main Thread await perform fetchAnimals resume perform
How the system handles asynchronous code

When the response arrives, fetchAnimals() resumes execution but outside the main thread. When updating the UI outside the main thread, Xcode warns you because doing so can causes performance issues and/or unexpected behavior.

You could fix this by adding the code to update isLoading inside an async call from the main dispatch queue:

DispatchQueue.main.async {
  self.isLoading = true
}

This updates isLoading on the main thread. AnimalsNearYouView also receives this update inside the main thread.

However, Swift 5.5 has a new way of handling async calls and managing updates outside the main thread.

You’ll fix this issue in a moment. But first, you’ll create a Service to fetch animals from the API. That way you remove the code to fetch and store animals from your view model, decoupling code and separating concerns from your view model.

Creating AnimalFetcher

In AnimalsNearYouViewModel.swift, at the top of the file, add:

protocol AnimalsFetcher {
  func fetchAnimals(page: Int) async -> [Animal]
}

This code defines a protocol with the requirement to fetch animals.

You’ll use this protocol to decouple the object that fetches animals from the view model. This enforces dependency inversion, a software principle introduced in Chapter 2 “Laying Down a Strong Foundation”, to create lowly coupled code. You’ll see how this aids in unit testing later in the chapter.

Creating your first service

Now, inside AnimalsNearYou, create a new group named services. Inside services, create a new file named FetchAnimalsService.swift. Add the following code to the new file:

struct FetchAnimalsService {
  private let requestManager: RequestManagerProtocol

  init(requestManager: RequestManagerProtocol) {
    self.requestManager = requestManager
  }
}

This code creates a new struct with a request manager to fetch animals.

Next, create the following extension below:

// MARK: - AnimalFetcher
extension FetchAnimalsService: AnimalsFetcher {
  func fetchAnimals(page: Int) async -> [Animal] {
    let requestData = AnimalsRequest.getAnimalsWith(
      page: page,
      latitude: nil,
      longitude: nil
    )
    do {
      let animalsContainer: AnimalsContainer = try await
        requestManager.perform(requestData)
      return animalsContainer.animals
    } catch {
      print(error.localizedDescription)
      return []
    }
  }
}

This creates an extension of FetchAnimalsService to conform to AnimalsFetcher and add the code to fetch animals from the API, which asynchronously returns an array of Animal.

Updating View Model to use FetchAnimalsService

Back in AnimalsNearYouViewModel.swift, replace:

private let requestManager = RequestManager()

With:

private let animalFetcher: AnimalsFetcher

Also, replace the initializer with:

init(isLoading: Bool = true, animalFetcher: AnimalsFetcher) {
  self.isLoading = isLoading
  self.animalFetcher = animalFetcher
}

This new initializer has two parameters: isLoading that by default is set to true and animalFetcher that expects any object that conforms to AnimalsFetcher.

Next, change fetchAnimals() to use this object to fetch animals from the API and store them in CoreData. Replace everything with:

let animals = await animalFetcher.fetchAnimals(page: 1)
for var animal in animals {
  animal.toManagedObject()
}
isLoading = false

Inside ContentView.swift, find:

AnimalsNearYouView(viewModel: AnimalsNearYouViewModel())

And replace it with:

AnimalsNearYouView(
  viewModel: AnimalsNearYouViewModel(
    animalFetcher: FetchAnimalsService(requestManager: RequestManager())
  )
)

Now, when instantiating an AnimalsNearYouViewModel, you also pass an instance of FetchAnimalsService.

To build and run, you’ll also have to update the preview for AnimalsNearYouView.

Inside services, create a new file called AnimalsFetcherMock.swift and add:

struct AnimalsFetcherMock: AnimalsFetcher {
  func fetchAnimals(page: Int) async -> [Animal] {
    Animal.mock
  }
}

This code creates a mock object to feed mock data to your view previews. When previewing data, you don’t want to wait for a network request to finish.

Back in AnimalsNearYouView.swift, at the bottom of the file in the preview code, replace:

viewModel: AnimalsNearYouViewModel()

With:

viewModel: AnimalsNearYouViewModel(
  animalFetcher: AnimalsFetcherMock()
)

Here, you update the preview’s view model to use your newly created AnimalFetcherMock(), thereby skipping the load from the API.

Build and run.

Animals near you screen showing a list of pets
Animals near you screen showing a list of pets

The app continues to run as expected, but Xcode still shows a purple warning.

Xcode's issue navigator showing some warnings
Xcode's issue navigator showing some warnings

That’s because FetchAnimalsService runs outside the main thread. To fix this, you have to set the execution of AnimalsNearYouViewModel to the main thread.

Using @MainActor

Inside AnimalsNearYouViewModel.swift, add the following line at the top before the class declaration:

@MainActor

The @MainActor annotation makes sure all code executed in this class is inside the main thread. When you receive the result back from FetchAnimalsService, the execution changes back to the main thread so you can update any publishing property without the fear of updating the UI outside the main thread.

Build and run again.

Animals near you screen showing a list of pets
Animals near you screen showing a list of pets

Now the purple warning should be gone.

Understanding actors

An actor is a type in Swift that runs concurrent code while protecting its state from data races. You create actors to run code asynchronously that performs a task and changes state, without worrying about other parts of your code accessing and modifying that data while the task is running.

You’ve already used @MainActor to change the state of the view and show the loading indicator.

If you want to learn more about actors, check out chapter 8 “Getting Started With Actors” from Modern Concurrency in Swift.

Creating a service for storing animals

To make your view model even slimmer and follow another principle introduced in Chapter 2 “Laying Down a Strong Foundation”, the single responsibility principle, you’ll also create a service for storing animals.

Inside AnimalsNearYouViewModel.swift add the following protocol at the top of the file:

protocol AnimalStore {
  func save(animals: [Animal]) async throws
}

Create a new file inside services and name it AnimalStoreService.swift. Add:

import CoreData

struct AnimalStoreService {
  private let context: NSManagedObjectContext

  init(context: NSManagedObjectContext) {
    self.context = context
  }
}

AnimalStoreService takes a NSManagedObjectContext object for saving fetched animals to CoreData. Keep in mind that this context must be a background context because you’ll use this service to manage CoreData objects fetched from the API by a method running on a background thread. Using viewContext instead would intermittently result in a crash or poor performance, since it runs in the main thread.

Next, add the following extension to convert objects fetched from the API into managed objects and save new ones:

// MARK: - AnimalStore
extension AnimalStoreService: AnimalStore {
  func save(animals: [Animal]) async throws {
    // 1
    for var animal in animals {
      // 2
      animal.toManagedObject(context: context)
    }
    // 3
    try context.save()
  }
}

Here’s a code breakdown:

  1. You iterate over the animals fetched from the API.
  2. Then, you transform your animal object into CoreData entities, passing the background context from the service. You must do this because you’re saving animals in the background context and using CoreData to merge changes to the view context.
  3. You save the context to persist your changes.

Back in AnimalsNearYouViewModel, add:

private let animalStore: AnimalStore

Also, change the init to:

init(
  isLoading: Bool = true,
  animalFetcher: AnimalsFetcher,
  animalStore: AnimalStore
) {
  self.isLoading = isLoading
  self.animalFetcher = animalFetcher
  self.animalStore = animalStore
}

This code adds a new property for receiving an object that conforms to AnimalsStore to store animals from the API request.

Inside fetchAnimals(), replace the for-in loop with:

do {
  try await animalStore.save(animals: animals)
} catch {
  print("Error storing animals... \(error.localizedDescription)")
}

Here, you remove the code that saves the animals from the view model and call save(animals:) from AnimalStore to do this job.

Next, open ContentView.swift and replace the initializer for AnimalsNearYouViewModel with:

AnimalsNearYouView(
  viewModel: AnimalsNearYouViewModel(
    animalFetcher: FetchAnimalsService(requestManager: RequestManager()),
      animalStore: AnimalStoreService(
        context: PersistenceController.shared.container.newBackgroundContext()
      )
  )
)

This code passes an instance of AnimalStoreService to the view model’s initializer.

Finally, in AnimalsNearYouView.swift, inside the Preview Provider code, add the following parameter to the view model’s initializer:

animalStore: AnimalStoreService(context: CoreDataHelper.previewContext)

Here you create a new AnimalStoreService object with preview context, so this view can be rendered in Xcode Previews using in-memory store.

Build and run to make sure the app still fetches and stores animals.

Animals near you screen showing a list of pets
Animals near you screen showing a list of pets

Adding infinite scrolling

Great job refactoring the code to use a view model! This will help you implement new features in the future.

Speaking of new features, there’s one that’s still missing from Animals Near You: Infinite scrolling. Users already expect this behavior when scrolling through a list.

Petfinder’s API has so many kinds of animals that it’s impractical to fetch everything over a single HTTP request. The API paginates the result and returns a fixed number of animals.

With infinite scrolling, when the user reaches the bottom of the list, the app requests the next page from the API.

You’ll implement this feature now.

Open AnimalsNearYouViewModel.swift, add this inside AnimalsNearYouViewModel:

@Published var hasMoreAnimals = true

Next, at the end of fetchAnimals(), add:

hasMoreAnimals = !animals.isEmpty

hasMoreAnimals tracks if the API can return more animals on request. You’ll use this property to show a row at the end of the list indicating the app is fetching more animals. If the response from Petfinder’s API returns an empty array of animals, you’ll set this property to false, as that means the list has reached its end.

Adding pagination

Still in AnimalsNearYouViewModel, to keep track of the page from the API, add:

private(set) var page = 1

Finally, add the following method:

func fetchMoreAnimals() async {
  page += 1
  await fetchAnimals()
}

fetchMoreAnimals() is a method that increments the current page by one and calls fetchAnimals() to fetch the next page.

For this to work, find the following line inside fetchAnimals():

let animals = await animalFetcher.fetchAnimals(page: 1)

And update it to use the new page property:

let animals = await animalFetcher.fetchAnimals(page: page)

This code keeps the current page up to date as you request more animals from the API.

Return to AnimalsNearYouView.swift. In body, under the ForEach view, add:

if !animals.isEmpty && viewModel.hasMoreAnimals {
  ProgressView("Finding more animals...")
    .padding()
    .frame(maxWidth: .infinity)
    .task {
      await viewModel.fetchMoreAnimals()
    }
}

Here, you add a new ProgressView at the end of the list, indicating the app is fetching more animals. When it appears, the task(priority:_:) modifier calls fetchMoreAnimals() to asynchronously fetch more animals from the API.

Build and run the app. Scroll to the bottom of the list to see more animals from the API added to the list.

Animals near you screen showing finding more animals indicator
Animals near you screen showing finding more animals indicator

Improving the UI

Animals Near You has all the functionality you planned for, but there’s still something missing. The user might scroll through animals, but there’s very little information upfront to hook them into viewing an animal’s details. Each row only displays the animal’s photo and name.

To get the user to view an animal’s details, you’ll add more information to each row, like gender, age and a short description.

Adding more information to rows

Open AnimalRow.swift and add the following two computed properties:

var animalType: String {
  animal.type ?? ""
}

var animalBreedAndType: String {
  "\(animal.breed) \(animalType)"
}

You’ll use these properties to display a label on AnimalRow for the animal’s breed and type.

Under the Text for the animal name, insert:

Text(animalBreedAndType)
  .font(.callout)

if let description = animal.desc {
  Text(description)
    .lineLimit(2)
    .font(.footnote)
}

This code adds two Text one to display a string containing the animal breed and type, and another to display a description, this ones will only appear if animal.desc is not nil.

Build and run.

Animals near you screen now with pet's breed, type and description
Animals near you screen now with pet's breed, type and description

Adding labels for attributes

Now that the row has the animal breed, type and description, all that’s left to add are age and gender.

Below the previous added code, insert:

HStack {
  Text(animal.age.rawValue)
    .padding(4)
    .background(animal.age.color.opacity(0.2))
    .cornerRadius(8)
    .foregroundColor(animal.age.color)
    .font(.subheadline)
  Text(animal.gender.rawValue)
    .padding(4)
    .background(.pink.opacity(0.2))
    .cornerRadius(8)
    .foregroundColor(.pink)
    .font(.subheadline)
}

Here, you add a new HStack view with two new Text labels: One for the animal’s age and another for its gender.

Build and run.

Animals near You screen with gender and age
Animals near You screen with gender and age

Reducing repetition with View Modifiers

Notice both Text labels have the same view modifiers, except for changing the background and foreground color depending on the information it displays.

To avoid the code duplicate use Custom View Modifiers. Custom view modifiers help you create a view modifier that applies all those modifiers at once, so you don’t have to do it for each text label.

Create a new file inside views and name it AnimalAttributesCard.swift. Add:

import SwiftUI

struct AnimalAttributesCard: ViewModifier {
  let color: Color
  func body(content: Content) -> some View {
    content
      .padding(4)
      .background(color.opacity(0.2))
      .cornerRadius(8)
      .foregroundColor(color)
      .font(.subheadline)
  }
}

This code creates a custom view modifier with all the common modifiers you’re using for Text views.

Back in AnimalRow.swift, remove the modifiers from the age Text:

.padding(4)
.background(animal.age.color.opacity(0.2))
.cornerRadius(8)
.foregroundColor(animal.age.color)
.font(.subheadline)

And replace them with:

.modifier(AnimalAttributesCard(color: animal.age.color))

Next, remove the modifiers from the gender Text:

.padding(4)
.background(.pink.opacity(0.2))
.cornerRadius(8)
.foregroundColor(.pink)
.font(.subheadline)

And replace them with:

.modifier(AnimalAttributesCard(color: .pink))

Build and run to ensure everything still runs the same.

Animals near you screen with gender and age
Animals near you screen with gender and age

Testing your view model

Making sure your code is resilient is a key part of app development, because it’s always in constant change. To help you accomplish this you need to add tests to your code. There are many types of software tests, for this project, you’ll use Unit tests to test your view model and make sure it behaves the way you expect.

Unit tests act as a safeguard for your codebase. This tests will catch breaking changes and any problems with your code when you introduce new features or changes to the existing ones.

Writing test cases

Inside PetSaveTests/Tests, create a new group and name it AnimalsNearYou.

Next, create a new file named AnimalsNearYouViewModelTestCase.swift.

Add the following code to the new file:

import XCTest
@testable import PetSave

@MainActor
final class AnimalsNearYouViewModelTestCase: XCTestCase {
  let testContext = PersistenceController.preview.container.viewContext
  // swiftlint:disable:next implicitly_unwrapped_optional
  var viewModel: AnimalsNearYouViewModel!

  @MainActor
  override func setUp() {
    super.setUp()
    viewModel = AnimalsNearYouViewModel(
      isLoading: true,
      animalFetcher: AnimalsFetcherMock(),
      animalStore: AnimalStoreService(context: testContext)
    )
  }
}

Here, you create a new test case class for testing AnimalsNearYouViewModel. It also overrides setUp() to set up the view model for each test.

Notice that when instantiating an AnimalsNearYouViewModel, you use AnimalsFetcherMock as the service to fetch animals. When testing the view model, it’s always good to isolate and focus your tests on a specific test case. Since you’re only testing the view model and don’t want your tests to depend on external data, you’ll use a mocked response from the API.

Next, you’ll add the code to test if the view model updates the isLoading property as it fetches animals from the API. Add:

func testFetchAnimalsLoadingState() async {
  XCTAssertTrue(viewModel.isLoading, "The view model should be loading, but it isn't")
  await viewModel.fetchAnimals()
  XCTAssertFalse(viewModel.isLoading, "The view model should'nt be loading, but it is")
}

testFetchAnimalsLoadingState() tests if isLoading is true before the API response and if it’s false when the response arrives.

Notice testFetchAnimalsLoadingState() is an async method. This is necessary because fetchAnimals() is also an async method, and you may only call it in an async context.

Build and run the tests.

testFetchAnimalsLoadingState test passed
testFetchAnimalsLoadingState test passed

Now that you’re testing the state for loading, it’s time to test the pagination from the API.

Add the following code:

func testUpdatePageOnFetchMoreAnimals() async {
  XCTAssertEqual(viewModel.page, 1, "the view model's page property should be 1 before fetching, but it's \(viewModel.page)")
  await viewModel.fetchMoreAnimals()
  XCTAssertEqual(viewModel.page, 2, "the view model's page property should be 2 after fetching, but it's \(viewModel.page)")
}

This method tests if page is 1 before the user scrolls to the bottom of the list and requests more animals. Then, it tests if page changed to 2 once the request finishes.

Build and rerun the tests.

testFetchAnimalsLoadingState and testUpdatePageOnFetchMoreAnimals passed
testFetchAnimalsLoadingState and testUpdatePageOnFetchMoreAnimals passed

Finally, you’ll test when AnimalsNearYouViewModel receives an empty response.

To do this, you’ll create a new struct that conforms to AnimalFetcher to mock an empty response from the API.

At the bottom of the file, add:

struct EmptyResponseAnimalsFetcherMock: AnimalsFetcher {
  func fetchAnimals(page: Int) async -> [Animal] {
    return []
  }
}

EmptyResponseAnimalsFetcherMock conforms to AnimalsFetch. However, instead of fetching animals from an external source or returning mock data, it immediately returns an empty array of Animal, mocking an empty response from the API.

Next inside AnimalsNearYouViewModelTestCase, add:

func testFetchAnimalsEmptyResponse() async {
  // 1
  viewModel = AnimalsNearYouViewModel(
    isLoading: true,
    animalFetcher: EmptyResponseAnimalsFetcherMock(),
    animalStore: AnimalStoreService(context: testContext)
  )
  await viewModel.fetchAnimals()
  // 2
  XCTAssertFalse(viewModel.hasMoreAnimals, "hasMoreAnimals should be false with an empty response, but it's true")
  XCTAssertFalse(viewModel.isLoading, "the view model shouldn't be loading after receiving an empty response, but it is")
}

Here you:

  1. Instantiate a new view model, pass the EmptyResponseAnimalsFetcherMock and try to fetch more animals.
  2. Then, you test if hasMoreAnimals is false and if isLoading is also false.

Build and run the test case.

All tests passed
All tests passed

Key points

  • View models help you keep your view code clean and focused on layout building.

  • You can easily update SwiftUI views by using @Published and @ObservedObject properties to observe state.

  • Actors are great for protecting the state from data races and performing async tasks that don’t block the main thread.

  • You can create Services to decouple code and run tasks from your view model.

  • Software tests can make your development safer and help you find possible bugs in your code.

Where to go from here?

You’ve reached the end of the chapter, but there are still features PetSave needs. Animals Near You might be done, but the search for the perfect pet continues. In the next chapter, you’ll implement the search feature, so that users can better find pets for them.

If you want to learn more about testing, check out our video tutorial about Testing in iOS.

Also, check out our tutorial about Understanding Data Flow in SwiftUI to learn more about @ObservedObjects, @State and source of truth.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

© 2022 Razeware LLC