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.
1 Answer 1
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: