If you are here, it means you must be looking for an option to write a unit test case for a 3rd party framework you might have imported into your class.
Integrating external libraries can supercharge your app, but it often raises a key question: “How can I write unit tests for my code that uses this framework, especially if I can’t easily mock its components?”
The ease of testing largely depends on how the third-party framework is designed.
Scenario 1: The Ideal Case – Third-Party Framework Provides Protocols
Sometimes, you get lucky! A well-designed third-party framework might define its services using protocols. This makes your life much easier because you can directly mock these protocols in your tests.
How it works:
- Your code depends on the protocol provided by the framework.
- In your tests, you create a “mock” object that conforms to the same protocol.
- You inject this mock into your class under test, giving you full control over the dependency’s behavior.
Example:
Let’s say you’re using a third-party framework for payment processing that offers a protocol.
Swift
// --- Imagine this comes from a 3rd Party Framework ---
public protocol ThirdPartyPaymentGateway {
func executePayment(amount: Decimal, cardToken: String, completion: @escaping (Result<String, Error>) -> Void)
}
// (The actual SDK class would conform to the protocol)
public class ActualPaymentSDK: ThirdPartyPaymentGateway { ... }
// --- Your Application Code ---
class OrderService {
private let paymentGateway: ThirdPartyPaymentGateway
init(paymentGateway: ThirdPartyPaymentGateway) {
self.paymentGateway = paymentGateway
}
func placeOrder(amount: Decimal, cardToken: String, completion: @escaping (String) -> Void) {
paymentGateway.executePayment(amount: amount, cardToken: cardToken) { result in
switch result {
case .success(let transactionId):
completion("Order placed successfully! Transaction ID: \(transactionId)")
case .failure(let error):
completion("Order failed: \(error.localizedDescription)")
}
}
}
}
// ------------------ Your Unit Test Code ---------------------
// Mock implementation for the 3rd party protocol
import ThirdPartyPaymentGateway
class MockPaymentGateway: ThirdPartyPaymentGateway {
var executePaymentCalled = false
var paymentAmount: Decimal?
var cardTokenUsed: String?
var paymentResult: Result<String, Error> = .success("mock_transaction_123") // Default mock response
func executePayment(amount: Decimal, cardToken: String, completion: @escaping (Result<String, Error>) -> Void) {
executePaymentCalled = true
paymentAmount = amount
cardTokenUsed = cardToken
completion(paymentResult)
}
}
// In your XCTestCase:
func testPlaceOrder_Success() {
// Arrange
let mockGateway = MockPaymentGateway()
mockGateway.paymentResult = .success("tx_SUCCESS_XYZ")
let orderService = OrderService(paymentGateway: mockGateway)
var resultMessage: String?
let expectation = self.expectation(description: "Payment completion")
// Act
orderService.placeOrder(amount: 49.99, cardToken: "tok_valid") { message in
resultMessage = message
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
// Assert
XCTAssertTrue(mockGateway.executePaymentCalled)
XCTAssertEqual(mockGateway.paymentAmount, 49.99)
XCTAssertEqual(mockGateway.cardTokenUsed, "tok_valid")
XCTAssertEqual(resultMessage, "Order placed successfully! Transaction ID: tx_SUCCESS_XYZ")
}
In this case, because ThirdPartyPaymentGateway
is a protocol, mocking is straightforward.
Scenario 2: The Common Challenge – Third-Party Framework Provides Only Concrete Classes
More often, third-party libraries expose concrete classes directly, without public protocols you can conform to for mocking. Directly instantiating and using these concrete classes in your code makes it difficult to isolate your logic for unit testing. You can’t easily replace the third-party dependency with a test double.
The Solution: The Wrapper Pattern
The most effective way to handle this is to create a “wrapper” around the third-party class. This involves:
- Defining Your Own Protocol: Create a protocol in your codebase that defines the specific interactions your app needs with the third-party service. This protocol acts as an abstraction layer.
- Creating a Wrapper Class: Implement a class (the wrapper) that conforms to your new protocol. This wrapper will internally instantiate and use the concrete third-party class.
- Depending on Your Protocol: Your application code will now depend on your protocol, not the concrete third-party class.
- Mocking Your Protocol: For testing, you can now easily create a mock object that conforms to your protocol.
Example:
Let’s say you’re using a third-party analytics library that only provides a concrete class.
Swift
// --- Imagine this comes from a 3rd Party Framework (NO public protocol) ---
public class ExternalAnalyticsManager {
public init() { /* ... setup ... */ }
public func logUserSignIn(userId: String) {
// Complex internal logic to send analytics event
print("ExternalAnalyticsManager: User \(userId) signed in.")
}
public func logScreenView(screenName: String, parameters: [String: String]) {
print("ExternalAnalyticsManager: Screen '\(screenName)' viewed with params: \(parameters).")
}
}
// --- Your Application Code ---
// 1. Define your custom protocol
protocol AppAnalyticsService {
func trackUserLogin(userId: String)
func trackScreen(name: String, details: [String: String])
}
// 2. Create a Wrapper Class
class ThirdPartyAnalyticsWrapper: AppAnalyticsService {
private let externalManager:ExternalAnalyticsManager
init(externalManager: ExternalAnalyticsManager) {
self.externalManager = externalManager
}
func trackUserLogin(userId: String) {
externalManager.logUserSignIn(userId: userId)
}
func trackScreen(name: String, details: [String: String]) {
externalManager.logScreenView(screenName: name, parameters: details)
}
}
// 3. Your feature code depends on YOUR protocol
class SettingsViewModel {
private let analytics: AppAnalyticsService
init(analytics: AppAnalyticsService) {
self.analytics = analytics
}
func onAppear() {
analytics.trackScreen(name: "SettingsScreen", details: ["version": "1.2.0"])
}
func userLoggedIn() {
// ... logout logic ...
analytics.trackUserLogin(userId: self.userID)
}
}
// ------------------ Your Unit Test Code ---------------
// 4. Mock YOUR protocol
class MockAppAnalyticsService: AppAnalyticsService {
var trackUserLoginCalledWithId: String?
var trackScreenCalledWithName: String?
var trackScreenCalledWithDetails: [String: String]?
var trackScreenCallCount = 0
func trackUserLogin(userId: String) {
trackUserLoginCalledWithId = userId
}
func trackScreen(name: String, details: [String: String]) {
trackScreenCalledWithName = name
trackScreenCalledWithDetails = details
trackScreenCallCount += 1
}
}
// In your XCTestCase:
func testSettingsViewModel_OnAppear_TracksScreen() {
let mockAnalytics = MockAppAnalyticsService()
let viewModel = SettingsViewModel(analytics: mockAnalytics)
viewModel.onAppear()
XCTAssertEqual(mockAnalytics.trackScreenCallCount, 1)
XCTAssertEqual(mockAnalytics.trackScreenCalledWithName, "SettingsScreen")
XCTAssertEqual(mockAnalytics.trackScreenCalledWithDetails, ["version": "1.2.0"])
}
Benefits of the Wrapper Pattern:
- Testability: You gain full control over the dependency in your tests by mocking your own protocol.
- Decoupling: Your core application logic is decoupled from the specifics of the third-party library. If you ever need to switch to a different analytics provider, you only need to create a new wrapper conforming to
AppAnalyticsService
; yourSettingsViewModel
remains unchanged. - Clearer Intent: Your protocol defines exactly what your application needs from the external service, making the interaction explicit.
- API Simplification/Adaptation: The wrapper can also adapt the third-party library’s API to better suit your application’s needs, perhaps by simplifying complex methods or combining multiple calls.
By using these strategies, particularly the wrapper pattern for concrete third-party classes, you can significantly improve the testability and maintainability of your Swift applications.
Leave a Reply