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 either:
- Swift Package Manager, using the same GitHub URL
and instructions as the
main
Connectlibrary. - CocoaPods, using the
Connect-Swift-MocksCocoaPod.
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")}