SwiftUI - Test drive the logic inside a view using TDD

SwiftUI - Test drive the logic inside a view using TDD

Alexandre Alcuvilla's photo
Alexandre Alcuvilla
·May 30, 2022·

16 min read

Problem: How test behavior inside a SwiftUI view ?

Solution: Extract all the logic in a ViewModel and test drive only the behaviour using the TDD process !

You have this view to integrate image.png

Specs for this design look like this:

- [ ] Display favorites/restaurants  

But there is a problem with this design
It's only takes into account the happy path
But a lot of things can go wrong here
What happen:

  • On slow init restaurants (slow server response)?
  • If loading restaurants failed (server error)?
  • If the server return an empty response (no restaurants)?
  • On slow update for favorite status (slow server response) ?
  • If favorite status failed (server error)?

After ask those questions to the team
I have more infos to understand what I'm trying to build
Here are the new specs:

[ ] On init restaurants/favorites are loading  
    [ ] If init succeed but no restaurants, display an image with a message  
     [ ] If init succeed favorites/restaurants are displayed  
     [ ] User can select favorites/restaurant to display details view  
     [ ] User can add a restaurant to favorites  
        [ ] Status is pending (UI loader is display)  
        [ ] If success delete from restaurants and add to favorites  
        [ ] If failed deliver an error  
    [ ] User can delete restaurant from favorites  
        [ ] Status is pending (UI loader is display)  
        [ ] If success delete from favorites and add to restaurants
        [ ] If failed deliver an error  
    [ ] If init failed (at least one request failed) an error message is deliver  
        [ ] User can retry if load failed  

Knowing what we want to build
Let's test drive this implementation using TDD
Every of those specs must have a corresponding test


Start with the first one

[ ] On init restaurants/favorites are loading  

With this test:

  func test_load_deliversStatusIsPending() {
    let sut = RestaurantViewModel() // 1

    sut.load() // 2

    XCTAssertEqual(sut.status, .pending) // 3
  }
  1. The RestaurantViewModel type not exist
  2. The SUT doesn't have a load() function
  3. The status .pending not exist

Write some code to make the test pass

class RestaurantViewModel: ObservableObject {
  enum Status {
    case pending
  }

  @Published var status: Status = .pending

  func load() {
    status = .pending
  }
}

Test succeeded
First requirement done

[✅] On init restaurants/favorites are loading  

Now test the error case

    [ ] If init failed (at least one request failed) an error message is deliver  
        [ ] User can retry if load failed  

Like to start with errors
Don't have to worry about them later:

  func test_load_deliversErrorOnLoadFailure() {
    let error = NSError(domain: "any error", code: 404)
    let errorDetails = ErrorDetails(title: "Request Failed", message: "Error 404", isRetry: true)
    let service = RestaurantService() // 1
    let sut = RestaurantViewModel(service: service)

    let exp = XCTestExpectation(description: "Expect load completion")

    sut.load {
      exp.fulfill()
    }

    service.completeWithError(error) // 2

    wait(for: [exp], timeout: 1)

    XCTAssertEqual(sut.errorDetails?.title, errorDetails.title, "Expect title update")
    XCTAssertEqual(sut.errorDetails?.message, errorDetails.message, "Expect message update")
    XCTAssertEqual(sut.errorDetails?.isRetry, errorDetails.isRetry, "Expect is retry update")
    XCTAssertTrue(sut.isPresentAlert, "Expect that setting the error details update this property")
  }
  1. The service will be the futur object that handles API request
  2. The service simulate a load completion with an error

Now fix the compiler error
The ErrorDetails type not exist

struct ErrorDetails {
  let title: String
  let message: String
  let isRetry: Bool
}

The RestaurantService not exist
It also needs functions called completeWithError() and load()

class RestaurantService {
  typealias LoadCompletions = (Result<(restaurants: [Restaurant],
                                       favorites: [Favorite]), Error>) -> Void

  var loadCompletions = [LoadCompletions]()

  func load(completion: @escaping LoadCompletions) {
    loadCompletions.append(completion) // 1
  }

  func completeWithError(_ error: Error, at index: Int = 0) {
    loadCompletions[index](.failure(error)) // 2
  }
}
  1. Everytime the load() function is called, I accumulate the completion
  2. Access the completion and send a .failure(error) message

Inject the RestaurantService in the RestaurantViewModel

class RestaurantViewModel: ObservableObject {
  ...
  let service: RestaurantService
  ...
  init(service: RestaurantService) {
    self.service = service
  }
  ...
}

Add a completion to the load function of the SUT (RestaurantViewModel)
To access to the result when complete

  func load(completion: @escaping () -> Void) {

Add the isPresentAlert and errorDetails properties

class RestaurantViewModel: ObservableObject {
  ...
  @Published var isPresentAlert = false
  var errorDetails: ErrorDetails? {
    didSet { updateAlertVisibility() } // 1
  }
  ...

  // MARK: - Helpers

  private func updateAlertVisibility() {
    isPresentAlert = errorDetails == nil ? false : true
  }
}
  1. The property isPresentAlert need to be update according to the errorDetails state

Now make the test pass
Updating errorDetails and isPresentAlert properties
When loading failed

class RestaurantViewModel: ObservableObject {
  ...
  func load(completion: @escaping () -> Void) {
    status = .pending

    service.load { [weak self] result in
      switch result {
      case .failure(let error as NSError):
        self?.errorDetails = ErrorDetails(title: "Request Failed",
                                         message: "Error \(error.code)",
                                         isRetry: true)
      case .success:
        break
      }
      completion()
    }
  }
  ...
}

Test is passing
Checkmark this requirement as done

    [✅] If init failed (at least one request failed) an error message is deliver  
        [✅] User can retry if load failed  

Simplify the test setup
Extract it to the makeSUT() function

  private func makeSUT() -> (sut: RestaurantViewModel, service: RestaurantService) {
    let service = RestaurantService()
    let sut = RestaurantViewModel(service: service)
    return (sut, service)
  }

Used like this in test

  func test_load_deliversErrorOnRequestFailure() {
    ...
    let (sut, service) = makeSUT()
    ...
}

Now the success loading with empty restaurants

  [ ] If init succeed but no restaurants, display an image with a message  

Start writing a failing test

  func test_load_deliverEmptyMessageWhenCompleteWithEmptyRestaurantsAndFavorites() {
    let emptyRestaurants = [Restaurant]()
    let emptyFavorites = [Favorite]()
    let (sut, service) = makeSUT()

    let exp = XCTestExpectation(description: "Expect load completion")

    sut.load {
      exp.fulfill()
    }

    service.completeSuccessfullyWith(restaurants: emptyRestaurants,
                                     favorites: emptyFavorites) // 1

    wait(for: [exp], timeout: 1)

    XCTAssertEqual(sut.status, .empty) // 2
  }
  1. The function completeSuccessfullyWith not exist
  2. The .empty case not exist

Add the completeSuccessfullyWith to the service

  func completeSuccessfullyWith(restaurants: [Restaurant], favorites: [Favorite], at index: Int = 0) {
    loadCompletions[index](.success((restaurants: restaurants,
                                     favorites: favorites)))
  }

Add the .empty case to the status

  enum Status {
    case pending
    case empty
  }

The test failed
XCTAssertEqual failed: ("pending") is not equal to ("empty")

Let's make it pass
Update the SUT to handle the .success case

  func load(completion: @escaping () -> Void) {
    status = .pending

    service.load { [weak self] result in
      switch result {
      case .failure(let error as NSError):
        self?.errorDetails = ErrorDetails(title: "Request Failed",
                                         message: "Error \(error.code)",
                                         isRetry: true)
      case .success(let success):
        let isRestaurantEmpty = success.restaurants.isEmpty && success.favorites.isEmpty

        if isRestaurantEmpty {
          self?.status = .empty
        }
      }
      completion()
    }
  }

Test are passing


Extract the logic for the empty restaurant tests
To avoid repetition
And improve test readability

  private func expect(_ sut: RestaurantViewModel, toCompleteWith expectedStatus: RestaurantViewModel.Status, when action: () -> Void) {
    let exp = XCTestExpectation(description: "Expect load completion")

    sut.load {
      exp.fulfill()
    }

    action()

    wait(for: [exp], timeout: 1)

    XCTAssertEqual(sut.status, expectedStatus)
  }

Refactoring the test

  func test_load_deliverEmptyMessageWhenCompleteWithEmptyRestaurantsAndFavorites() {
    let emptyRestaurants = [Restaurant]()
    let emptyFavorites = [Favorite]()
    let (sut, service) = makeSUT()

    expect(
      sut,
      toCompleteWith: .empty,
      when: {
        service.completeSuccessfullyWith(restaurants: emptyRestaurants,
                                         favorites: emptyFavorites)
      })
  }

Now test the happy path
Restaurants and favorites are returned properly

    [ ] If init succeed favorites/restaurants are displayed (both requests must be successful)  
  func test_load_deliversLoadedRestaurantsAndFavorites() {
    let restaurants = [kfc(), burgerKing()] // 1
    let favorites = [mcDonalds()] // 1
    let (sut, service) = makeSUT()

    expect(
      sut,
      toCompleteWith: .loaded(restaurants: restaurants,
                              favorites: favorites),
      when: {
        service.completeSuccessfullyWith(restaurants: restaurants,
                                         favorites: favorites)
      })
  }
  1. Restaurants kfc(), burgerKing() and mcDonalds() not exist
  2. Status .loaded(restaurants:favorites:) not exist

First create fake restaurants

func mcDonalds() -> Restaurant {
  .init(imageName: "mc-donalds",
        imageNameFavorite: "mc-donalds-favorite",
        waitingTime: "25-35",
        name: "McDonald's",
        rate: "4.0 Good (500+)",
        type: "American, Burgers")
}

func burgerKing() -> Restaurant {
  .init(imageName: "burger-king",
        imageNameFavorite: "burger-king-favorite",
        waitingTime: "25-35",
        name: "BURGER KING",
        rate: "4.3 Very good (500+)",
        type: "American, Burgers")
}

func kfc() -> Restaurant {
  .init(imageName: "kfc",
        imageNameFavorite: "kfc-favorite",
        waitingTime: "15-25",
        name: "KFC",
        rate: "4.1 Good (500+)",
        type: "Sandwichs, Burgers")
}

func carls() -> Restaurant {
  .init(imageName: "carls-jr",
        imageNameFavorite: "carls-jr-favorite",
        waitingTime: "15-25",
        name: "Carl's Jr",
        rate: "3.8 Good (61)",
        type: "Sandwichs, Burgers")
}

Second create the loaded(restaurants:favorites) status

enum Status {
    case pending
    case empty
    case loaded(restaurants: [Restaurant], favorites: [Favorite])
}

The compiler returns an another error
In the assertion of the helper function
Global function 'XCTAssertEqual(::_:file:line:)' requires that 'RestaurantViewModel.Status' conform to 'Equatable'

Let's conform to Equatable

  enum Status: Equatable {
    case pending
    case empty
    case loaded(restaurants: [Restaurant], favorites: [Favorite])
  }

Test failed
XCTAssertEqual failed: ("pending") is not equal to ("loaded(restaurants: [SwiftUITestDrivenViewTests.Restaurant(imageName: "kfc", imageNameFavorite: "kfc-favorite", waitingTime: "15-25", name: "KFC", rate: "4.1 Good (500+)", type: "Sandwichs, Burgers"), SwiftUITestDrivenViewTests.Restaurant(imageName: "burger-king", imageNameFavorite: "burger-king-favorite", waitingTime: "25-35", name: "BURGER KING", rate: "4.3 Very good (500+)", type: "American, Burgers")], favorites: [SwiftUITestDrivenViewTests.Restaurant(imageName: "mc-donalds", imageNameFavorite: "mc-donalds-favorite", waitingTime: "25-35", name: "McDonald\'s", rate: "4.0 Good (500+)", type: "American, Burgers")])")

Let's make it pass
In the .success case add the else condition
To handle non empty response

      ...
      case .success(let success):
        let isRestaurantEmpty = success.restaurants.isEmpty && success.favorites.isEmpty

        if isRestaurantEmpty {
          self?.status = .empty
        } else {
          self?.status = .loaded(restaurants: success.restaurants,
                                favorites: success.favorites)
        }
        ...

Test is passing
Checkmark this requirement as done

    [✅] If init succeed favorites/restaurants are displayed (both requests must be successful)  

Now test the selection of a restaurant

    [ ] User can select favorites/restaurant to display details view  

As usual start writing a test

  func test_selectRestaurant_pushSelectedRestaurantToDetailsView() {
    let selectedRestaurant = kfc()
    let (sut, _) = makeSUT()

    sut.updateSelected(with: selectedRestaurant) // 1

    XCTAssertEqual(sut.selectedRestaurant, selectedRestaurant) // 2
    XCTAssertTrue(sut.isPushToDetailsView) // 3
  }
  1. The SUT don't have a function updateSelected(with:)
  2. The SUT don't have selectedRestaurant property
  3. The SUT don't have isPushToDetailsView property

Add them to the SUT
And also a private function updateDetailViewVisibility()
That handle details view visibility

class RestaurantViewModel: ObservableObject {
  ...
  @Published var isPushToDetailsView = false
  private(set) var selectedRestaurant: Restaurant? {
    didSet { updateDetailViewVisibility() }
  }
  ...
  func updateSelected(with restaurant: Restaurant?) {
    selectedRestaurant = restaurant
  }
  ...
  // MARK: Helpers
  ...
  private func updateDetailViewVisibility() {
    isPushToDetailsView = selectedRestaurant == nil ? false : true
  }

Test is passing
Checkmark this requirement as done

    [✅] User can select favorites/restaurant to display details view  

Test the behavior of adding a restaurant to favorite

     [ ] User can add a restaurant to favorites  
        [ ] Status is pending (UI loader is display)  
        [ ] If success delete from restaurants and add to favorites  
        [ ] If failed deliver an error  

Start with the error

  func test_addToFavorites_deliversErrorOnRequestFailure() {
    let error = NSError(domain: "any error", code: 404)
    let errorDetails = ErrorDetails(title: "Request Failed", message: "Error 404", isRetry: false)
    let (sut, service) = makeSUT()
    let restaurant = kfc()

    let exp = XCTestExpectation(description: "Expect addToFavorites completion")

    sut.addToFavorites(restaurant) { // 1
      exp.fulfill()
    }

    service.completeAddFavoriteWithError(error) // 2

    wait(for: [exp], timeout: 1)

    XCTAssertEqual(sut.errorDetails?.title, errorDetails.title)
    XCTAssertEqual(sut.errorDetails?.message, errorDetails.message)
    XCTAssertEqual(sut.errorDetails?.isRetry, errorDetails.isRetry)
    XCTAssertTrue(sut.isPresentAlert, "Expect that setting the error details update this property")
  }
  1. The SUT don't have the addToFavorites(restaurant:) function
  2. The service don't have the completeAddFavoriteWithError(error:) function

Add addToFavorites(restaurant:) to the SUT

  func addToFavorites(_ restaurant: Restaurant, completion: @escaping () -> Void) {

  }

Add completeAddFavoriteWithError(error:) to the service

class RestaurantService {
  ...
  typealias FavoriteResult = Result<Restaurant, Error>
  typealias FavoriteCompletions = (FavoriteResult) -> Void
  ...
  var addFavoriteCompletions = [FavoriteCompletions]()
  ...
  func addFavorite(_ restaurant: Restaurant, completion: @escaping FavoriteCompletions) {
    addFavoriteCompletions.append(completion)
  }
  ...
  func completeAddFavoriteWithError(_ error: Error, at index: Int = 0) {
    addFavoriteCompletions[index](.failure(error))
  }
}

Run tests
There is a crash
Thread 1: Fatal error: Index out of range

  func completeAddFavoriteWithError(_ error: Error, at index: Int = 0) {
    addFavoriteCompletions[index](.failure(error))
  }

The addFavoriteCompletions property is empty

When we check RestaurantViewModel
The addToFavorites(restaurant:completion:) does nothing

  func addToFavorites(_ restaurant: Restaurant, completion: @escaping () -> Void) {

  }

Ok let's fix this

  func addToFavorites(_ restaurant: Restaurant, completion: @escaping () -> Void) {
    service.addFavorite(restaurant) { result in
      switch result {
      case .success:
        break
      case let .failure(error as NSError):
        self.errorDetails = ErrorDetails(title: "Request Failed",
                                         message: "Error \(error.code)",
                                         isRetry: false)
      }
      completion()
    }
  }

Test is passing
Checkmark this requirement as done

    [ ] User can add restaurant to favorites  
        [ ] Status is pending (UI loader is display)  
        [ ] If success add to favorites and delete from restaurant  
        [✅] If failed deliver an error  

Now test the add favorite restaurant success case

        [ ] If success add to favorites and delete from restaurant  

Start with a test

  func test_addToFavorites_addToFavoritesDeleteFromRestaurantsOnSuccess() {
    let restaurant = kfc()
    let (sut, service) = makeSUT()
    sut.status = .loaded(restaurants: [restaurant], favorites: [])
    let exp = XCTestExpectation(description: "Expect load completion")

    sut.addToFavorites(restaurant) {
      exp.fulfill()
    }

    service.completeAddFavoriteSuccessfullyWith(restaurant) // 1

    wait(for: [exp], timeout: 1)

    XCTAssertEqual(sut.status, .loaded(restaurants: [], favorites: [restaurant]))
  }
  1. The SUT don't have the completeAddFavoriteSuccessfullyWith(restaurant:) function

Add the completeAddFavoriteSuccessfullyWith(restaurant:) to the RestaurantService

  func completeAddFavoriteSuccessfullyWith(_ restaurant: Restaurant, at index: Int = 0) {
    addFavoriteCompletions[index](.success(restaurant))
  }

Test failed
XCTAssertEqual failed: ("loaded(restaurants: [SwiftUITestDrivenViewTests.Restaurant(imageName: "kfc", imageNameFavorite: "kfc-favorite", waitingTime: "15-25", name: "KFC", rate: "4.1 Good (500+)", type: "Sandwichs, Burgers")], favorites: [])") is not equal to ("loaded(restaurants: [], favorites: [SwiftUITestDrivenViewTests.Restaurant(imageName: "kfc", imageNameFavorite: "kfc-favorite", waitingTime: "15-25", name: "KFC", rate: "4.1 Good (500+)", type: "Sandwichs, Burgers")])")

The restaurant is not added to the favorite
Let's fix this handling the success case

  func addToFavorites(_ restaurant: Restaurant, completion: @escaping () -> Void) {
    service.addFavorite(restaurant) { result in
      switch result {
      case .success:
        self.updateStatusAddingFavorite(restaurant)
      case let .failure(error as NSError):
        self.errorDetails = ErrorDetails(title: "Request Failed",
                                         message: "Error \(error.code)",
                                         isRetry: false)
      }
      completion()
    }
  }
  ...
  // MARK: - Helpers
  ...
  private func updateStatusAddingFavorite(_ restaurant: Restaurant) {
    if case var .loaded(restaurants: restaurants, favorites: favorites) = self.status {
      favorites.append(restaurant)
      restaurants.removeAll(where: { $0 == restaurant })
      self.status = .loaded(restaurants: restaurants,
                            favorites: favorites)
    }
  }

Test is passing
Checkmark this requirement as done

    [ ] User can add restaurant to favorites
        [ ] Status is pending (UI loader is display)
        [✅] If success add to favorites and delete from restaurant
        [✅] If failed deliver an error

Now the pending status for a selected restaurant

        [ ] Status is pending (UI loader is display)  
func test_isPending_updatePendingStatusOnFavoriteApiRequest() {
    let restaurant = kfc()
    let (sut, service) = makeSUT()
    sut.status = .loaded(restaurants: [restaurant], favorites: [])
    let exp = XCTestExpectation(description: "Expect delete from favorite to complete")

    XCTAssertFalse(sut.isPending(for: restaurant), "Expect that restaurant is not pending on init") // 1

    sut.addToFavorites(restaurant) {
      exp.fulfill()
    }

    XCTAssertTrue(sut.isPending(for: restaurant), "Expect that restaurant is pending because of the running api request") // 1

    service.completeAddFavoriteSuccessfullyWith(restaurant)

    wait(for: [exp], timeout: 1)

    XCTAssertFalse(sut.isPending(for: restaurant), "Expect that restaurant is not pending because the api request complete") // 1
  }
  1. The SUT don't have the isPending(for:) function

Add isPending(for:) to the SUT

class RestaurantViewModel: ObservableObject {
  ...
  @Published private(set) var pendingRestaurants = [Restaurant]()
  ... 
  func isPending(for restaurant: Restaurant) -> Bool {
    pendingRestaurants.contains(restaurant)
  }
  ...
}

Test failed to the true assertion

    XCTAssertTrue(sut.isPending(for: restaurant), "Expect that restaurant is pending because of the running api request")

Let's make it pass

class RestaurantViewModel: ObservableObject {
  ...
  func addToFavorites(_ restaurant: Restaurant, completion: @escaping () -> Void) {
    addPending(restaurant)

    service.addFavorite(restaurant) { result in
      self.deletePending(restaurant)
      ...
    }
  }    
  ...    
  // MARK: - Helpers
  ...
  private func addPending(_ restaurant: Restaurant) {
    pendingRestaurants.append(restaurant)
  }

  private func deletePending(_ restaurant: Restaurant) {
    pendingRestaurants.removeAll(where: { $0 == restaurant })
  }
}

Test is passing
Checkmark this requirement as done

    [✅] User can add restaurant to favorites  
        [✅] Status is pending (UI loader is display)  
        [✅] If success add to favorites and delete from restaurant  
        [✅] If failed deliver an error  

Test for favorite deletion

    [ ] User can delete restaurant from favorites  
        [ ] Status is pending (UI loader is display)  
        [ ] If success delete from favorites and add to restaurants
        [ ] If failed deliver an error  

I Leave you thoses case as an exercice
Because it's the same as addition


All behaviours are now tested

[✅] On init restaurants/favorites are loading  
    [✅] If init succeed but no restaurants, display an image with a message  
    [✅] If init succeed favorites/restaurants are displayed  
    [✅] User can select favorites/restaurant to display details view  
     [✅] User can add a restaurant to favorites  
        [✅] Status is pending (UI loader is display)  
        [✅] If success delete from restaurants and add to favorites  
        [✅] If failed deliver an error  
    [✅] User can delete restaurant from favorites  
        [✅] Status is pending (UI loader is display)  
        [✅] If success delete from favorites and add to restaurants
        [✅] If failed deliver an error  
    [✅] If init failed (at least one request failed) an error message is deliver  
        [✅] User can retry if load failed  

We can now extract the production code from the test code
In a RestaurantService as a protocol

protocol RestaurantService {
  typealias LoadCompletions = (Result<(restaurants: [Restaurant],
                                       favorites: [Favorite]), Error>) -> Void
  typealias FavoriteResult = Result<Restaurant, Error>
  typealias FavoriteCompletions = (FavoriteResult) -> Void

  func load(completion: @escaping LoadCompletions)
  func addFavorite(_ restaurant: Restaurant, completion: @escaping FavoriteCompletions)
  func deleteFavorite(_ restaurant: Restaurant, completion: @escaping FavoriteCompletions)
}

The RestaurantService class
Now conforms to the protocol
And becomes RestaurantServiceSpy
RestaurantServiceSpy is now a private class
In the Helpers section test

private  class RestaurantServiceSpy: RestaurantService {
    var loadCompletions = [LoadCompletions]()
    var addFavoriteCompletions = [FavoriteCompletions]()
    var deleteFavoriteCompletions = [FavoriteCompletions]()

    func load(completion: @escaping LoadCompletions) {
      loadCompletions.append(completion)
    }

    func addFavorite(_ restaurant: Restaurant, completion: @escaping FavoriteCompletions) {
      addFavoriteCompletions.append(completion)
    }

    func deleteFavorite(_ restaurant: Restaurant, completion: @escaping FavoriteCompletions) {
      deleteFavoriteCompletions.append(completion)
    }

    func completeWithError(_ error: Error, at index: Int = 0) {
      loadCompletions[index](.failure(error))
    }

    func completeSuccessfullyWith(restaurants: [Restaurant], favorites: [Favorite], at index: Int = 0) {
      loadCompletions[index](.success((restaurants: restaurants,
                                       favorites: favorites)))
    }

    func completeAddFavoriteWithError(_ error: Error, at index: Int = 0) {
      addFavoriteCompletions[index](.failure(error))
    }

    func completeAddFavoriteSuccessfullyWith(_ restaurant: Restaurant, at index: Int = 0) {
      addFavoriteCompletions[index](.success(restaurant))
    }

    func completeDeleteFavoriteWithError(_ error: Error, at index: Int = 0) {
      deleteFavoriteCompletions[index](.failure(error))
    }

    func completeDeleteFavoriteSuccessfullyWith(_ restaurant: Restaurant, at index: Int = 0) {
      deleteFavoriteCompletions[index](.success(restaurant))
    }
  }

I already create all SwiftUI views required for this app
Those views of course called this tested view model


Now create a fake service
That simulate failed and succeed request
Here is the scenario that I want

  1. Load is a failure
  2. The user retry
  3. Load is a success
  4. Restaurants are displayed
  5. Add/delete a favorite always failed the first time
  6. After Add/delete a favorite always succeeded
  7. User can view a details view of a restaurant

Let's create fake service for empty restaurants

class FakeRestaurantService: RestaurantService {

  private var loadCallCount = 0
  private var addFavoriteCallCount = 0
  private var deleteFavoriteCallCount = 0

  func load(completion: @escaping LoadCompletions) {
    loadCallCount += 1
    print("loadCallCount: \(loadCallCount)")
    if loadCallCount < 2 {
      let error = NSError(domain: "any", code: 404)
      DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
        completion(.failure(error))
      }
    } else {
      DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) {
        completion(.success((restaurants: [mcDonalds(), kfc(), carls()],
                             favorites: [burgerKing()])))
      }
    }
  }

  func addFavorite(_ restaurant: Restaurant, completion: @escaping FavoriteCompletions) {
    addFavoriteCallCount += 1

    if addFavoriteCallCount < 2 {
      let error = NSError(domain: "any", code: 400)
      DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
        completion(.failure(error))
      }
    } else {
      DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
        completion(.success(restaurant))
      }
    }
  }

  func deleteFavorite(_ restaurant: Restaurant, completion: @escaping FavoriteCompletions) {
    deleteFavoriteCallCount += 1

    if deleteFavoriteCallCount < 2 {
      let error = NSError(domain: "any", code: 404)
      DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
        completion(.failure(error))
      }
    } else {
      DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
        completion(.success(restaurant))
      }
    }
  }
}

