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
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
}
- The
RestaurantViewModel
type not exist - The SUT doesn't have a
load()
function - 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")
}
- The
service
will be the futur object that handles API request - 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
}
}
- Everytime the
load()
function is called, I accumulate the completion - 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
}
}
- The property
isPresentAlert
need to be update according to theerrorDetails
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
}
- The function
completeSuccessfullyWith
not exist - 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)
})
}
- Restaurants
kfc()
,burgerKing()
andmcDonalds()
not exist - 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
}
- The SUT don't have a function
updateSelected(with:)
- The SUT don't have
selectedRestaurant
property - 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")
}
- The SUT don't have the
addToFavorites(restaurant:)
function - 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]))
}
- 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
}
- 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
- Load is a failure
- The user retry
- Load is a success
- Restaurants are displayed
- Add/delete a favorite always failed the first time
- After Add/delete a favorite always succeeded
- 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
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
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 !!