18
Access Control, Code Organization & Testing
Written by Eli Ganim
Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as
text.You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.
You declare Swift types with properties, methods, initializers and even other nested types. These elements make up the interface to your code or the API (Application Programming Interface).
As code grows in complexity, controlling this interface becomes an important part of software design. You may wish to create methods that serve as “helpers” to your code, or properties that keep track of internal states that you don’t want as part of your code’s interface.
Swift solves these problems with a feature area known as access control, which lets you control your code’s viewable interface. Access control enables you, the library author, to hide implementation complexity from users.
This hidden internal state is sometimes referred to as the invariant, which your public interface should always maintain. Preventing direct access to the internal state and keeping the invariant valid is a fundamental software design concept known as encapsulation. In this chapter, you will learn what access control is, the problems it solves, and how to apply it.
Problems introduced by lack of access control
Imagine for a moment you are writing a banking library. This library would help serve as the foundation for your customers (other banks) to write their banking software.
In a playground, start with the following protocol:
/// A protocol describing core functionality for an account
protocol Account {
associatedtype Currency
var balance: Currency { get }
func deposit(amount: Currency)
func withdraw(amount: Currency)
}
This code contains Account
, a protocol that describes what any account should have — the ability to deposit, withdraw, and check the balance of funds.
Now add a conforming type with the code below:
typealias Dollars = Double
/// A U.S. Dollar based "basic" account.
class BasicAccount: Account {
var balance: Dollars = 0.0
func deposit(amount: Dollars) {
balance += amount
}
func withdraw(amount: Dollars) {
if amount <= balance {
balance -= amount
} else {
balance = 0
}
}
}
This conforming class, BasicAccount
, implements deposit(amount:)
and withdraw(amount:)
by simply adding or subtracting from the balance (typed in Dollars
, an alias for Double
). Although this code is very straightforward, you may notice a slight issue. The balance
property in the Account
protocol is read-only — in other words, it only has a get
requirement.
However, BasicAccount
implements balance
as a variable that is both readable and writeable.
Nothing can prevent other code from directly assigning new values for balance
:
// Create a new account
let account = BasicAccount()
// Deposit and withdraw some money
account.deposit(amount: 10.00)
account.withdraw(amount: 5.00)
// ... or do evil things!
account.balance = 1000000.00
Oh no! Even though you carefully designed the Account
protocol to only be able to deposit
or withdraw
funds, the implementation details of BasicAccount
make it possible for outside to change the internal state arbitrarily.
Fortunately, you can use access control to limit the scope at which your code is visible to other types, files or even software modules!
Note: Access control is not a security feature that protects your code from malicious hackers. Instead, it lets you express intent by generating helpful compiler errors if a user attempts directly access implementation details that may compromise the invariant, and therefore, correctness.
Introducing access control
You can add access modifiers by placing a modifier keyword in front of a property, method or type declaration.
private(set) var balance: Dollars
Private
The private
access modifier restricts access to the entity it is defined in and any nested type within it — also known as the “lexical scope”. Extensions on the type within the same source file can also access the entity.
class CheckingAccount: BasicAccount {
private let accountNumber = UUID().uuidString
class Check {
let account: String
var amount: Dollars
private(set) var cashed = false
func cash() {
cashed = true
}
init(amount: Dollars, from account: CheckingAccount) {
self.amount = amount
self.account = account.accountNumber
}
}
}
func writeCheck(amount: Dollars) -> Check? {
guard balance > amount else {
return nil
}
let check = Check(amount: amount, from: self)
withdraw(amount: check.amount)
return check
}
func deposit(_ check: Check) {
guard !check.cashed else {
return
}
deposit(amount: check.amount)
check.cash()
}
// Create a checking account for John. Deposit $300.00
let johnChecking = CheckingAccount()
johnChecking.deposit(amount: 300.00)
// Write a check for $200.00
let check = johnChecking.writeCheck(amount: 200.0)!
// Create a checking account for Jane, and deposit the check.
let janeChecking = CheckingAccount()
janeChecking.deposit(check)
janeChecking.balance // 200.00
// Try to cash the check again. Of course, it had no effect on
// Jane’s balance this time :]
janeChecking.deposit(check)
janeChecking.balance // 200.00
Playground sources
Before jumping into the rest of this chapter, you’ll need to learn a new Swift playground feature: source files.
Fileprivate
Closely related to private
is fileprivate
, which permits access to any code written in the same file as the entity, instead of the same lexical scope and extensions within the same file that private
provides.
private init(amount: Dollars, from account: CheckingAccount) { //...
fileprivate init(amount: Dollars, from account: CheckingAccount) { //...
Internal, public and open
With private
and fileprivate
, you could protect code from being accessed by other types and files. These access modifiers modified access from the default access level of internal.
Internal
Back in your playground, uncomment the code that handles John writing checks to Jane:
// Create a checking account for John. Deposit $300.00
let johnChecking = CheckingAccount()
johnChecking.deposit(amount: 300.00)
// ...
Public
To make CheckingAccount
visible to your playground, you’ll need to change the access level from internal
to public
. An entity that is public
can be seen and used by code outside the module in which it’s defined.
public class CheckingAccount: BasicAccount {
public class BasicAccount: Account
// In BasicAccount:
public init() { }
// In CheckingAccount:
public override init() { }
Open
Now that CheckingAccount
and its public members are visible to the playground, you can use your banking interface as designed.
class SavingsAccount: BasicAccount {
var interestRate: Double
init(interestRate: Double) {
self.interestRate = interestRate
}
func processInterest() {
let interest = balance * interestRate
deposit(amount: interest)
}
}
open class BasicAccount: Account { //..
override func deposit(amount: Dollars) {
// LOL
super.deposit(amount: 1_000_000.00)
}
Mini-exercises
- Create a struct
Person
in a new Sources file. This struct should havefirst
,last
andfullName
properties that are readable but not writable by the playground. - Create a similar type, except make it a class and call it
ClassyPerson
. In the playground, subclassClassyPerson
with classDoctor
and make a doctor’sfullName
print the prefix"Dr."
.
Organizing code into extensions
A theme of access control is the idea that your code should be loosely coupled and highly cohesive. Loosely coupled code limits how much one entity knows about another, which in turn makes different parts of your code less dependent on others. As you learned earlier, highly cohesive code helps closely related code work together to fulfill a task.
Extensions by behavior
An effective strategy in Swift is to organize your code into extensions by behavior. You can even apply access modifiers to extensions themselves, which will help you categorize entire code sections as public
, internal
or private
.
private var issuedChecks: [Int] = []
private var currentCheck = 1
private extension CheckingAccount {
func inspectForFraud(with checkNumber: Int) -> Bool {
issuedChecks.contains(checkNumber)
}
func nextNumber() -> Int {
let next = currentCheck
currentCheck += 1
return next
}
}
Extensions by protocol conformance
Another effective technique is to organize your extensions based on protocol conformance. You’ve already seen this technique used in Chapter 16, “Protocols”. As an example, let’s make CheckingAccount
conform to CustomStringConvertible
by adding the following extension:
extension CheckingAccount: CustomStringConvertible {
public var description: String {
"Checking Balance: $\(balance)"
}
}
available()
If you take a look at SavingsAccount
, you’ll notice that you can abuse processInterest()
by calling it multiple times and repeatedly adding interest to the account. To make this function more secure, you can add a PIN to the account.
class SavingsAccount: BasicAccount {
var interestRate: Double
private let pin: Int
init(interestRate: Double, pin: Int) {
self.interestRate = interestRate
self.pin = pin
}
func processInterest(pin: Int) {
if pin == self.pin {
let interest = balance * interestRate
deposit(amount: interest)
}
}
}
@available(*, deprecated, message: "Use init(interestRate:pin:) instead")
@available(*, deprecated, message: "Use processInterest(pin:) instead")
Opaque return types
Imagine you need to create a public API for users of your banking library. You’re required to make a function called createAccount
that creates a new account and returns it. One of the requirements of this API is to hide implementation details so that clients are encouraged to write generic code. It means that you shouldn’t expose the type of account you’re creating, be it a BasicAccount
, CheckingAccount
or SavingsAccount
. Instead you’ll just return some instance that conforms to the protocol Account
.
func createAccount() -> Account {
CheckingAccount()
}
func createAccount() -> some Account {
CheckingAccount()
}
Swift Package Manager
Another powerful way to organize your code is to use Swift Package Manager, or SwiftPM for short. SwiftPM lets you “package” your module so that you or other developers can use it in their code with ease. For example, a module that implements the logic of downloading images from the web is useful in many projects. Instead of copying & pasting the code to all your projects that need image downloading functionality, you could import this module and reuse it.
Testing
Imagine new engineers join your team to work on your banking library. These engineers are tasked with updating the SavingsAccount
class to support taking loans. For that, they will need to update the basic functionally of the code you’ve written. This change is risky since they’re not familiar with the code, and their changes might introduce bugs to the existing logic. An excellent way to prevent this from happening is to write unit tests.
Creating a test class
To write unit tests, you first need to import the XCTest framework. Add this at the top of the playground:
import XCTest
class BankingTests: XCTestCase {
}
Writing tests
Once you have your test class ready, it’s time to add some tests. Tests should cover the core functionality of your code and some edge cases. The acronym FIRST describes a concise set of criteria for useful unit tests. Those criteria are:
func testSomething() {
}
BankingTests.defaultTestSuite.run()
Test Suite 'BankingTests' started at ...
Test Case '-[__lldb_expr_2.BankingTests testSomething]' started.
Test Case '-[__lldb_expr_2.BankingTests testSomething]' passed (0.837 seconds).
Test Suite 'BankingTests' passed at ...
Executed 1 test, with 0 failures (0 unexpected) in 0.837 (0.840) seconds
XCTAssert
XCTAssert
functions ensure your tests meet certain conditions. For example, you can verify that a certain value is greater than zero or that an object isn’t nil
. Here’s an example of how to check that a new account starts with zero balance. Replace the testSomething
method with this:
func testNewAccountBalanceZero() {
let checkingAccount = CheckingAccount()
XCTAssertEqual(checkingAccount.balance, 0)
}
Test Case '-[__lldb_expr_4.BankingTests testNewAccountBalanceZero]' started.
Test Case '-[__lldb_expr_4.BankingTests testNewAccountBalanceZero]' passed (0.030 seconds).
public private(set) var balance: Dollars = 0.0
error: -[BankingTests testNewAccountBalanceZero] : XCTAssertEqual failed: ("1.0") is not equal to ("0.0")
func testCheckOverBudgetFails() {
let checkingAccount = CheckingAccount()
let check = checkingAccount.writeCheck(amount: 100)
XCTAssertNil(check)
}
XCTFail and XCTSkip
If a certain pre-condition isn’t met, you can opt to fail the test. For example, suppose you’re writing a test to verify an API that’s only available on iOS 14 and above. In that case, you can fail the test for iOS simulators running older versions with an informative message:
func testNewAPI() {
guard #available(iOS 14, *) else {
XCTFail("Only available in iOS 14 and above")
return
}
// perform test
}
func testNewAPI() throws {
guard #available(iOS 14, *) else {
throw XCTSkip("Only available in iOS 14 and above")
}
// perform test
}
XCTFail and XCTSkip
If a certain pre-condition isn’t met, you can opt to fail the test or skip it. For example, if you’re writing a test to verify an API that’s only available in iOS 14 and above, you can fail the test for iOS simulators running older version with an informative message:
func testNewAPI() {
guard #available(iOS 14, *) else {
XCTFail("Only availble in iOS 14 and above")
return
}
// perform test
}
Making things @testable
When you import Foundation
, Swift brings in the public interface for that module. You might create a Banking
module for your banking app that imports the public interface. But you might want to check the internal state with XCTAssert.
Instead of making things public that really shouldn’t be, you can do this in your test code:
@testable import Banking
The setUp and tearDown methods
You’ll notice that both test methods start by creating a new checking account, and it’s likely that many of the tests you’d write will do the same. Luckily there’s a setUp
method. This method executes before each test, and its purpose is to initialize the needed state for the tests to run.
var checkingAccount: CheckingAccount!
override func setUp() {
super.setUp()
checkingAccount = CheckingAccount()
}
override func tearDown() {
checkingAccount.withdraw(amount: checkingAccount.balance)
super.tearDown()
}
Challenges
Before moving on, here are some challenges to test your knowledge of access control and code organization. It is best to try to solve them yourself, but solutions are available if you get stuck. These came with the download or are available at the printed book’s source code link listed in the introduction.
Challenge 1: Singleton pattern
A singleton is a design pattern that restricts the instantiation of a class to one object.
Challenge 2: Stack
Declare a generic type Stack
. A stack is a LIFO (last-in-first-out) data structure that supports the following operations:
Challenge 3: Character battle
Utilize something called a static factory method to create a game of Wizards vs. Elves vs. Giants.
let elf = GameCharacterFactory.make(ofType: .elf)
let giant = GameCharacterFactory.make(ofType: .giant)
let wizard = GameCharacterFactory.make(ofType: .wizard)
battle(elf, vs: giant) // Giant defeated!
battle(wizard, vs: giant) // Giant defeated!
battle(wizard, vs: elf) // Elf defeated!
Key points
- Access control modifiers are
private
,fileprivate
,internal
,public
andopen
. Theinternal
access level is the default. - Modifiers control your code’s visible interface and can hide complexity.
-
private
andfileprivate
protect code from being accessed by code in other types or files, respectively. -
public
andopen
allow code access from another module. Theopen
modifier additionally lets you override from other modules. - When you apply access modifiers to extensions, all members of the extension receive that access level.
- Extensions that mark protocol conformance cannot have access modifiers.
- The keyword
available
can be used to evolve a library by deprecating APIs. - You use unit tests to verify your code works as expected.
-
@testable import
lets you test internal API.