Testing & mocking
Generating mocks
Section titled “Generating mocks”Testing generated Connect-Swift APIs is easily achieved
by using the connect-swift-mocks plugin
to generate mock client implementations from your Protobuf
definitions. This plugin supports all of the same
options that the
production connect-swift plugin supports.
This buf.gen.yaml file demonstrates generating production
interfaces and implementations into the Generated folder, and a corresponding
set of mocks into the GeneratedMocks folder:
version: v2plugins: # Generated models - remote: buf.build/apple/swift out: Generated opt: Visibility=Public # Production generated services/methods - remote: buf.build/connectrpc/swift out: Generated opt: - GenerateAsyncMethods=true - GenerateCallbackMethods=true - Visibility=Public # Mock generated services/methods - remote: buf.build/connectrpc/swift-mocks out: GeneratedMocks opt: - GenerateAsyncMethods=true - GenerateCallbackMethods=true - Visibility=PublicThe GenerateAsyncMethods and
GenerateCallbackMethods options
that you specify must match the option(s) you’re using for production
clients.
As an example, consider this Protobuf file:
syntax = "proto3";
package connectrpc.eliza.v1;
service ElizaService { rpc Say(SayRequest) returns (SayResponse) {} rpc Converse(stream ConverseRequest) returns (stream ConverseResponse) {}}
message SayRequest { string sentence = 1;}
message SayResponse { string sentence = 1;}
message ConverseRequest { string sentence = 1;}
message ConverseResponse { string sentence = 1;}When the production connect-swift plugin is invoked, it outputs
2 things for each service:
- A protocol interface ending with
*ClientInterface - A production implementation that conforms to the protocol and ends with
*Client
Click to expand eliza.connect.swift
import Connectimport Foundationimport SwiftProtobuf
public protocol Connectrpc_Eliza_V1_ElizaServiceClientInterface: Sendable { @discardableResult func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers, completion: @escaping @Sendable (ResponseMessage<Connectrpc_Eliza_V1_SayResponse>) -> Void) -> Cancelable
func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers) async -> ResponseMessage<Connectrpc_Eliza_V1_SayResponse>
func `converse`(headers: Headers, onResult: @escaping @Sendable (StreamResult<Connectrpc_Eliza_V1_ConverseResponse>) -> Void) -> any BidirectionalStreamInterface<Connectrpc_Eliza_V1_ConverseRequest>
func `converse`(headers: Headers) -> any BidirectionalAsyncStreamInterface<Connectrpc_Eliza_V1_ConverseRequest, Connectrpc_Eliza_V1_ConverseResponse>}
/// Concrete implementation of `Connectrpc_Eliza_V1_ElizaServiceClientInterface`.public final class Connectrpc_Eliza_V1_ElizaServiceClient: Connectrpc_Eliza_V1_ElizaServiceClientInterface, Sendable { private let client: ProtocolClientInterface
public init(client: ProtocolClientInterface) { self.client = client }
@discardableResult public func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:], completion: @escaping @Sendable (ResponseMessage<Connectrpc_Eliza_V1_SayResponse>) -> Void) -> Cancelable { return self.client.unary(path: "connectrpc.eliza.v1.ElizaService/Say", request: request, headers: headers, completion: completion) }
public func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:]) async -> ResponseMessage<Connectrpc_Eliza_V1_SayResponse> { return await self.client.unary(path: "connectrpc.eliza.v1.ElizaService/Say", request: request, headers: headers) }
public func `converse`(headers: Headers = [:], onResult: @escaping @Sendable (StreamResult<Connectrpc_Eliza_V1_ConverseResponse>) -> Void) -> any BidirectionalStreamInterface<Connectrpc_Eliza_V1_ConverseRequest> { return self.client.bidirectionalStream(path: "connectrpc.eliza.v1.ElizaService/Converse", headers: headers, onResult: onResult) }
public func `converse`(headers: Headers = [:]) -> any BidirectionalAsyncStreamInterface<Connectrpc_Eliza_V1_ConverseRequest, Connectrpc_Eliza_V1_ConverseResponse> { return self.client.bidirectionalStream(path: "connectrpc.eliza.v1.ElizaService/Converse", headers: headers) }}When the mock connect-swift-mocks plugin is invoked, it outputs a
.mock.swift file which includes an implementation ending with *ClientMock
that conforms to the same interface as the production client:
Click to expand eliza.mock.swift
import Combineimport Connectimport ConnectMocksimport Foundationimport SwiftProtobuf
/// Mock implementation of `Connectrpc_Eliza_V1_ElizaServiceClientInterface`.open class Connectrpc_Eliza_V1_ElizaServiceClientMock: Connectrpc_Eliza_V1_ElizaServiceClientInterface, @unchecked Sendable { private var cancellables = [Combine.AnyCancellable]()
/// Mocked for calls to `say()`. public var mockSay = { (_: Connectrpc_Eliza_V1_SayRequest) -> ResponseMessage<Connectrpc_Eliza_V1_SayResponse> in .init(result: .success(.init())) } /// Mocked for async calls to `say()`. public var mockAsyncSay = { (_: Connectrpc_Eliza_V1_SayRequest) -> ResponseMessage<Connectrpc_Eliza_V1_SayResponse> in .init(result: .success(.init())) } /// Mocked for calls to `converse()`. public var mockConverse = MockBidirectionalStream<Connectrpc_Eliza_V1_ConverseRequest, Connectrpc_Eliza_V1_ConverseResponse>() /// Mocked for async calls to `converse()`. public var mockAsyncConverse = MockBidirectionalAsyncStream<Connectrpc_Eliza_V1_ConverseRequest, Connectrpc_Eliza_V1_ConverseResponse>()
public init() {}
@discardableResult open func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:], completion: @escaping @Sendable (ResponseMessage<Connectrpc_Eliza_V1_SayResponse>) -> Void) -> Cancelable { completion(self.mockSay(request)) return Cancelable {} }
open func `say`(request: Connectrpc_Eliza_V1_SayRequest, headers: Headers = [:]) async -> ResponseMessage<Connectrpc_Eliza_V1_SayResponse> { return self.mockAsyncSay(request) }
open func `converse`(headers: Headers = [:], onResult: @escaping @Sendable (StreamResult<Connectrpc_Eliza_V1_ConverseResponse>) -> Void) -> any BidirectionalStreamInterface<Connectrpc_Eliza_V1_ConverseRequest> { self.mockConverse.$inputs.first { !$0.isEmpty }.sink { _ in self.mockConverse.outputs.forEach(onResult) }.store(in: &self.cancellables) return self.mockConverse }
open func `converse`(headers: Headers = [:]) -> any BidirectionalAsyncStreamInterface<Connectrpc_Eliza_V1_ConverseRequest, Connectrpc_Eliza_V1_ConverseResponse> { return self.mockAsyncConverse }}Using generated mocks
Section titled “Using generated mocks”As mentioned in the tutorial, we recommend
having your application consume the *ClientInterface protocols rather than
the concrete types directly. Doing so allows for replacing the concrete
implementations with the generated mock implementations:
final class MessagingViewModel: ObservableObject { private let elizaClient: Connectrpc_Eliza_V1_ElizaServiceClientInterface
init(elizaClient: Connectrpc_Eliza_V1_ElizaServiceClientInterface) { self.elizaClient = elizaClient }
@Published private(set) var messages: [Message] {...}
func send(_ sentence: String) async { let request = Connectrpc_Eliza_V1_SayRequest.with { $0.sentence = sentence } let response = await self.elizaClient.say(request: request, headers: [:]) ... }}To use the generated mocks, you will need to include the
ConnectMocks library which is available in the
Connect-Swift repo alongside the Connect library.
It can be integrated via Swift Package Manager, using the same
GitHub URL and
instructions as the
main Connect library.
You can then write unit tests that inject the mock implementations instead of the production implementations, making validating requests and providing mocked response data easy:
import Connectimport ConnectMocks@testable import ElizaApp // The target containing your application logicimport SwiftProtobufimport XCTest
final class ElizaAppTests: XCTestCase { /// Example test that injects a mock generated client into a unary view model. @MainActor func testUnaryMessagingViewModel() async { let client = Connectrpc_Eliza_V1_ElizaServiceClientMock() client.mockAsyncSay = { request in XCTAssertEqual(request.sentence, "hello!") return ResponseMessage(result: .success(.with { $0.sentence = "hi, i'm eliza!" })) }
let viewModel = MessagingViewModel(elizaClient: client) await viewModel.send("hello!")
XCTAssertEqual(viewModel.messages.count, 2) XCTAssertEqual(viewModel.messages[0].message, "hello!") XCTAssertEqual(viewModel.messages[0].author, .user) XCTAssertEqual(viewModel.messages[1].message, "hi, i'm eliza!") XCTAssertEqual(viewModel.messages[1].author, .eliza) }}Similar tests can be written for streaming, assuming a
BidirectionalStreamingMessagingViewModel that uses the generated async
version of the converse() streaming method:
/// Example test that injects a mock generated client into a bidirectional stream view model.@MainActorfunc testBidirectionalStreamMessagingViewModel() async { let client = Connectrpc_Eliza_V1_ElizaServiceClientMock() client.mockAsyncConverse.outputs = [.message(.with { $0.sentence = "hi, i'm eliza!" })]
let viewModel = BidirectionalStreamingMessagingViewModel(elizaClient: client) await viewModel.send("hello!") await viewModel.send("hello again!")
XCTAssertEqual(viewModel.messages[0].message, "hello!") XCTAssertEqual(viewModel.messages[0].author, .user)
XCTAssertEqual(viewModel.messages[1].message, "hi, i'm eliza!") XCTAssertEqual(viewModel.messages[1].author, .eliza)
XCTAssertEqual(viewModel.messages[2].message, "hello again!") XCTAssertEqual(viewModel.messages[2].author, .user)}Testing with @Sendable closures
Section titled “Testing with @Sendable closures”If your codebase is not yet using async/await and is instead consuming
generated clients that provide completion/result closures which are annotated
with @Sendable, writing tests can prove challenging. For example:
func testGetUser() { let client = Users_V1_UsersMock() client.mockGetUserInfo = { request in return ResponseMessage(result: .success(...)) }
var receivedMessage: Users_V1_UserInfoResponse? client.getUserInfo(request: Users_V1_UserInfoRequest()) { response in // ERROR: Mutation of captured var 'receivedMessage' in concurrently-executing code receivedMessage = response.message } XCTAssertEqual(receivedMessage?.name, "jane")}One workaround for this is to wrap the captured type with a class
that conforms to Sendable. For example:
public final class Locked<T>: @unchecked Sendable { private let lock = NSLock() private var wrappedValue: T
/// Thread-safe access to the underlying value. public var value: T { get { self.lock.lock() defer { self.lock.unlock() } return self.wrappedValue } set { self.lock.lock() self.wrappedValue = newValue self.lock.unlock() } }
public init(_ value: T) { self.wrappedValue = value }}The above error can be solved by updating the test to use this wrapper:
func testGetUser() { let client = Users_V1_UsersMock() client.mockGetUserInfo = { request in return ResponseMessage(result: .success(...)) }
let receivedMessage = Locked<Users_V1_UserInfoResponse?>(nil) client.getUserInfo(request: Users_V1_UserInfoRequest()) { response in receivedMessage.value = response.message } XCTAssertEqual(receivedMessage.value?.name, "jane")}