Better unit testing with Swift
This article was updated on March 31, 2020. It includes feedback from a live session I hosted on testing in Swift. Email me if you want to join the next session!
I’m a big fan of third party testing frameworks. My first foray into testing was with Cedar and eventually Rspec. Now that more of my work has moved to Swift I’ve been enjoying Quick and Nimble.
With the release of Swift 2.0 and Xcode 7 I though it was a great time to try writing my unit tests using only Apple’s XCTest framework. That’s right, no dependencies, no BDD, and no matchers.
Here are some concepts that I’ve picked up over the past couple of months of “real-world” unit testing in Swift. I wish I knew these from the beginning, but hopefully they can help spark some light with seasoned Swift testers too.
Swift unit testing mindset
Before diving into details, let’s highlight the general ideas when writing a Swift unit test.
- Everything is a protocol. Classes do not know about concrete types.
- Mocks implement protocols to keep track of which functions are called with what.
- Static dependencies are
let
variables and injected at initialization time. - Mocks are
Equatable
, enabling use ofXCTAssertEqual()
under test.
Inject static dependencies
The most important of these “rules” is to inject your dependencies. Without a solid approach to DI your tests can become very difficult to write.
To me, dependency injection means an object doesn’t create its own instance variables. The objects are “given” to the class from somewhere else.
For example, let’s look at a service layer object, BoardGameService
. Its role is to retrieve data from an API and pass it along to a parser. Note that I’m assuming everything happens synchronously and cannot fail.
struct BoardGameService {
let api = BoardGameAPI()
let parser = BoardGameParser()
func boardGame(id: UInt) -> BoardGame {
let json = api.json("/board-games/\(id)")
return parser.parse(json)
}
}
How can we test that the correct path was passed to the API? There is no way to assert which objects were passed through because the service is creating the object itself. The dependency is not being injected.
Well, let’s inject it!
struct BoardGameService {
let api: BoardGameAPI
init(api: BoardGameAPI) {
self.api = api
}
// ...
}
Now under test we can pass in our own BoardGameAPI
instance.
class BoardGameServiceTests: XCTestCase {
func test_GettingABoardGame_BuildsThePath() {
let api = BoardGameAPI()
let subject = BoardGameService(api: api)
subject.boardGame(42)
// assert path
}
}
Great, now we can give the object under test, subject
, any instance of BoardGameAPI
that we want. It could be a “testable” subclass, a mock, or even a spied instance.
The best approach here is to make BoardGameAPI
conform to a protocol, so we can inject a mock instance to run assertions on.
Make dependencies protocols
Let’s use a protocol to get around creating messy subclasses of concrete objects under test. In this scenario, we want BoardGameAPI
to conform to something, say API
.
protocol API {
func json(path: String) -> JSON
}
struct BoardGameAPI: API {
func json(path: String) -> JSON {
// ...
}
}
Now that the dependency is straightened out, we need to make the consumer think in terms of protocols, not concrete classes.
struct BoardGameService {
let api: API
init(api: API) {
self.api = api
}
// ...
}
Beautiful. Now under test we create one more object to keep track of which methods get called with what. You only need to add this class to your test bundle.
struct MockAPI: API {
var lastPath: String?
func json(path: String) -> JSON {
lastPath = path
return JSON()
}
}
Note the instance variable, lastPath
, which gets set upon calling json()
. We use this property to assert that the function was called with the correct parameter.
struct BoardGameServiceTests: XCTestCase {
func test_GettingABoardGame_BuildsThePath() {
let api = MockAPI()
let subject = BoardGameService(api: api)
subject.boardGame(42)
XCTAssertEqual(api.lastPath, "/board-games/42")
}
}
To test the interaction with the BoardGameParser
we follow the same path. First create a Parser
protocol and make BoardGameParser
conform to it. Then add lastJSON
to our new MockParser
. It may start to feel tedious having to create three objects for every class. But in the long run you are given fine-grain control over how your mocks and tests work.
Set default initialization parameters
One catch with this approach is that every time you create a BoardGameService
you will need to pass in the dependencies. Under test this is fine (actually, preferable) but in production code it quickly gets annoying.
To remedy this we can use Swift’s default parameter values for the initializer. This means that the object knows what it needs unless it’s told otherwise. Set default values for all static dependencies, think anything other than a delegate or model object.
struct BoardGameService {
let api: API
let parser: Parser
init(api: API = BoardGameAPI(), parser: Parser = BoardGameParser()) {
self.api = api
self.parser = parser
}
// ...
}
Now that all our classes are being injected protocols, let’s move on to the actual tests. What are some best practices with writing Swift unit tests?
Structure and name tests with preconditions, the stimulus, then assertions
When naming tests I make sure to follow one rule.
When I look at this test in six months I should understand what it’s actually testing.
What does that mean in practice? Well let’s break down the BoardGameService
test example.
struct BoardGameServiceTests: XCTestCase { // test class Name
func test_GettingABoardGame_BuildsThePath() { // test case
let api = MockAPI()
let subject = BoardGameService(api: api) // preconditions
subject.boardGame(42) // stimulus
XCTAssertEqual(api.lastPath, "/board-games/42") // assertion
}
}
The test class name matches the name of the object under test. Simple.
I like to name my test case so it is obvious to see what method is being called and what the assertion is. In this example GettingABoardGame
correlates to calling boardGame()
on the service. I like to name these to match the behavior, not necessarily the function name. The last part introduces what the assertions are testing. Here, we want to make sure that the path that is sent to the API is correct, so BuildsThePath
.
The preconditions are where the test setup occurs. This can be injecting dependencies, initializing known values, or even other function calls. The idea is get the subject in a known state for the stimulus. I try to restrict my use of the setUp()
method of XCTest for object instantiation.
Next, the stimulus is where the actual “work” gets done. This is where you call the actual function on the object under test.
Finally, you list out your assertions. By keeping these together and at the end it makes it easy to skim the tests and see what is being tested and where.
As your application’s test suite grows it can becomes harder to read and understand the tests you wrote. By constraining them to a shared format and cadence you are making it easier make changes in the future.
Make value types Equatable
The last part of the Swift testing puzzle is making sure you can run valid assertions on your mocks and fakes.
XCTest provides a number of different assertions dealing with all sorts of primitive types. But the most valuable one, or at least the one I use most, is XCTAssertEqual()
.
XCTAssertEqual(expression1, expression2)
- causes a failure whenexpression1
is not equal toexpression2
When using this to compare Swift objects there is one minor caveat. The two instances you are comparing must conform to the Equatable
protocol.
Apple and others recommend that every Swift value type should be equatable.
Conforming to Equatable
is easy, you just have to implement the ==
function and perform the necessary equality checks. Let’s use the BoardGame
model from earlier as an example. Note that the function is defined outside of the class and extension.
protocol Game {
var name: String { get }
var maximumPlayers: UInt { get }
}
struct BoardGame: Game {
let name: String
let maximumPlayers: UInt
init(name: String, maximumPlayers: UInt) {
self.name = name
self.maximumPlayers = maximumPlayers
}
}
extension BoardGame: Equatable { }
func ==(lhs: BoardGame, rhs: BoardGame) -> Bool {
return lhs.name == rhs.name && lhs.maximumPlayers == rhs.maximumPlayers
}
Under test we can now use XCTAssertEqual()
to ensure our mocks were called with the correct parameters.
struct BoardGameParserTests: XCTestCase {
func test_ParsingValidJSON_ReturnsABoardGame() {
let subject = BoardGameParser()
let json = ["name": "Carcassonne", "maximumPlayers": 5]
let expectedBoardGame = BoardGame(name: "Carcassonne", maximumPlayers: 5)
let actualBoardGame = subject.parse(json)
XCTAssertEqual(actualBoardGame, expectedBoardGame)
}
}
Oh, and if you’re looking for great two player board games I’ve got you covered!
Recap: IDEPEM!
To help remember the Swift Unit Testing Mindset, just think IDEPEM.
- Inject
- Dependencies,
- Everthing’s a
- Protocol,
- Equatable
- Mocks
Remember, you use the Xcode IDE and generate .PEM files for push notifications!
Bonus: Custom XCTest helpers
An easy way to maintain quality in your test suite is to share assertions between tests. This can be accomplished by extracting helper methods to run common assertions.
Learn how to extract common test behavior into helper methods while keeping those pesky failure messages on the right line.