3
\$\begingroup\$

I have read the last two days through several tutorials about how to mock a HTTP-request against a REST API. Finally I made a prototype for applying, what I have understood from the tutorials.

Here's the relevant code of the actual app-target.

Main UI:

struct ContentView: View { // Create an instance of ContentViewModel, which uses DataService by default. @State var contentVM: ContentViewModel? = nil var body: some View { VStack { Button("Trigger Fetch") { contentVM = ContentViewModel() } List { ForEach(contentVM?.people ?? []) { person in VStack(alignment: .leading) { Text(person.name) .font(.title2) Text("Height: \(person.height)") Text("Mass: \(person.mass)") Text("Gender: \(person.gender)") } } }.listStyle(.plain) } .padding() } } 

ViewModel:

@Observable class ContentViewModel { var people = [Person]() var fetchableImpl: PeopleFetchable init(fetchableImpl: PeopleFetchable = DataService()) { self.fetchableImpl = fetchableImpl self.loadPeople() } func loadPeople() { Task { do { self.people = try await fetchableImpl.fetchPeople() } catch { print(error) } } } } 

The protocol, used for having a common type:

protocol PeopleFetchable { func fetchPeople() async throws -> [Person] } 

Implementation of the PeopleFetchable-protocol, used for production.

struct DataService: PeopleFetchable { func fetchPeople() async throws -> [Person] { let url = URL(string: "https://swapi.dev/api/people") if let url = url { let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode(Response.self, from: data).results } return [] } } 

Tests-target

Implementation of the PeopleFetchable-protocol, used for unit-testing.

struct MockDataService: PeopleFetchable { func fetchPeople() async throws -> [Person] { var mockData = [Person]() mockData.append(Person(name: "Name01", height: "10", mass: "10", gender: "male")) mockData.append(Person(name: "Name02", height: "20", mass: "20", gender: "female")) mockData.append(Person(name: "Name03", height: "30", mass: "30", gender: "male")) mockData.append(Person(name: "Name04", height: "40", mass: "40", gender: "female")) return mockData } } 

The complete unit-test class:

import XCTest @testable import MockHTTPReq final class MockHTTPReqTests: XCTestCase { // Create an instance of ContentViewModel, which uses MockDataService. var contentVM = ContentViewModel(fetchableImpl: MockDataService()) func testFetchPeopleCount() throws { let peopleCount = contentVM.people.count XCTAssert( peopleCount == 4, "count-people shall be 4, is \(peopleCount)") } func testFetchPeopleNameOfFirstPerson() throws { let name = contentVM.people[0].name XCTAssert( name == "Name01", "name of first person shall be 'Name01', is \(name)") } func testFetchPeopleNameOfLastPerson() throws { let name = contentVM.people.last!.name XCTAssert( name == "Name04", "name of last person shall be 'Name04', is \(name)") } func testFetchPeopleHeightOfFirstPerson() throws { let height = contentVM.people.first!.height XCTAssert( height == "10", "height of first person shall be '10', is \(height)") } func testFetchPeopleGenderOfLastPerson() throws { let gender = contentVM.people.last!.gender XCTAssert( contentVM.people.last?.gender == "female", "gender of last person shall be 'female', is \(gender)") } } 

The code works. Respectively: I get the expected results, currently.

But:

  • Is my implementation really correct? Or have I overseen something?

  • Even if it is correct: Is there a better way, composing a mock data-service?

  • What's your opinion about my naming, messages, error-handling, etc.? What would you have done differently and why?

Looking forward to reading your comments and answers.

\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

The problem with the fetchPeople() method (and others like it) is that they are all virtually identical but every time you need to consume a new endpoint, you have to make another one.

Instead, I suggest you have the DataService only contain the common bits that all these methods would have and move the different parts out of the type.

My favorite example is something like this:

class DataService { func response<Result>(_ endpoint: Endpoint<Result>) async throws -> Result { let (data, _) = try await URLSession.shared.data(from: endpoint.request) return try endpoint.response(data) } } struct Endpoint<Response> { let request: URLRequest let response: (Data) throws -> Response } extension Endpoint where Response: Decodable { init(request: URLRequest, decoder: DataDecoder) { self.request = request self.response = { try decoder.decode(Response.self, from: $0) } } } extension Endpoint where Response == Void { public init(request: URLRequest) { self.request = request self.response = { _ in } } } 

Now you can build out any particular endpoint without having to constantly break open the DataService class to add more methods to it. For example, your fetchPeople would look like:

extension Endpoint where Response == [Person] { static let fetchPeople = Endpoint( request: URLRequest(url: URL(string: "https://swapi.dev/api/people")!), response: { try JSONDecoder().decode(PeopleResponse.self, from: $0).results } ) } 

And you could call it like this: self.people = try await service.response(.fetchPeople)

A nice thing about this idea is that you can now crate new endpoints and test them without having to even involve the service. Much less break it open to add new methods that merely duplicate a bunch of code.

Real production code would have more bells and whistles. However, the Endpoint type above is exactly what I have used in production code for some half-a-dozen apps or more. This should give you an idea of what you could do if you break of the notion though.

Here's a couple of great talks on the idea:

\$\endgroup\$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.