SwiftUI - TDD async/await

Photo by Kai Pilger on Unsplash

SwiftUI - TDD async/await

Alexandre Alcuvilla's photo
Alexandre Alcuvilla
·Oct 13, 2022·

17 min read

Problem: How to TDD async/await ?

Solution: Stub the response, async function needs to know it's result before call

IMPORTANT: This tutorial is only available for iOS 15+
It will use the async/await integration in URLSession
And the AsyncImage SwiftUI view to load the image

async/await it new to the in iOS ecosystem
Will learn here how to TDD it

Will use the Dog API to test our logic
It's a simple API no need for private key
First we will get dogs form the API
Second download dogs images from returned image url response.

Here is the result of our implementation
image.png


First the list of behaviour to test

 ======= LOAD DOGS
[ ] On init view model, status is idle
[ ] On load use the dog API url to get dogs images
[ ] Load dogs update status to loading
    [ ] If Load dogs failed delivers .connectivity error
      [ ] Display an error message (text)
      [ ] User can retry to load (button)
    [ ] Load dogs succeed with 200 response
      [ ] Parsing succeed delivers dogs
      [ ] Extract dogs names from returned url
      [ ] Parsing failed delivers .invalidData error
        [ ] Display an error message (text)
        [ ] User can retry to load (button)

Start with this first requirement
It ensure that nothing happen on start

 ======= LOAD DOGS
[ ] On init view model, status is idle

As usual write the test first

  func test_init_statusIdle() {
    let sut = DogsViewModel()

    XCTAssertEqual(sut.status, .idle)
  }
  1. Need to create the DogsViewModel
  2. Need to create the status enum and variable

Fix the error for Xcode to compile

class DogsViewModel: ObservableObject {
  enum Status { 
    case idle
  }

  @Published var status = Status.idle
}

Run tests
They are passing
Can mark this requirement as done.

 ========= LOAD DOGS
[✅] On init view model, status is idle

Continue with this requirement for

 ========= LOAD DOGS
[ ] On load use the dog API url to get dogs images

As usual write the test first

  func test_loadDogs_performARequestWithDogsURLRequest() async {
    let dogsURLRequest = URLRequest(url: URL(string: "https://dog.ceo/api/breeds/image/random/3")!) // 1
    let urlSessionSpy = URLSessionAsyncAwaitSpy(result: .failure(.connectivity)) // 2
    let networkService = NetworkService(session: urlSessionSpy) // 3
    let dogServiceSpy = DogService(networkService: networkService) // 4
    let sut = DogViewModelSpy(dogService: dogServiceSpy) // 5

    try? await sut.loadDogs() // 6

    XCTAssertEqual(urlSessionSpy.requests, [dogsURLRequest]) // 7
  }
  1. Url that returns 3 dogs in a random order.
  2. First we need to create a spy that will use our mock of the URLSession async function. We inject (stub) the response of API (need to complete with error or success. The function must complete or you will be blocked be the await that will never return).
  3. The NetworkService will request dogs to the API and will return response as Data. We inject our urlSessionSpy to able to control the result.
  4. The DogService will turn Data into a rich model dogs
  5. The DogViewModel will use the service to get dogs
  6. Use the SUT that perform an async loading of dogs waiting for the response
  7. After load complete, check that the right URL has been used

First we need a mock to test the URLSession
For this async function
image.png

Start creating the protocol
It that looks like the signature of this function

protocol AsyncAwait {
  func data(request: URLRequest) async throws -> (Data, URLResponse)
}

Implement the mock to URLSession

extension URLSession: AsyncAwait {
    func data(request: URLRequest) async throws -> (Data, URLResponse) {
        try await self.data(for: request)
    }
}

We can now create URLSessionAsyncAwaitSpy

  private class URLSessionAsyncAwaitSpy: AsyncAwait {
    var requests = [URLRequest]()
    let result: Result<(Data, URLResponse), NetworkService.Error>

    internal init(result: Result<(Data, URLResponse), NetworkService.Error>) {
      self.result = result
    }

    func data(request: URLRequest) async throws -> (Data, URLResponse) {
      self.requests.append(request) // 1
      return try result.get() // 2
    }
  }
  1. We accumulate all requests for this function calls (Spy)
  2. We return the result that was injected (Stub)

Second create the NetworkService

class NetworkService {
  private let session: AsyncAwait

  init(session: AsyncAwait = URLSession.shared) {
    self.session = session
  }

  enum Error: Swift.Error {
    case connectivity
  }

  func perform(request: URLRequest) async throws -> (Data, URLResponse) {
    do {
      let (data, response) = try await session.data(request: request)
      return (data, response)
    } catch {
      throw error
    }
  }
}

Third create the DogService

class DogService {
  private let networkService: NetworkService
  private let loadDogsRequest = URLRequest(url: URL(string: "https://any-url.com")!) // 1

  init(networkService: NetworkService) {
    self.networkService = networkService
  }

  func loadDogs() async throws {
    do {
      _ = try await networkService.perform(request: loadDogsRequest)
    } catch {
      throw error
    }
  }
}
  1. Enter a wrong URL to see a failed test first

Fourth update DogsViewModel injecting the DogService and create the loadDogs() async function

class DogsViewModel: ObservableObject {
  ...
  private let dogService: DogService

  init(dogService: DogService) {
    self.dogService = dogService
  }

  func loadDogs() async throws {
    do {
      try await dogService.loadDogs()
    } catch { 
      throw error
    }
  }

Run the test and it fails
XCTAssertEqual failed: ("[any-url.com]") is not equal to ("[dog.ceo/api/breeds/image/random/3]")

Let’s make it pass using the right URL

class DogService {
  ...
  private let loadDogsRequest = URLRequest(url: URL(string: "https://dog.ceo/api/breeds/image/random/3")!)
  ...
}

The setup to init the SUT is a bit verbose Let's create an helper function to avoid to always repeat

  // MARK: - Helpers

  private func makeSUT(
    loadDogsResult: Result<(Data, URLResponse), NetworkService.Error> = .failure(.connectivity)) // 1
  -> (sut: DogsViewModel, urlSessionSpy: URLSessionAsyncAwaitSpy) {
    let urlSessionSpy = URLSessionAsyncAwaitSpy(result: loadDogsResult)
    let networkService = NetworkService(session: urlSessionSpy)
    let dogService = DogService(networkService: networkService)
    let sut = DogsViewModel(dogService: dogService)
    return (sut, urlSessionSpy)
  }
  1. Inject the mock result of loading dogs on SUT creation. Add a default value to hide details for tests where the result in not relevant.

Refactor the 2 tests with the makeSUT() function

  func test_init_statusIdle() {
    let (sut, _) = makeSUT()

    XCTAssertEqual(sut.status, .idle)
  }

  func test_loadDogs_performARequestWithDogsURLRequest() async {
    let dogsURLRequest = URLRequest(url: URL(string: "https://dog.ceo/api/breeds/image/random/3")!)
    let (sut, urlSessionSpy) = makeSUT()

    try? await sut.loadDogs()

    XCTAssertEqual(urlSessionSpy.requests, [dogsURLRequest])
  }

Run tests
They are passing


Continue with this requirement for

 ========= LOAD DOGS
   [ ] Load dogs update status to loading

As usual write the test first

  func test_loadDogs_statusLoading() async {
    let (sut, _) = makeSUT(loadDogsResult: .failure(.connectivity))

    try? await sut.loadDogs()

    XCTAssertEqual(sut.messages, [.status(.loading)]) // 1
  }
  1. Needs to create the messages property that will capture all status values update.

Fix the error for Xcode to compile
First create a DogsViewModelSpy
It captures the status updates

  private class DogsViewModelSpy: DogsViewModel {
    enum Message: Equatable {
      static func == (lhs: Message, rhs: Message) -> Bool {
        switch (lhs, rhs) {
        case (status(let status1), status(let status2)):
          return status1 == status2 ? true : false
        }
      }

      case status(DogsViewModel.Status)
    }

    var messages = [Message]()

