Skip to content
Created by

Testing & mocking

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: v2
plugins:
# 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=Public

The 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 Connect
import Foundation
import 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 Combine
import Connect
import ConnectMocks
import Foundation
import 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
}
}

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 Connect
import ConnectMocks
@testable import ElizaApp // The target containing your application logic
import SwiftProtobuf
import 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.
@MainActor
func 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)
}

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")
}