What are the best practices for testing client logic in TCA? #3574
-
Hello everybody! I'm relatively new to TCA. Tried it a couple of years ago last time and now decided to try it again on my new personal project. I've been looking for examples of covering client logic with tests and couldn't really find any. The only test examples I've seen were about covering feature logic and mocking dependency clients. This gives me a feeling as if testing client logic is not a very TCA thing to do? Please correct me if I'm wrong and missing something obvious. Just to give you a more concrete example of what I'm trying to do and how I've approached this so far. I have a feature that depends on a This is the simplified version of my @Reducer
public struct Search {
@ObservableState
public struct State: Equatable {
var searchText: String = ""
var playlists: [Playlist] = []
public init() {}
}
public enum Action: Equatable {
case setSearchText(String)
case searchButtonTapped
case setPlaylists([Playlist])
}
@Dependency(\.searchClient) private var searchClient
public init() {}
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .setSearchText(let searchText):
state.searchText = searchText
return .none
case .searchButtonTapped:
return .run { [searchClient, searchText = state.searchText] send in
let playlists: [Playlist] = try await searchClient.searchPlaylists(searchText)
await send(.setPlaylists(playlists))
}
case .setPlaylists(let playlists):
state.playlists = playlists
return .none
}
}
}
} The simplified version of my public struct SearchClient: Sendable {
public var searchPlaylists: @Sendable (_ query: String) async throws -> [Playlist]
public init(
searchPlaylists: @escaping @Sendable (_ query: String) async throws -> [Playlist]
) {
self.searchPlaylists = searchPlaylists
}
}
extension SearchClient: DependencyKey {
public static let liveValue: Self = {
@Dependency(\.cloudClient) var cloudClient
return Self { query in
let request = SpotifyRequest(
httpMethod: .get,
endpoint: .search,
parameters: ["q": query]
)
let playlistPage: PlaylistPage = try await cloudClient.send(request)
return playlistPage.playlists
}
}()
}
extension DependencyValues {
public var searchClient: SearchClient {
get { self[SearchClient.self] }
set { self[SearchClient.self] = newValue }
}
} And finally, my public struct CloudClient: Sendable {
public var _send: @Sendable (_ request: any HTTPRequest) async throws -> Data
public init(send: @escaping @Sendable (_ request: any HTTPRequest) async throws -> Data) {
self._send = send
}
public func send<T: Decodable & Sendable>(_ request: any HTTPRequest) async throws -> T {
let data = try await _send(request)
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw ClientError.decoding(error.localizedDescription)
}
}
}
extension CloudClient: DependencyKey {
public static let liveValue: CloudClient = {
@Dependency(\.urlSessionClient) var urlSessionClient
@Dependency(\.spotifyTokenClient) var spotifyTokenClient
CloudClient { request in
let makeTokenHeader = {
let token = try await spotifyTokenClient.getAccessToken()
return SpotifyHeader.authorization(accessToken: token)
}
let request = (request as? SpotifyRequest)?.adding(headers: try await makeTokenHeader()) ?? request
let (data, _) = try await urlSessionClient.fetchData(request.urlRequest)
if let error = try? JSONDecoder().decode(SpotifyError.self, from: data) {
throw error
}
return data
}
}()
}
public extension DependencyValues {
var cloudClient: CloudClient {
get { self[CloudClient.self] }
set { self[CloudClient.self] = newValue }
}
} An example of how I test the struct SearchClientTests {
@Test
func search() async throws {
let playlistPage = PlaylistPage.mock(playlists: [.mock(contacts: [.socialMedia("grungegazeman")])])
let data = try JSONEncoder().encode(playlistPage)
try await withDependencies {
$0.cloudClient._send = { _ in data }
} operation: {
let playlists = try await SearchClient.liveValue.searchPlaylists("shoegaze")
#expect(playlists == playlistPage.playlists)
}
}
...
} I'm not particularly happy with this setup. It seems to be a bit clunky and cumbersome that I have to encode my mock before injecting it. Ideally I'd prefer to find a way to just inject it straight away. As I see it, the main blocker to make it happen is that I can't make the closure a generic because Swift doesn't allow it. I considered using a I have a feeling that I'm going against the grain of the TCA philosophy here, even though this use case seems pretty common to me. Can somebody point to a better way to deal with this please? Many Thanks! |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment 3 replies
-
Hi @armanarutiunov,
Nothing about TCA or our Dependencies library forces you to use structs for dependencies. You are absolutely able to use protocols, and if you are more comfortable with that then please do so. However, protocols will not magically solve anything for you. There are always trade offs. Sure, protocols allow you to put a generic in an endpoint: protocol Client {
func send<T: Decodable>(request: any HTTPRequest) async throws -> T
} …but that only complicates mocking: struct MockClient: Client {
func send<T: Decodable>(request: any HTTPRequest) async throws -> T {
// How to construct a T out of thin air?
}
} So this only pushes the problem of struct dependencies to another area with protocol dependencies. But, still, if at the end of the day you prefer protocol dependencies, then it is absolutely possible to do that and nothing about TCA prevents it. Aside from the struct vs protocol discussion, I'm still not certain what the problem is you are describing. It seems you want to write a unit test that uses the live implementation of your dependency rather than a mock? And that you say that TCA is making this difficult? But nowhere in your code do I see an actual TCA test. I only see tests trying to test the live client directly. If you want to write a TCA test that uses the live version of your dependencies, you can do so like this: let store = TestStore(initialState: Search.State()) {
Search()
} withDependencies: {
$0.searchClient = .liveValue
}
await store.send(…) { /* Assertions */ }
await store.receive(…) { /* Assertions */ } And if you want to use all live dependencies, not just a single live dependency, then you can even do this: let store = TestStore(initialState: Search.State()) {
Search()
} withDependencies: {
$0.context = .live
}
await store.send(…) { /* Assertions */ }
await store.receive(…) { /* Assertions */ } |
Beta Was this translation helpful? Give feedback.
That is correct. TCA is not providing any tooling to help in this specific area, but that doesn't mean that you or someone else cannot create that tooling. It's just not a priority for us right now.