Unit testing a Swift project is quite different from unit testing a project written in Objective-C. For those that are used to the flexibility of the Objective-C runtime, it may feel as if your hands are tied behind your back.
Access Control
While access control is a very welcome addition with many benefits, it can complicate unit testing, especially if you are new to unit testing. You probably know that you can apply the testable
attribute to an import statement in a test target to gain access to entities that are declared as internal.
import XCTest
@testable import Notes
class NotesTests: XCTestCase {
...
}
While this is a convenient addition, it doesn't give you access to private entities in a test target. This brings us to the question of the day. How do you unit test private entities?
Wrong Question
The short answer to this question is simple. You cannot access private entities from another module and this also applies to test targets. Plain and simple. That is what access control is for.
But that is not the answer to the question. If you ask how to unit test private entities, then you are asking the wrong question. But why is that?
None of Your Business
Why do you declare an entity private? What is your motivation for doing so? Take a look at the following example.
import Foundation
struct AccountViewViewModel {
// MARK: - Properties
let account: Account
// MARK: - Public Interface
var subscriptionAsString: String {
switch account.subscription {
case .monthly: return "Monthly Subscription"
case .yearly: return "Yearly Subscription"
case .trial: return "Trial Subscription"
}
}
var expiresAtAsString: String {
// Parse Date
let date = parse(date: account.expiresAt)
// Initialize Date Formatter
let dateFormatter = DateFormatter()
// Configure Date Formatter
dateFormatter.dateFormat = "YYYY, MMMM"
// Convert Date to String
return dateFormatter.string(from: date)
}
// MARK: - Private Interface
private func parse(date dateAsString: String) -> Date {
let dateFormat: String
if dateAsString.contains("/") {
dateFormat = "YYYY'/'MM'/'dd"
} else {
dateFormat = "YYYYMMdd"
}
// Initialize Date Formatter
let dateFormatter = DateFormatter()
// Configure Date Formatter
dateFormatter.dateFormat = dateFormat
if let date = dateFormatter.date(from: dateAsString) {
print(date)
return date
} else {
fatalError("Incompatible Date Format")
}
}
}
I would like to unit test the AccountViewViewModel
structure. As you can see, the AccountViewViewModel
struct exposes two internal computed properties and it also defines a private method. The expiresAtAsString
computed property offloads some of its work to the private parse(date:)
method. Testing the internal computed properties is straightforward.
// MARK: - Tests for Subscription as String
func testSubscriptionAsString_Monthly() {
let account = Account(expiresAt: "20161225", subscription: .monthly)
let accountViewViewModel = AccountViewViewModel(account: account)
XCTAssertEqual(accountViewViewModel.subscriptionAsString, "Monthly Subscription")
}
func testSubscriptionAsString_Yearly() {
let account = Account(expiresAt: "20161225", subscription: .yearly)
let accountViewViewModel = AccountViewViewModel(account: account)
XCTAssertEqual(accountViewViewModel.subscriptionAsString, "Yearly Subscription")
}
func testSubscriptionAsString_Trial() {
let account = Account(expiresAt: "20161225", subscription: .trial)
let accountViewViewModel = AccountViewViewModel(account: account)
XCTAssertEqual(accountViewViewModel.subscriptionAsString, "Trial Subscription")
}
// MARK: - Tests for Expires at as String
func testExpiresAtAsString_20161225() {
let account = Account(expiresAt: "20161225", subscription: .trial)
let accountViewViewModel = AccountViewViewModel(account: account)
XCTAssertEqual(accountViewViewModel.expiresAtAsString, "2016, December")
}
But how do we test the private method? We cannot access the private method from the test target. But why should we unit test the private method? We marked it as private for a reason. Right? And that brings us to the answer to the question we started with. We don't test private methods.
Unit Testing the Public Interface
By unit testing the public interface of the AccountViewViewModel
struct we automatically or implicitly unit test the private interface of the struct. You have the task to make sure the public interface is thoroughly tested. This means that you need to make sure every code path of the AccountViewViewModel
struct is covered by unit tests. In other words, the suite of unit tests should result in complete code coverage. That includes public, internal, and private entities.
If we enable code coverage in Xcode and we run the unit tests of the AccountViewViewModel
struct, we can see that some code paths are not executed.
This tells us that the unit tests are incomplete. We can ignore the code path for the fatal error. I never unit test code paths that result in a fatal error, but that largely depends on how you use fatal errors in your projects.
We can increase code coverage for the AccountViewViewModel
struct by adding one more unit test.
func testExpiresAtAsString_20161225WithForwardSlashes() {
let account = Account(expiresAt: "2016/12/25", subscription: .trial)
let accountViewViewModel = AccountViewViewModel(account: account)
XCTAssertEqual(accountViewViewModel.expiresAtAsString, "2016, December")
}
Implementation and Specification
It is important to understand that we are testing the specification of the AccountViewViewModel
struct. We are not testing its implementation. While this may sound similar, it is actually very different. We are testing the functionality of the AccountViewViewModel
struct. We are not interested in how it does its magic under the hood.
The key takeaway of this article is that private entities don't need to be unit tested. Unit testing is a form of black-box testing. This means that we don't test the implementation of the AccountViewViewModel
struct, we test its specification.
This doesn't mean that we are not interested in the implementation, though. We need to make sure the suite of unit tests covers every code path of the entity we are testing. Code coverage reports are invaluable to accomplish this.