Use this fake service in the live preview

struct RestaurantsView_Previews: PreviewProvider {
  static var previews: some View {
    RestaurantsView(title: "Restaurants",
                    viewModel: .init(service: FakeRestaurantService()))
  }
}

Let see it in action in the live preview Simulator Screen Recording - iPhone 11 - 2022-05-29 at 13.29.15.gif


Cool we can simulate the behaviour of our use case
Without API request
No internet connection
Even without running a single time the app in the simulator
That boost our productivity a lot


Create also a fake service for empty restaurant
To check if it also works properly

class FakeEmptyRestaurantService: RestaurantService {
  func load(completion: @escaping LoadCompletions) {
    DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(2000)) {
      completion(.success((restaurants: [],
                           favorites: [])))
    }
  }

  func addFavorite(_ restaurant: Restaurant, completion: @escaping FavoriteCompletions) {}

  func deleteFavorite(_ restaurant: Restaurant, completion: @escaping FavoriteCompletions) {}
}

Use it in the live preview

struct RestaurantsView_Previews: PreviewProvider {
  static var previews: some View {
    RestaurantsView(title: "Restaurants",
                    viewModel: .init(service: FakeRestaurantService()))
  }
}

It's working pretty well
Simulator Screen Recording - iPhone 11 - 2022-05-29 at 14.09.46.gif


Thanks to the power of TTD
We only take care of the expected behaviour
And leave implementation details away
Avoid coupling our test with frameworks are other details
Making our tests even more powerful
Avoid future breaking changes

That's it !!

 
Share this