    override var status: DogsViewModel.Status {
      didSet { messages.append(.status(status)) }
    }
}

Update the DogsViewModel with the loading status
Make it Equatable to able to compare

class DogsViewModel: ObservableObject {
  enum Status: Equatable { // 1
    static func == (lhs: DogsViewModel.Status, rhs: DogsViewModel.Status) -> Bool {
      switch (lhs, rhs) {
      case (.idle, .idle), (.loading, .loading):
        return true
      default:
        return false
      }
    }

    case idle
    case loading // 2
  }
  ...
}
  1. Make the enum Status conform to Equatable, to be able to compare them
  2. Add the loading case

Run the test and it fails
XCTAssertEqual failed: ("[]") is not equal to ("[AsyncAwaitTests.AsyncAwaitTests.(unknown context at $1038858a0).DogsViewModelSpy.Message.status(AsyncAwait.DogsViewModel.Status.loading)]")

Let’s make it pass on updating the status to loading

class DogsViewModel: ObservableObject {
  ...
  func loadDogs() async throws {
    status = .loading  👈
  ...

Run tests
They are passing
Can mark this requirement as done

 ========= LOAD DOGS
 [✅] Load dogs update status to loading

Continue with this requirement for error on request API

 ========= LOAD DOGS
   [ ] If Load dogs failed delivers .connectivity error

As usual write the test first

  func test_load_deliversConnectivityErrorOnNetworkError() async throws {
    let connectivityError = NetworkService.Error.connectivity
    let (sut, _) = makeSUT(loadDogsResult: .failure(connectivityError))

    do {
      try await sut.loadDogs()
      XCTFail("Expected to failed") // 1
    } catch {
      XCTAssertEqual(error as? NetworkService.Error, connectivityError)
    }
  }
  1. We expect a failure here. If request succeed the test must failed.

Run tests
They are passing
With async function you always need to complete
The await keyword will block us until success or throws error
And to be able to write previous test
I always complete SUT with a .connectivity error

Can mark this requirement as done.

 ========= LOAD DOGS
 [✅] If Load dogs failed delivers .connectivity error

Continue with this requirement for display an error message on network error

 ========= LOAD DOGS
   [ ] Display an error message (text) on .connectivity error

As usual write the test first

  func test_load_onRequestError_displayMessageInfo() async {
    let (sut, _) = makeSUT(loadDogsResult: .failure(.connectivity))

    do {
      try await sut.loadDogs()
      XCTFail("Expected to failed")
    } catch {
      // 1
      XCTAssertEqual(sut.error, .loadDogs, "Expect error update or the alert will not be displayed")
      // 2
      XCTAssertEqual(sut.alertTitle(), "Error loading dogs", "Expected a title or the alert will be displayed without it") 
    }
  }
  1. Need to create the error property that is responsible to display an alert on screen
  2. Need to create the function alertTitle() that return the title of the alert for .connectivity error

Fix the error for Xcode to compile

class DogsViewModel: ObservableObject {
  ...
  enum Error: String, Swift.Error, Identifiable {
    case loadDogs

    var id: String {
      self.rawValue
    }
  }
  ...
  var error: Error?
  ...
  func loadDogs() async throws {
    status = .loading

    do {
      try await dogService.loadDogs()
    } catch let error as NetworkService.Error {
      switch error {
      case .connectivity:
        self.error = .loadDogs 👈
      }
      throw error
    }
  }

  func alertTitle() -> String {
    switch error {
    case .loadDogs:
      return "Error loading dogs"
    case .none:
      return ""
    }
  }
}

Run tests
They are passing
Can mark this requirement as done.

 ========= LOAD DOGS
 [✅] Display an error message (text) on .connectivity error

Continue with this requirement for retry on load error

 ========= LOAD DOGS
   [ ] User can retry to load (button) on .connectivity error

As usual write the test first

  func test_retryLoadDogs_loadDogs() async {
    let (sut, _) = makeSUT(loadDogsResult: .failure(.connectivity))

    await sut.retryLoadDogs() // 1

    XCTAssertEqual(sut.messages, [.status(.loading)])
  }
  1. Need to create the retryLoadDogs() function

Fix the error for Xcode to compile

class DogsViewModel: ObservableObject {
  ...
  func retryLoadDogs() async {
    try? await loadDogs()
  }
  ...

Run tests
They are passing
Can mark this requirement as done

 ========= LOAD DOGS
 [✅] User can retry to load (button) on .connectivity error

Continue with this requirement for succeeded to load but return an empty JSON

 ========= LOAD DOGS
   [ ] Load dogs succeed with 200 response

As usual write the test first

  func test_loadHereos_deliversSuccess200HTTPResponseWithEmptyDogsJSON() async throws {
    let emptyDogsJSON = Data("{\"message\": []}".utf8)
    let expectedEmptyDogs = [DogUI]() // 1
    let (sut, _) = makeSUT(loadDogsResult: .success((emptyDogsJSON, httpResponse(with: 200)))) // 2

    try? await sut.loadDogs()
    waitForUICompletion() // 3

    XCTAssertEqual(sut.messages, [.status(.loading), .status(.loaded(dogs: expectedEmptyDogs))]) // 4
  }
  1. Create the DogUI model that represent the dog requirement to display the UI.
  2. Create the httpResponse(with:) helper function that simulate an http response.
  3. Create the waitForUICompletion() helper function that wait until the async update of UI completes.
  4. Add the .loaded(dogs:) status case.

First the DogUI model

struct DogUI {

}

Second create the httpResponse(with:) and waitForUICompletion() helpers functions

class AsyncAwaitTests: XCTestCase {
  ...
  // MARK: - Helpers
  ...
  private func anyURL() -> URL {
    return URL(string: "https://any-url.com")!
  }

  private func httpResponse(with statusCode: Int) -> HTTPURLResponse {
    return HTTPURLResponse(url: anyURL(), statusCode: statusCode, httpVersion: nil, headerFields: nil)!
  }

  private func waitForUICompletion() {
    let expectation = self.expectation(description: "Expect that ui update on the main thread completes")
    DispatchQueue.main.async {
        expectation.fulfill()
    }
    wait(for: [expectation], timeout: 1)
  }
}

Fourth add the .loaded(dogs:) status case

class DogsViewModel: ObservableObject {
  enum Status: Equatable {
    static func == (lhs: DogsViewModel.Status, rhs: DogsViewModel.Status) -> Bool {
      switch (lhs, rhs) {
      case (.idle, .idle), (.loading, .loading):
        return true
      case (.loaded(dogs: let dogs1), .loaded(dogs: let dogs2)):  👈
        return dogs1 == dogs2 ? true : false // 1
      default:
        return false
      }
    }

    case idle
    case loading
    case loaded(dogs: [DogUI])  👈
  }
  ...
}
  1. The DogUI needs to conform to Equatable to be able to compare
    struct DogUI: Equatable {
    ...
    }
    

Run the test and it fails
The status .loaded has never been called
XCTAssertEqual failed: ("[AsyncAwaitTests.AsyncAwaitTests.(unknown context at $10c899490).DogsViewModelSpy.Message.status(AsyncAwait.DogsViewModel.Status.loading)]") is not equal to ("[AsyncAwaitTests.AsyncAwaitTests.(unknown context at $10c899490).DogsViewModelSpy.Message.status(AsyncAwait.DogsViewModel.Status.loading), AsyncAwaitTests.AsyncAwaitTests.(unknown context at $10c899490).DogsViewModelSpy.Message.status(AsyncAwait.DogsViewModel.Status.loaded(dogs: []))]")

Let’s make it pass
Start by update the status with the loaded dogs

class DogsViewModel: ObservableObject {
  ...
  func loadDogs() async throws {
    ...    
    do {
      let dogs = try await dogService.loadDogs()
      DispatchQueue.main.async {
        self.status = .loaded(dogs: dogs)
      }
    } catch let error as NetworkService.Error {
      ...
    }
  }
  ...
}

After handle the response
Check the status code 200
Parse the object using Decodable

class DogService {
    ...
    func loadDogs() async throws -> [DogUI] {
    do {
      _ = try await networkService.perform(request: loadDogsRequest)
      let (data, response) = try await networkService.perform(request: loadDogsRequest)
      if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
        let decoder = JSONDecoder()
        do {
          let dogsApi = try decoder.decode(DogsApi.self, from: data) // 1
          let dogs = dogsApi.message.map{ DogUI(url: URL(string: $0)) } // 2
          return dogs
        }
        catch {

        }
      }
    } catch {
      throw error
    }

    return []
  }
}
  1. Need to create the DogApi model
  2. Add the url property to the DogUI model

First create the DogApi model

struct DogsApi: Decodable {
  let message: [String]
}

Second add the url property to the DogUI model

struct DogUI: Equatable {
  let url: URL?
}

Run tests
They are passing
Can mark this requirement as done

 ========= LOAD DOGS
 [✅] Load dogs succeed with 200 response

Continue with this requirement for decodable parsing succeeded

 ========= LOAD DOGS
   [ ] Parsing succeed delivers dogs

As usual write the test first

  func test_loadHereos_deliversDogsOnSuccess200HTTPResponse() async throws {
    let validDogsJSON = Data("{\"message\": [\"https://images.dog.ceo/breeds/husky/n02110185_10116.jpg\"]}".utf8)
    let expectedDogs = DogUI(url: URL(string: "https://images.dog.ceo/breeds/husky/n02110185_10116.jpg")!)
    let (sut, _) = makeSUT(loadDogsResult: .success((validDogsJSON, httpResponse(with: 200))))

    do {
      try await sut.loadDogs()
      waitForUICompletion()

      XCTAssertEqual(sut.status, .loaded(dogs: [expectedDogs]))
    } catch {
      XCTFail("Expected to succeeded, got error \(error) instead")
    }
  }

Run tests
They are passing
Can mark this requirement as done

 ========= LOAD DOGS
 [✅] Load dogs succeed with 200 response

Continue with this requirement for dogs names extract from url

 ========= LOAD DOGS
   [ ] Extract dogs names from returned url

As usual write the test first

  func test_dogsName_extractDogsNamesFromPicturesURL() async throws {
    let pictureURL1 = URL(string: "https://images.dog.ceo/breeds/setter-english/n02100735_10038.jpg")!
    let pictureURL2 = URL(string: "https://images.dog.ceo/breeds/komondor/n02105505_1657.jpg")!
    let dog1 = DogUI(url: pictureURL1)
    let dog2 = DogUI(url: pictureURL2)
    let expectDogsName1 = "Setter English"
    let expectDogsName2 = "Komondor"

    XCTAssertEqual(dog1.name, expectDogsName1) // 1
    XCTAssertEqual(dog2.name, expectDogsName2) // 1
  }
  1. Needs to create the name property

Fix the error for Xcode to compile

struct DogUI: Equatable {
  let url: URL?
  👇
  var name: String { 
    if let urlString = url?.absoluteString {
      return urlString
        .components(separatedBy: "/")[4]
        .replacingOccurrences(of: "-", with: " ")
        .capitalized
    } else {
      return "Unknown"
    }
  }
}

Run tests
They are passing
Can mark this requirement as done.

 ========= LOAD DOGS
 [✅] Extract dogs names from returned url

Continue with this requirement for Decodable failure

 ========= LOAD DOGS
   [ ] Parsing failed delivers .invalidData error

As usual write the test first

  func test_loadHereos_deliversErrorOn200HTTPResponseWithInvalidData() async throws {
    let expectedError = NetworkService.Error.invalidData // 1
    let invalidDogsJSON = Data("".utf8)
    let (sut, _) = makeSUT(loadDogsResult: .success((invalidDogsJSON, httpResponse(with: 200))))

    do {
      try await sut.loadDogs()
      XCTFail("Expected to failed the JSON cannot be decoded")
    } catch {
      XCTAssertEqual(error as? NetworkService.Error, expectedError)
    }
  }
  1. Needs to create the .invalidData error case

Fix the error for Xcode to compile

class NetworkService {
  ...
  enum Error: Swift.Error {
    case connectivity
    case invalidData 👈
  }
  ...
}

Run the test and it fails
Handle the new .invalidData case

class DogsViewModel: ObservableObject {
  ...
  func loadDogs() async throws {
  ...
      switch error {
      case .connectivity:
        self.error = .loadDogs
      case .invalidData:  👈
        break
      }
  ...
  }
}

Send .invalidData error on decode failure

class DogService {
  ...
  func loadDogs() async throws -> [DogUI] {
    ...
          do {
          let dogsApi = try decoder.decode(DogsApi.self, from: data)
          let dogs = dogsApi.message.map{ DogUI(url: URL(string: $0)) }
          return dogs
        } catch {
          throw NetworkService.Error.invalidData 👈
        }
    ...
  }
}

Run tests
They are passing
Can mark this requirement as done

 ========= LOAD DOGS
 [✅] Parsing failed delivers .invalidData error

Continue with this requirement for message error on .invalidData error

 ========= LOAD DOGS
   [ ] Display an error message (text) on .invalidData error

As usual write the test first

  func test_load_displayErrorMessageoOnInvalidDataError() async throws {
    let expectedErrorText = "Error parsing dogs"
    let (sut, _) = makeSUT(loadDogsResult: .failure(.invalidData))

    XCTAssertEqual(sut.alertTitle(), "", "Expected to get an empty string as default value")

    do {
      try await sut.loadDogs()
      XCTFail("Expected to failed")
    } catch {
      XCTAssertEqual(sut.error, .parsingDogs) // 1
      XCTAssertEqual(sut.alertTitle(), expectedErrorText)
    }
  }
  1. Needs to create the .parsingDogs error in the view model

Fix the error for Xcode to compile
Add the .parsingDogs error
The function loadDogs must handle this new case
Update the alertTitle() function with this new case

class DogsViewModel: ObservableObject {
  ...
  enum Error: String, Swift.Error, Identifiable {
    case loadDogs
    case parsingDogs 👈
    ...
  func loadDogs() async throws {
      switch error {
      case .connectivity:
        self.error = .loadDogs
      case .invalidData:
        self.error = .parsingDogs 👈
      }
   }
  ...
  func alertTitle() -> String {
    switch error {
    case .loadDogs:
      return "Error loading dogs"
    case .parsingDogs: 👈
      return "Error parsing dogs" 
    case .none:
      return ""
    }
  }
}

Run tests
They are passing
Can mark this requirement as done.

 ========= LOAD DOGS
 [✅] Display an error message (text) on .invalidData error

Continue with this requirement for retry on .invalidData error

 ========= LOAD DOGS
   [ ] User can retry to load (button) on .invalidData error

As usual write the test first

  func test_loadHereos_onParsingError_userCanRetryToLoad() async {
    let (sut, _) = makeSUT(loadDogsResult: .failure(.invalidData))

    await sut.retryLoadDogs()

    XCTAssertEqual(sut.messages, [.status(.loading)])
  }

Run tests
They are passing
The retryLoadDogs() function already exist Can mark this requirement as done.

 ========= LOAD DOGS
 [✅] User can retry to load (button) on .invalidData error

Now let's create the view that implement the view model

struct DogsView: View {
  @ObservedObject var viewModel: DogsViewModel

  var body: some View {
    NavigationView {
      ZStack {
        switch viewModel.status {
        case .idle:
          EmptyView()
        case .loading:
          ProgressView()
        case .loaded(dogs: let dogs):
          ZStack {
            Color(UIColor.secondarySystemBackground)
              .edgesIgnoringSafeArea(.all)
            ScrollView {
              LazyVStack(spacing: 34) {
                ForEach(dogs, id: \.url) { dog in
                  VStack(spacing: 0) {
                    AsyncImage(url: dog.url) { phase in
                      if let image = phase.image {
                        image // Displays the loaded image.
                          .resizable()
                          .scaledToFill()
                          .frame(width: UIScreen.main.bounds.width - 32, height: UIScreen.main.bounds.width, alignment: .center)
                          .clipped()
                      } else if phase.error != nil {
                        Color.red // Indicates an error.
                          .frame(width: UIScreen.main.bounds.width - 32, height: UIScreen.main.bounds.width, alignment: .center)
                      } else {
                        ZStack { // Acts as a placeholder.
                          Color(UIColor.tertiarySystemBackground)
                            .frame(width: UIScreen.main.bounds.width - 32, height: UIScreen.main.bounds.width, alignment: .center)
                          ProgressView()
                            .scaleEffect(4)
                            .progressViewStyle(CircularProgressViewStyle(tint: SwiftUI.Color(UIColor.label).opacity(0.5)))
                        }
                      }
                    }
                    Text(dog.name)
                      .font(.title)
                      .foregroundColor(SwiftUI.Color.primary)
                      .padding()
                  }
                  .background(SwiftUI.Color(UIColor.tertiarySystemBackground))
                  .cornerRadius(20)
                  .shadow(color: Color.black.opacity(0.25), radius: 20, x: 0, y: 3)
                }
              }
            }
            .padding(.top, 32)
          }
        }
      }
      .navigationTitle("Dogs")
    }
    .task {
      try? await viewModel.loadDogs()
    }
    .alert(item: $viewModel.error) { _ in
      return Alert(
        title: Text(viewModel.alertTitle()),
        message: nil,
        dismissButton: .default(Text("RETRY")) {
          Task {
            await viewModel.retryLoadDogs()
          }
        })
    }
  }
}

To preview the result let's create a fake view model
It will first simulate a .connectivity network error
Second an .invalidData parsing error
Third a success response with 3 dogs

Start creating setting the preview

struct DogsView_Previews: PreviewProvider {
  static var previews: some View {
    DogsView(viewModel: fakeViewModel()) // 1
  }
}
  1. Needs to create this fakeViewModel that doesn't exist

Create the fakeViewModel

  static func fakeViewModel() -> DogsViewModel {
    let urlSessionSpy = URLSessionAsyncAwaitSpy( // 1
      results:
        [
          .failure(.connectivity),
          .failure(.invalidData),
          .success((validData(), validResponse())) // 2
        ]
    )
    let networkService = NetworkService(session: urlSessionSpy)
    let service = DogService(networkService: networkService)
    return DogsViewModel(dogService: service)
  }
  1. Need to create the URLSessionAsyncAwaitSpy that will handle all fake results API
  2. Need to create validData() and validResponse() that will simulate the fake valid response

First create the URLSessionAsyncAwaitSpy

  private class URLSessionAsyncAwaitSpy: AsyncAwait {
    var results: [Result<(Data, URLResponse), NetworkService.Error>]

    internal init(results: [Result<(Data, URLResponse), NetworkService.Error>]) {
      self.results = results
    }

    func data(request: URLRequest) async throws -> (Data, URLResponse) {
      return try results.removeFirst().get() // 1
    }
  }
  1. Use the removeFirst() function to return the result and will be ready for the next one

Second create validData() and validResponse()

  static func validData() -> Data {
    let dogsJSON =
    [
      "message":
        [
          "https://images.dog.ceo/breeds/setter-english/n02100735_10038.jpg",
          "https://images.dog.ceo/breeds/retriever-golden/n02099601_6331.jpg",
          "https://images.dog.ceo/breeds/komondor/n02105505_1657.jpg"
        ]
    ]

    do {
      return try JSONSerialization.data(withJSONObject: dogsJSON)
    } catch {
      assertionFailure("Expected to get a valid JSON, got error \(error) instead")
      return Data()
    }
  }

  static func validResponse() -> HTTPURLResponse {
    HTTPURLResponse(url: URL(string: "https://any-url.com")!, statusCode: 200, httpVersion: nil, headerFields: nil)!
  }

Run the preview
First we failed as expected with a .connectivity error
With the expected message create when TDD the view model image.png

Tap the RETRY button the and the .invalidData error appear image.png

Tap the RETRY button and the success with valid data will be displayed image.png


I hope you enjoy this TDD journey with async/await
async/await can be tested using stub (inject result)
Because it needs how it will complete before method execution
Or you will always be block by await
If never completes with success or error

That's it !!

 
Share this