Whilst protocols certainly have their uses, anybody who has come across this compiler error – “protocol can only be used as a generic constraint because it has Self or associated type requirements” will know that they can be awkward at times. They also require a certain degree of boilerplate code and ceremony to create (the protocol definition itself, followed by the definition of various types with the protocol conformance).
A common use case for protocols is to provide mock dependencies for unit tests. You define a protocol with the interface required by a component, and then create one type which will do the real work in production, and another type that will mock it’s behaviour for the purpose of a unit test. You may have even used some kind of mocking framework to achieve the same effect.
In this article, we’ll investigate a far simpler and more flexible technique. For most use cases, we can do away with the boilerplate and restrictions placed upon us by protocols and instead use simple functions! 🚀
One caveat before continuing. Mocking test dependencies is a somewhat controversial topic, and some would argue it’s not the best way to structure and test your code. We’ll discuss mocking test dependencies here as it makes for a simple demonstration (and lots of people do work that way) but if you’re interested in learning more about why some feel it’s not such a great idea, check out “Test Doubles are a Scam” by Matt Diephouse.
Crypto Exchange Demo
I’ve become a bit of a blockchain and crypto nerd of late, so we’ll look at a simple example of fetching crypto currency market data to get the current price of Bitcoin and Ethereum. This won’t be a working demo, but will help provide some insight into simplifying our dependencies. We’ll need some types to represent fiat currency (USD / GBP), some crypto (BTC / ETH), fetching some market data and a crypto exchange from where we can trade.
enum FiatCurrency {
case gpb
case usd
}
enum CryptoCoin {
case bitcoin
case ethereum
}
struct MarketQuote {
let coin: CryptoCoin
let price: Double
}
struct BuyRequest {
let coin: CryptoCoin
let coinAmount: Double
let currency: FiatCurrency
let fiatCost: Double
let fiatFee: Double
}
Protocol Oriented Approach
Below is an obvious approach using protocols. We define the MarketDataType protocol to which any type can conform. For production we create a MarketData type with the protocol conformance, which is passed into the constructor of the CryptoExchange.
protocol MarketDataType {
func fetchPrice(coin: CryptoCoin, currency: FiatCurrency) -> MarketQuote
}
struct MarketData: MarketDataType {
func fetchPrice(coin: CryptoCoin, currency: FiatCurrency) -> MarketQuote {
// fetch the market data
}
}
struct CryptoExchange {
let market: MarketDataType
let feePercentage: Double
func buy(coin: CryptoCoin, coinAmount: Double, currency: FiatCurrency) -> BuyRequest {
let quote = market.fetchPrice(coin: coin, currency: currency)
let cost = quote.price * coinAmount
let fee = feePercentage * cost
return BuyRequest(coin: coin,
coinAmount: coinAmount,
currency: currency,
fiatCost: cost,
fiatFee: fee)
}
}
In order to test the CryptoExchange, we create a MockMarketData type which conforms to MarketDataType.
struct MockMarketData: MarketDataType {
let coin: CryptoCoin
let price: Double
func fetchPrice(coin: CryptoCoin, currency: FiatCurrency) -> MarketQuote {
MarketQuote(coin: coin, price: price)
}
}
The mock type is then passed into the CryptoExchange to test the correct trade cost and fee is calculated.
import XCTest
@testable import CryptoExchange
class CryptoExchangeTests: XCTestCase {
func testBuyReturnsCorrectCost() {
let mockMarket = MockMarketData(coin: .bitcoin, price: 100_000.00)
let exchange = CryptoExchange(market: mockMarket, feePercentage: 0.03)
let request = exchange.buy(coin: .bitcoin, coinAmount: 0.5, currency: .usd)
XCTAssertEqual(request.fiatCost, 50_000)
}
func testBuyReturnsCorrectFee() {
let mockMarket = MockMarketData(coin: .bitcoin, price: 60_000.00)
let exchange = CryptoExchange(market: mockMarket, feePercentage: 0.03)
let request = exchange.buy(coin: .bitcoin, coinAmount: 0.2, currency: .usd)
XCTAssertEqual(request.fiatFee, 360)
}
}
Functional Approach!
With a functional approach, we can completely remove the need to create the MarketDataType protocol and the definition of the MarketData and MockMarketData types. After all, a protocol with a single function is really just a function with a fancy house to live in! Let’s update the CryptoExchange to take a function rather than a protocol.
struct CryptoExchange {
let fetchPrice: (CryptoCoin, FiatCurrency) -> MarketQuote
let feePercentage: Double
func buy(coin: CryptoCoin, coinAmount: Double, currency: FiatCurrency) -> BuyRequest {
let quote = fetchPrice(coin, currency)
let cost = quote.price * coinAmount
let fee = feePercentage * cost
return BuyRequest(coin: coin,
coinAmount: coinAmount,
currency: currency,
fiatCost: cost,
fiatFee: fee)
}
}
The ‘fetchPrice’ field is now a function that takes a coin and a currency and returns a market quote just as before, except it is no longer a protocol definition. When testing the CryptoExchange, we don’t need those three market data type types anymore for the dependency (i.e. the protocol plus 2x conformance types). We simply pass a function to the constructor for any given situation. This could be for production, for testing or something else entirely. Whoop whoop!
import XCTest
@testable import CryptoExchange
class CryptoExchangeTests: XCTestCase {
func mockPrice(coin: CryptoCoin, currency: FiatCurrency) -> MarketQuote {
MarketQuote(coin: coin, price: 100_000)
}
func testBuyReturnsCorrectCost() {
let exchange = CryptoExchange(fetchPrice: mockPrice, feePercentage: 0.03)
let request = exchange.buy(coin: .bitcoin, coinAmount: 0.5, currency: .usd)
XCTAssertEqual(request.fiatCost, 50_000)
}
func testBuyReturnsCorrectFee() {
let exchange = CryptoExchange(fetchPrice: { _,_ in
return MarketQuote(coin: .bitcoin, price: 100_000)
}, feePercentage: 0.03)
let request = exchange.buy(coin: .bitcoin, coinAmount: 0.2, currency: .usd)
XCTAssertEqual(request.fiatFee, 600)
}
}
This works great with a single function, and you may well be wondering what is the best approach to replace a protocol with multiple functions? In the next article, we’ll look at how this can be achieved using structs and concrete types, which is in fact all the swift compiler is doing for us under the hood when we create a protocol anyway 🧐. Subscribe below to go more in depth on this interesting topic next time! ⬇️⬇️⬇️⬇️