Skip to content

Instantly share code, notes, and snippets.

@younata
Created May 22, 2024 18:03
Show Gist options
  • Save younata/e6fd80cede42fa34d65e09cdfb3e69a1 to your computer and use it in GitHub Desktop.
Save younata/e6fd80cede42fa34d65e09cdfb3e69a1 to your computer and use it in GitHub Desktop.
Example Client-Server implementation, using a theoretical variant of the subsonic API that actually returns JSON
import Observation
enum State<T> {
case loading
case loaded(T)
case failed(String)
}
@MainActor
final class ArtistsController {
let subsonicService: SubsonicService
var state: State<[Artist]> = .loading
init(subsonicService: SubsonicService) { self.subsonicService = subsonicService }
func fetch() async {
state = .loading
do {
state = .loaded(try await subsonicService.artists())
} catch {
state = .failed("Error loading data, sorry!")
}
}
}
import SwiftUI
struct ArtistsList {
let controller: ArtistsController
var body: some View {
ZStack {
switch controller.state {
case .loading:
ProgressView()
case .failed(let reason):
VStack {
Text(verbatim: reason)
Button("Retry?") {
Task {
await controller.fetch()
}
}
}
.padding()
case .loaded(let artists):
List(artists) { artist in
Text(verbatim: artist.name)
}
}
}
.task {
await controller.fetch()
}
}
}
public protocol AuthService: Actor {
func authenticatedRequest(to path: String, arguments: [URLQueryItem]) throws -> URLRequest
}
import Fakes // github.com/quick/swift-fakes
// Fakes provides the Spy and Pendable classes.
final class FakeHTTPClient: HTTPClient {
let dataSpy = Spy<URLRequest, Pendable<Result<(Data, URLResponse), Error>>>(pendingFailure: TestError.uhOh)
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
try await dataSpy(request)
}
}
// Creating a network client
public protocol HTTPClient: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: HTTPClient {}
// Another fake, this time without actually using swift fakes.
import Nimble
actor LoggedInAuthService: AuthService {
func authenticatedRequest(to path: String, arguments: [URLQueryItem]) throws -> URLRequest {
var components = try unwrap(URLComponents(string: "https://example.com"))
// unwrap comes from Nimble, it throws & registers a test failure if the (auto)closure passed in to it returns nil.
// you could also use XCTUnwrap from XCTest which has very similar behavior, but as the author of Nimble, I'm very obviously biased.
components.path = path
components.queryItems = arguments
return URLRequest(url: try unwrap(components.url))
}
}
// Creating a service that uses the network client
public protocol SubsonicService: Sendable {
func artists() async throws -> [Artist]
// (the actual one has more, this is a demonstration)
}
// Creating an implementation of SubsonicService
public struct JSONSubsonicService: SubsonicService {
let client: HTTPClient
let authService: AuthService
func artists() async throws -> [Artist] {
let request = try await authService.authenticatedRequest(to: "/rest/getArtists", arguments: [])
let (data, response) = try await client.data(for: request)
return try JSONDecoder().decode([Artist.self], from: data)
}
}
struct Artist: Identifiable, Equatable {
let id: String
let name: String
// in actuality there's more, but for the sake of example.
}
import Fakes // github.com/quick/swift-fakes
import Nimble // github.com/quick/nimble
import XCTest
@testable import WhateverThisSampleAppIs
final class SubsonicServiceTests: XCTestCase {
func testArtistsWhenLoggedInMakesRequestToArtist() {
// arrange
let client = FakeHTTPClient()
let authService = LoggedInAuthService()
let subject = SubsonicService(client: client, authService: authService)
// act
async let _ = subject.artists()
// assert
await expect(httpClient.dataSpy).toEventually(beCalled(
URLRequest(url: URL(string: "https://example.com/rest/getArtists")!)
))
}
func testArtistsWhenLoggedInAndRequestSucceedsReturnsArtists() throws {
// arrange
let client = FakeHTTPClient()
let authService = LoggedInAuthService()
let subject = SubsonicService(client: client, authService: authService)
client.dataSpy.resolve(success: Data("""
[{"id": "1", "name": "artist 1"}, {"id": "2", "name": "artist 2"}]
""".utf8)) // in actuality, you might want these to be stored on disk as responses.
// act
async let value = subject.artists()
// assert
let artists = try await value
expect(artists).to(equal([
Artist(id: "1", name: "artist 1"),
Artist(id: "2", name: "artist 2")
]))
}
func testArtistsWhenLoggedInAndRequestFailsThrowsError() {
let client = FakeHTTPClient()
let authService = LoggedInAuthService()
let subject = SubsonicService(client: client, authService: authService)
client.dataSpy.resolve(failure: TestError.uhOh)
let task = Task { try await subject.artists() }
// using a task here to allow us to pass the result of this in to nimble's expect
await expect { try await artists.value }.to(throwError(TestError.uhOh))
}
// And then we would also add a test that it throws when incorrect json is sent.
// As well as for invalid http responses (e.g. what if we get a 401? That would be handled as invalid json).
// For completeness, we should also test that it correctly handles credentials not being stored in AuthService.
}
enum TestError: Error {
case uhOh
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment