This article covers unit testing and UI testing, which together play an important role not only in proving that code is correct at the unit level but also in demonstrating that the application meets user requirements. While unit testing seeks to create a rapid and regular feedback loop for developers to gain confidence in correctness of the code, UI testing validates the application as a whole from an end user’s perspective to ensure that the final product performs as expected by users.
Related article: Test-Driven Development (TDD) in Swift
Contents
What is unit testing
Unit testing is the process of verifying that individual units of code perform as expected. It is the most basic level of testing done by the developers themselves as they write the code. A helpful acronym for desirable properties of unit tests is FIRST. This requires that unit tests should be:
- (F)ast: Unit tests should execute very quickly so they can be run every time a change is made to the code. If there are hundreds or even thousands of unit tests, they should execute in no more than a few seconds so the tests can be run as often as required.
- (I)solated: Each unit test should run independently. It should have its own environment, including instances of any required fixtures, not affected by or dependent on any other test.
- (R)epeatable: Unit tests should be capable of being repeated and the results should not change from one execution to another. This requires that unit tests should not be affected by any environmental factors or dependencies that cannot be controlled.
- (S)elf-validating: Each unit test should be able to validate on its own, and in an automated manner, whether it passed or not. There should be no need for manual or external validation.
- (T)horough: Unit tests should validate all possible paths through the code, typically starting with the happy path but also including edge cases, and tests for unexpected or invalid inputs to ensure that the code produces the expected results, including throwing appropriate errors when expected to do so.
What is UI testing
UI testing validates the functionality of the application as presented to users. This enables direct testing of user journeys through the application by simulating user interaction with UI components such as buttons, text fields, option pickers, etc., and validating the state of relevant UI components during and after these interactions. UI tests can, and should, be written directly from user requirements expressed as use cases, user stories, or whatever other artefact is used to capture user requirements.
UI tests can be seen as the ultimate test of functionality and integration. This is because while we can perform unit tests to validate that a unit of code works as expected, or integration tests to validate that certain parts of the application work together, neither unit tests nor integration tests test the application as a whole. If, therefore, we get failing UI tests for a certain user journey when all unit tests and integration tests related to that functionality have passed, it could indicate that the unit or integration tests are not covering the full scope of the required functionality (missing tests) or not testing the correct functionality (incorrect tests).
Unlike unit tests, which typically have a single assertion or a small number of related assertions, UI tests can verify in one test either the presence of all the UI elements expected to be found in one screen (or in one part of the screen for more complex screen layouts) or the steps a user would take to run through a particular journey, by tapping buttons and interacting with other UI elements in the expected way, and verifying results as they appear on the screen. Also unlike unit tests, UI tend to be quite slow since performing a UI test requires the application to be initialized and launched, and the test needs to wait for the operation being performed to complete and the UI to update before performing the required validations.
Test first or last
There is a debate sometimes on whether tests should be written before or after the code to be tested is written. The three common approaches are:
- Test last: In this approach, tests are written after the relevant code has been written.
- Test first: This approach requires writing tests before the code to be tested is written.
- Test-Driven Development (TDD): This is a specific methodology of using tests to drive development of code. TDD leads to writing test and production code in tandem, using small incremental cycles consisting of three steps: Red – write just enough test code until you get a failing test, where failure of the test code to compile is considered a failing test; Green – write just enough production code to make the failing test pass; and, Refactor – improve the code by changing its internal structure without altering its external behaviour, and without breaking any tests.
There are pros and cons of each approach, and the purpose of this article is not to perform a comparative analysis of testing approaches. The bottom line is that whether tests are written before the code is written, as the code is being written, or after the code has been written, it is important that code should be tested thoroughly and regularly to detect issues as early as practically possible rather than being allowed to snowball. It is also important that code is testable by design, and significant refactoring or redesign of production code is not required to be able to test it thoroughly.
Naming and documenting tests
There is no right or wrong way to name tests and document test code. It is important, however, for a developer or development team to adopt appropriate naming and documentation conventions for their test code, and to consistently follow these conventions to make test code clear and readable.
It is useful, and customary, to use a consistent term for the system being tested. A common convention, which will be adopted in this article, is to use the term sut
for the ‘system under test’. This will typically be an instance of a struct or a class for unit tests, and an instance of the application for UI tests.
Test code should be distributed among classes in a logical manner, and each test class should be named in a way that clarifies its purpose and the role it plays in the overall scheme of things. The convention that we will follow in this article for unit tests is to use a separate test class for each unit of code to be tested, and for the name of each test class to consist of the name of the unit being tested, followed by the word Tests
. For UI tests, the name of each test class will consist of the name of the screen, functionality, or user journey being tested, followed by the term UITests
.
Names of test methods provide an opportunity to convey important information about their purpose. In this article, we will make unit test method names fairly descriptive, consisting of at least two and at most three parts, separated by underscores. The first (mandatory) part will be composed of the word test
followed by the name of the property or method being tested, and any inputs provided to the method. The second (optional) part will be used for any preconditions of the test. The third (mandatory) part will specify the expected outcome. UI test names in this article will simply consist of the word test
followed by the name(s) of the UI component(s) or user interaction(s) being tested.
The above approach can sometimes lead to rather long unit test method names but has the benefit of a reader being able to surmise what each method does without having to read through its implementation. One way to avoid long test method names is to remove the expected outcome from the name of the test method and include it in the assertion(s) made by the test using the optional message
parameter, which is provided by all XCTest
assertions. These approaches are not mutually exclusive, however. Even if the name of a test method includes its expected outcome, the message can be used to provide further detail or context for an assertion, or any other aspect of the test, that cannot be included in the name of the method.
A typical unit test involves initializing any values and/or fixtures needed by the test and, if required, performing the steps needed for the sut
to fulfill any preconditions of the test. Then the test is performed and results are verified. Not all tests include all three steps, however, and some simple tests may just consist of a single assertion. A consistent methodology to delineate these steps can make test code more clear and readable. In this article, the three aforementioned unit test steps, where present, will be indicated using the following terms: Given – for initializing any values or fixtures needed for the test, and performing any actions required to prepare the sut
for the test; When – for the step or steps where the sut
is exercised as required by the test; and, Then – for verifying the results.
The UI tests we show will also use Given, When, and Then blocks to indicate the preparation, action, and verification steps of a test. By their nature, however, UI tests often tend to test multi-step workflows and validate not only how the UI should look at the end of the workflow but also how it is expected to change during execution of the workflow. As a result, a single UI test may have multiple Given, When or Then blocks to show the steps involved in preparing and testing the workflow, and the results expected at various stages.
What, and what not, to test
Units of code typically expose certain APIs to be used by other parts of the application. Implementation details should be encapsulated so that they can be changed without affecting anything else. A unit of code should be tested only using its exposed API. Any attempt to directly test implementation details will create coupling between tests and these implementation details, causing tests to fail when the implementation is changed as a result of refactoring even though the external behaviour of the unit of code remains unchanged. As a rule of thumb, therefore, unit tests should only have to change when the API exposed by the unit is changed, and not for changes in implementation details.
By the same token, not every unit of code in the application should be tested directly. In Swift, especially when using value types, it is common to use one or more types to compose another type where the types used in the composition exist solely to support the functionality of the composed type, and their existence and use is completely encapsulated. Such types should be considered implementation details, which can be refactored, even inlined, without affecting the external behaviour of the composed type. We may also extend types in the standard library to support some functionality required by the application. In cases where such an extension is created solely to support a particular unit of code, and its existence and use is encapsulated, the extension should be considered an implementation detail of the unit of code it supports.
Such implementation details should not be tested directly. Otherwise, we will get failing tests each time we refactor a unit of code in a way that changes either the implementation of one or more of the types used in its composition or a bespoke extension that it uses. Instead, we should let these implementation details get tested when we test the unit of code that uses them. We will see examples of such implementation details, and how they can be encapsulated, and indirectly tested, when we get into the case study in the next section. Test coverage tools can be used, if required, to confirm that all relevant code is being tested either directly or indirectly.
Case study
In the remainder of this article, we will build a small application from scratch, with unit tests and UI tests. The example application is a simple calculator, which allows the user to enter integers, and perform the four basic arithmetic operations, in the order in which the operations are entered. Our brief is to build the calculator in such a way that it is easy to configure the button layout. This is a subset of the example application used in an earlier article.
The order of presentation of code will be to show each unit of code to be tested along with any types, and extensions on types, that represent its implementation details, followed by the unit tests for that unit. UI tests will be presented after all the code and unit tests have been shown. It must be noted that this is only the order of presentation, and it says nothing about when the various tests were actually written. This is to avoid creating a bias for readers who may subscribe to a particular approach of writing tests.
1. Unit testing the model
We start by looking at the kinds of input that the calculator will handle. This includes:
- Numeric input in the form of single digits from
0
to9
- Arithmetic operations
- Instructions to evaluate the current operation, and to clear the contents of the calculator
Since the numeric input has only ten possible values, we will create a domain-specific type called Digit
, which is implemented by means of an enum, with all possible values as its cases, as shown below.
enum Digit: Int, CaseIterable, CustomStringConvertible {
case zero, one, two, three, four, five, six, seven, eight, nine
var description: String {
"\(rawValue)"
}
}
The cases of Digit
get implicitly assigned integer raw values of 0
to 9
respectively. A failable initializer also gets automatically synthesized, which accepts an integer raw value and returns the corresponding Digit
value wrapped in an optional if the raw value is valid, and nil
otherwise. Digit
also conforms to CustomStringConvertible
, with the string representation of each case being its integer raw value as a string.
Now let’s look at the tests for the Digit
type. In this article, we will present each test class in steps. First, we will show the class with the definition of the sut
and any setup and teardown code. Then we will show the test methods in the class one at a time, so the purpose of each method can be discussed.
Testing the Digit
type is quite simple. We only need to test this type via its allCases
property, which is static
and does not require an instance to be created . We also don’t need any setup or teardown code. We just make sut
a typealias
for the Digit
type, as shown in the test class below.
class DigitTests: XCTestCase {
typealias sut = Digit
}
Now we look at the tests in this class. The first test verifies the presence and ordering of all cases of the enum.
func testAllCases_hasValuesZeroToNineInAscendingOrder() {
XCTAssertEqual(sut.allCases, [.zero, .one, .two, .three, .four, .five, .six, .seven, .eight, .nine])
}
Next we test the raw value of each case.
func testAllCasesRawValue_hasIntegers0To9InAscendingOrder() {
XCTAssertEqual(sut.allCases.map({ $0.rawValue }), Array(0...9))
}
Finally, we test the custom string representation of each case.
func testAllCasesCustomStringRepresentation_hasStringsForIntegers0To9InAscendingOrder() {
XCTAssertEqual(sut.allCases.map(String.init), (0...9).map(String.init))
}
Next, we look at how we will represent arithmetic operations. Since we need to support the four arithmetic operations – addition, subtraction, multiplication, and division – we use an enum here as well.
enum ArithmeticOperation: CaseIterable {
case addition, subtraction, multiplication, division
}
As with the Digit
type, we will test ArithmeticOperation
only via its allCases
property, so we make sut
a typealias
for the ArithmeticOperation
type, as shown in the test class below.
class ArithmeticOperationTests: XCTestCase {
typealias sut = ArithmeticOperation
}
Here, the only test we need is for the presence of cases for the four arithmetic operations.
func testAllCases_hasValuesForAdditionSubtractionMultiplicationAndDivision() {
XCTAssertEqual(sut.allCases, [.addition, .subtraction, .multiplication, .division])
}
Having dealt with the inputs, we move on to the calculator. Shown below is a Calculator
type which accepts input in the form of Digit
and ArithmeticOperation
values, and also accepts instructions to evaluate the present operation, and to clear the contents of the calculator. Here is the code.
struct Calculator: Equatable {
private var newNumber: Decimal?
private var expression: ArithmeticExpression?
private var result: Decimal?
var number: Decimal? {
newNumber ?? expression?.number ?? result
}
mutating func setDigit(_ digit: Digit) {
guard !(newNumber == nil && digit == .zero) else { return }
let numberString = newNumber.map() { "\($0)" } ?? ""
newNumber = Decimal(string: numberString.appending("\(digit)"))
}
mutating func setOperation(_ operation: ArithmeticOperation) {
guard var numberToUse = newNumber ?? result else { return }
if let existingExpression = expression {
numberToUse = existingExpression.evaluate(with: numberToUse)
}
expression = ArithmeticExpression(number: numberToUse, operation: operation)
newNumber = nil
}
mutating func evaluate() {
guard let numberToUse = newNumber, let expressionToEvaluate = expression else { return }
result = expressionToEvaluate.evaluate(with: numberToUse)
newNumber = nil
expression = nil
}
mutating func allClear() {
newNumber = nil
expression = nil
result = nil
}
}
The implementation of Calculator
uses an ArithmeticExpression
type, which exists solely to support Calculator
and its use is completely encapsulated within it. ArithmeticExpression
is thus considered an implementation detail of Calculator
. We can refactor it in any way, even inline it in Calculator
, without affecting any other part of the application as long as the API exposed by Calculator
remains unchanged. Accordingly, we include the ArithmeticExpression
type definition in the same source file as Calculator
and use the private
access label to hide it from other parts of the application, as shown below.
private struct ArithmeticExpression: Equatable {
var number: Decimal
var operation: ArithmeticOperation
func evaluate(with secondNumber: Decimal) -> Decimal {
switch operation {
case .addition:
return number + secondNumber
case .subtraction:
return number - secondNumber
case .multiplication:
return number * secondNumber
case .division:
return number / secondNumber
}
}
}
What this means, as noted earlier in this article, is that we only need to directly test the Calculator
type. The ArithmeticExpression
type will get tested, along with the other implementation details, as we test the various aspects of the functionality of the Calculator
type using its exposed API.
Let us now look at the unit tests for the Calculator
type. Unlike the DigitTests
and ArithmeticOperationTests
classes that we saw earlier, the CalculatorTests
class follows a more conventional pattern, where sut
is defined as a variable which can hold an instance of Calculator
. Note that sut
is an implicitly unwrapped optional, which is initialized in the setUp()
method, and set to nil
in the tearDown()
method. This ensures that each test gets its own instance of Calculator
, making the tests independent of each other. Here is the test class.
class CalculatorTests: XCTestCase {
private var sut: Calculator!
override func setUp() {
sut = Calculator()
}
override func tearDown() {
sut = nil
}
}
The first test in CalculatorTests
verifies that the initial value of the number
property is nil
.
func testNumber_isNil() {
XCTAssertNil(sut.number)
}
We have a few tests pertaining to setting digits in the calculator. First, we test that when we set a single non-zero digit, the value of the number
property is a number consisting of that digit.
func testSetDigitWithNonZeroDigit_createsNumberWithNewDigit() {
// Given
let nonZeroDigit: Digit = .five
// When
sut.setDigit(nonZeroDigit)
// Then
XCTAssertEqual(sut.number, Decimal(nonZeroDigit))
}
Note that we have used an initializer of Decimal
that takes a Digit
parameter and uses its integer raw value to initialize a Decimal
value. This is an example of a helper function, which can make test code simpler and easier to read. We define this convenience initializer for Decimal
in the following extension on Decimal
included in the test target.
extension Decimal {
init(_ digit: Digit) {
init(digit.rawValue)
}
}
The next test is for setting a non-zero digit when a non-zero digit has previously been set. This appends the new digit to the existing value of the number
property. This is the first test where we have used the optional middle part of a test method name to specify the precondition for the test.
func testSetDigitWithNonZeroDigit_nonZeroDigitSet_appendsNewDigitToExistingNumber() {
// Given
let firstNonZeroDigit: Digit = .seven
let secondNonZeroDigit: Digit = .five
sut.setDigit(firstNonZeroDigit)
// When
sut.setDigit(secondNonZeroDigit)
// Then
XCTAssertEqual(sut.number, Decimal(string: "\(firstNonZeroDigit)\(secondNonZeroDigit)"))
}
When building a number, the calculator should ignore any leading zero. We test this by setting a zero and checking that the value of the number
property remains nil
.
func testSetDigitWithZero_hasNilNumber() {
// When
sut.setDigit(.zero)
// Then
XCTAssertNil(sut.number)
}
While any leading zero should be ignored, non-leading zeroes should be appended to the existing number. We test this next by setting a zero when a non-zero digit has previously been set.
func testSetDigitWithZero_nonZeroDigitSet_appendsZeroToExistingNumber() {
// Given
let nonZeroDigit: Digit = .six
sut.setDigit(nonZeroDigit)
// When
sut.setDigit(.zero)
// Then
XCTAssertEqual(sut.number, Decimal(string: "\(nonZeroDigit)0"))
}
The next group of tests covers setting and evaluating arithmetic operations.
The first test in this group is for setting an arithmetic operation in a Calculator
instance in which a non-zero digit has been set. Note that we assert only that the value of the number
property of the Calculator
instance remains unchanged, which is all we can observe using the exposed API. This is because, for reasons noted earlier in this article, we will conduct all our unit testing using only the exposed API of the unit being tested, to avoid coupling our tests with implementation details.
func testSetOperation_nonZeroDigitSet_hasUnchangedNumber() {
// Given
let nonZeroDigit: Digit = .nine
sut.setDigit(nonZeroDigit)
// When
sut.setOperation(.random)
// Then
XCTAssertEqual(sut.number, Decimal(nonZeroDigit))
}
Since the outcome of the above test is not affected by our choice of arithmetic operation, we have set an operation chosen at random from the cases of ArithmeticOperation
. This is enabled by the following extension on ArithmeticOperation
, which we define in the test target.
extension ArithmeticOperation {
static var random: ArithmeticOperation {
allCases[Int.random(in: 0..<allCases.count)]
}
}
We know that it requires two numbers to execute an arithmetic operation, so the next test sets a non-zero digit in a Calculator
instance in which we have previously set a non-zero digit and an arithmetic operation. Again, we assert only that the value of the number
property is a number consisting of the new digit.
func testSetNonZeroDigit_nonZeroDigitAndOperationSet_hasNumberWithNewDigit() {
// Given
sut = sut.settingDigit(.randomNonZero).settingOperation(.random)
let newNonZeroDigit: Digit = .three
// When
sut.setDigit(newNonZeroDigit)
// Then
XCTAssertEqual(sut.number, Decimal(newNonZeroDigit))
}
The outcome of the above test is not affected by the choice of the non-zero digit and the arithmetic operation used to prepare the sut
for the test. This is why we have used a randomly chosen arithmetic operation, and a randomly chosen non-zero digit. We had shown earlier the implementation of the method used to get a random arithmetic operation. Shown below is the method used to get a random non-zero digit, which is defined in an extension on Digit
included in the test target.
extension Digit {
static var randomNonZero: Digit {
let nonZeroCases = allCases.filter() { $0 != .zero }
return nonZeroCases[Int.random(in: 0..<nonZeroCases.count)]
}
}
Note the methods settingDigit(_:)
and settingOperation(_:)
that we have used above to prepare the sut
for the test to be performed. We define these helper methods, and two others that we will use later, by means of the following extension on Calculator
included in the test target.
extension Calculator {
func settingDigit(_ digit: Digit) -> Calculator {
var calculator = self
calculator.setDigit(digit)
return calculator
}
func settingOperation(_ operation: ArithmeticOperation) -> Calculator {
var calculator = self
calculator.setOperation(operation)
return calculator
}
func evaluating() -> Calculator {
var calculator = self
calculator.evaluate()
return calculator
}
func clearing() -> Calculator {
var calculator = self
calculator.allClear()
return calculator
}
}
Finally, we get to the tests that verify the ability of Calculator
to evaluate arithmetic operations. Since the same steps will be used for testing the four arithmetic operations, we define a verification method. Verification methods provide a way to encapsulate test steps and assertions to be used in more than one test, to reduce duplication in test code. We define the following verification method in an extension on CalculatorTests
, as shown below.
extension CalculatorTests {
private func verifyEvaluatingOperation(_ operation: ArithmeticOperation, file: StaticString = #file,
line: UInt = #line, action: (Decimal, Decimal) -> Decimal) {
// Given
let firstNonZeroDigit: Digit = .eight
let secondNonZeroDigit: Digit = .four
sut = sut.settingDigit(firstNonZeroDigit).settingOperation(operation).settingDigit(secondNonZeroDigit)
// When
sut.evaluate()
// Then
XCTAssertEqual(sut.number, action(Decimal(firstNonZeroDigit), Decimal(secondNonZeroDigit)), file: file, line: line)
}
}
Note the file
and line
parameters of the verification method, which are used in the assertion. All XCTest
assertions can include these parameters to indicate where a test failure occurred. With the #file
and #line
default values, the test method that calls the verification method does not need to, and normally should not, specify values for these parameters. By virtue of the default values, any test failure will be reported in the test method at the point where the verification method is called.
Next, we show the methods in CalculatorTests
which verify evaluation of the four arithmetic operations, using the above verification method.
func testEvaluate_firstNonZeroDigitAndAdditionOperationAndSecondNonZeroDigitSet_hasNumberWithResultOfAddition() {
verifyEvaluatingOperation(.addition) { $0 + $1 }
}
func testEvaluate_firstNonZeroDigitAndSubtractionOperationAndSecondNonZeroDigitSet_hasNumberWithResultOfSubtraction() {
verifyEvaluatingOperation(.subtraction) { $0 - $1 }
}
func testEvaluate_firstNonZeroDigitAndMultiplicationOperationAndSecondNonZeroDigitSet_hasNumberWithResultOfMultiplication() {
verifyEvaluatingOperation(.multiplication) { $0 * $1 }
}
func testEvaluate_firstNonZeroDigitAndDivisionOperationAndSecondNonZeroDigitSet_hasNumberWithResultOfDivision() {
verifyEvaluatingOperation(.division) { $0 / $1 }
}
Having shown the tests for setting and evaluating single arithmetic operations, we move on to tests for scenarios where a user wants to chain operations, either by using the result of a previous operation to start a new one or by starting the next operation without having to evaluate the previous one. Accordingly, the next test is for a scenario where the result of evaluating a previous operation is available, which allows an operation to be set without setting a new non-zero digit, and evaluated by setting a single non-zero digit after the operation has been set.
func testSetOperationAndNonZeroDigitAndEvaluate_previousOperationEvaluated_usesPreviousResultForNewOperation() {
// Given
sut = sut.settingDigit(.five).settingOperation(.multiplication).settingDigit(.six).evaluating()
let previousResult = Decimal(.five) * Decimal(.six)
let newNonZeroDigit: Digit = .three
// When
sut.setOperation(.division)
sut.setDigit(newNonZeroDigit)
sut.evaluate()
// Then
XCTAssertEqual(sut.number, previousResult / Decimal(newNonZeroDigit))
}
Next we show the test for chaining a new operation with the previous one without having to evaluate the previous operation.
func testSetOperationAndNonZeroDigitAndEvaluate_nonZeroDigitAndOperationAndNonZeroDigitSet_evaluatesPreviousOperationAndUsesResultForNewOperation() {
// Given
let firstNonZeroDigit: Digit = .eight
let secondNonZeroDigit: Digit = .two
sut = sut.settingDigit(firstNonZeroDigit).settingOperation(.addition).settingDigit(secondNonZeroDigit)
let newNonZeroDigit: Digit = .three
// When
sut.setOperation(.multiplication)
sut.setDigit(newNonZeroDigit)
sut.evaluate()
// Then
XCTAssertEqual(sut.number, (Decimal(firstNonZeroDigit) + Decimal(secondNonZeroDigit)) * Decimal(newNonZeroDigit))
}
The last group of tests in CalculatorTests
pertains to being able to clear the contents of the calculator. Note that in all these tests, we use random digits and arithmetic operations since the actual digits and arithmetic operations have no bearing on the outcome of the tests.
The first test is for the scenario where only a non-zero digit has been set.
func testAllClear_nonZeroDigitSet_hasNilNumber() {
// Given
sut.setDigit(.randomNonZero)
// When
sut.allClear()
// Then
XCTAssertNil(sut.number)
}
The next scenario is when a non-zero digit and an operation have been set.
func testAllClear_nonZeroDigitAndOperationSet_hasNilNumber() {
// Given
sut = sut.settingDigit(.randomNonZero).settingOperation(.random)
// When
sut.allClear()
// Then
XCTAssertNil(sut.number)
}
Finally, we look at the scenario where an operation has been evaluated.
func testAllClear_operationEvaluated_hasNilNumber() {
// Given
sut = sut.settingDigit(.randomNonZero).settingOperation(.random).settingDigit(.randomNonZero).evaluating()
// When
sut.allClear()
// Then
XCTAssertNil(sut.number)
}
This completes the tests we need for the model. As noted earlier, it is part of our brief to make the calculator button layout easily configurable. We look at this next.
2. Unit testing the configuration
We will use a ButtonConfiguration
type to represent button configuration information, which includes labels for the buttons and the number of columns to be used for button layout. To make button configuration easy to change, we can load configuration information from a file or store configuration preferences in user defaults. To keep things simple, we will load button configuration information from a JSON
file. This is done by the ButtonConfiguration
type shown below, which conforms to the Decodable
protocol, and has a static
property called decoded
, with a ButtonConfiguration
value decoded from the ‘ButtonConfiguration.json
’ file in the main bundle.
struct ButtonConfiguration: Equatable, Decodable {
let labels: [String]
let columnCount: Int
static let decoded = Bundle.main.decode(ButtonConfiguration.self, from: "ButtonConfiguration.json")
}
The actual decoding is done by the decode(_:from:)
method, which we define on the Bundle
type by means of the following extension. For reasons discussed earlier, as with other implementation details, the extension is marked private
and included in the same source file as ButtonConfiguration
.
private extension Bundle {
func decode<T: Decodable>(_ type: T.Type, from file: String) -> T {
guard let url = url(forResource: file, withExtension: nil) else {
fatalError("Unable to locate file: \(file)")
}
guard let data = try? Data(contentsOf: url) else {
fatalError("Unable to load file: \(file)")
}
guard let decoded = try? JSONDecoder().decode(T.self, from: data) else {
fatalError("Unable to decode file: \(file)")
}
return decoded
}
}
The final step is to create a ‘ButtonConfiguration.json
’ file in the main bundle, which contains the calculator button labels, in the order in which buttons customarily appear in a calculator keypad, and the actual number of columns to be used for the button layout.
{
"labels": ["7", "8", "9", "÷", "4", "5", "6", "x", "1", "2", "3", "-", "AC", "0", "=", "+"],
"columnCount": 4
}
Next we define a test class for ButtonConfiguration
. Since we only need to test a static
property, we don’t need to create an instance of ButtonConfiguration
. Instead, as we had done when we tested the Digit
and ArithemticOperation
types, we make sut
a typealias
for the ButtonConfiguration
type.
class ButtonConfigurationTests: XCTestCase {
typealias sut = ButtonConfiguration
}
This class contains a single test to verify that the value of the decoded
property has the same button configuration information as in the JSON
file in the main bundle.
func testDecoded_hasButtonConfigurationFromJSONFileInMainBundle() {
// Given
let buttonConfigurationInJSONFileInMainBundle = ButtonConfiguration(
labels: ["7", "8", "9", "÷", "4", "5", "6", "x", "1", "2", "3", "-", "AC", "0", "=", "+"],
columnCount: 4)
// Then
XCTAssertEqual(sut.decoded, buttonConfigurationInJSONFileInMainBundle)
}
3. Unit testing the view model
Now we move on to the view model. Note that CalculatorViewModel
conforms to the ObservableObject
protocol, which will allow the view that we will create later to observe changes in the view model. The calculator
property has the @Published
property wrapper, which means view updates will be performed when the value of this property changes.
final class CalculatorViewModel: ObservableObject {
private lazy var commands = {
buttonLabels.map(CommandFactory.createCommandForLabel)
}()
@Published private(set) var calculator: Calculator
let buttonLabels: [String]
let buttonColumnCount: Int
var displayText: String? {
calculator.number.map(String.init)
}
init(calculator: Calculator = Calculator(), buttonConfiguration: ButtonConfiguration = .decoded) {
self.calculator = calculator
buttonLabels = buttonConfiguration.labels
buttonColumnCount = buttonConfiguration.columnCount
}
func performAction(forButtonLabelIndex index: Int) {
calculator = commands[index].execute(with: calculator)
}
}
The CalculatorViewModel
initializer takes as its parameters a Calculator
value, which it uses to initialize the calculator
property, and a ButtonConfiguration
value, which it uses to initialize the buttonLabels
and buttonColumnCount
properties. Both these parameters have default values. The default value for the calculator
parameter is a new Calculator
instance while the default value for the buttonConfiguration
parameter is the value of the decoded
property of the ButtonConfiguration
type, which we have defined and tested in the last section. This allows us to inject custom Calculator
and ButtonConfiguration
values for testing the view model while enabling the view model to be initialized in production without any arguments.
Note that CalculatorViewModel
uses the Command Pattern to control the calculator. This not only makes the view model simple and easy to understand but also facilitates changing the button configuration, and potentially enhancing calculator functionality in the future, with relatively localized changes to the code. We are using a simple and compact implementation of the Command Pattern in Swift, which provides all the benefits of this classic pattern without the boilerplate code associated with the object-oriented implementation. It also enables us to create commands to work directly with value types, including commands that work with a specific value type as well as generic commands that can work with any value type.
For the purposes of our application, we need commands to invoke methods on instances of the Calculator
type, which only has mutating
methods. Accordingly, we define a CalculatorCommand
type, an instance of which can be used to invoke any mutating
method on a value of the Calculator
type.
private struct CalculatorCommand {
typealias MutatingAction = (inout Calculator) -> Void
private let mutatingAction: MutatingAction
init(_ mutatingAction: @escaping MutatingAction) {
self.mutatingAction = mutatingAction
}
func execute(with value: Calculator) -> Calculator {
var valueToMutate = value
mutatingAction(&valueToMutate)
return valueToMutate
}
}
Each instance of CalculatorCommand
stores a closure that takes an inout
parameter of the Calculator
type and mutates it in place. This closure is provided when the command is initialized. The execute(with:)
method creates a mutable copy of the Calculator
value provided as its argument, invokes the closure on this mutable value to mutate it in place, and returns the mutated value.
CalculatorViewModel
uses the following CommandFactory
type to create the required instances of CalculatorCommand
.
private struct CommandFactory {
private static let arithmeticOperationForLabel: [String: ArithmeticOperation] = [
"+": .addition, "-": .subtraction, "x": .multiplication, "÷": .division]
private static let evaluateLabel = "="
private static let allClearLabel = "AC"
static func createCommandForLabel(_ label: String) -> CalculatorCommand {
if let integer = Int(label), let digit = Digit(rawValue: integer) {
return CalculatorCommand() { $0.setDigit(digit) }
} else if let operation = arithmeticOperationForLabel[label] {
return CalculatorCommand() { $0.setOperation(operation) }
} else if label == evaluateLabel {
return CalculatorCommand() { $0.evaluate() }
} else if label == allClearLabel {
return CalculatorCommand() { $0.allClear() }
} else {
fatalError("Unable to create command for label: \(label)")
}
}
}
Note the private
access label used for the CalculatorCommand
and CommandFactory
types. Since these types exist solely to support the functionality of CalculatorViewModel
, they are considered implementation details of CalculatorViewModel
. We can refactor them in any way, including inlining them in CalculatorViewModel
, without affecting any other part of the application as long as the API exposed by CalculatorViewModel
remains unchanged. Accordingly, we include the CalculatorCommand
and CommandFactory
type definitions in the same source file as CalculatorViewModel
and make them private
to hide them from other parts of the application.
Next, we show tests for CalculatorViewModel
. As with other implementation details, we don’t directly test CalculatorCommand
or CommandFactory
, which will get tested as we test CalculatorViewModel
. Here is the test class.
class CalculatorViewModelTests: XCTestCase {
private var sut: CalculatorViewModel!
override func setUp() {
sut = CalculatorViewModel()
}
override func tearDown() {
sut = nil
}
}
The first test in the above class is for the default value of the displayText
property, which should be nil
.
func testDisplayText_isNil() {
XCTAssertNil(sut.displayText)
}
The next group of tests validates that the CalculatorViewModel
initializer correctly assigns injected values to the corresponding stored properties. We start with a test for the calculator
property getting initialized with the value injected through the initializer.
func testCalculator_calculatorValueInjected_hasInjectedValue() {
// Given
let calculator = Calculator().settingDigit(.randomNonZero)
// When
sut = CalculatorViewModel(calculator: calculator)
// Then
XCTAssertEqual(sut.calculator, calculator)
}
The next test verifies that the buttonLabels
property gets initialized from the corresponding property of the ButtonConfiguration
value injected through the initializer.
func testButtonLabels_buttonConfigurationValueInjected_hasValueFromInjectedButtonConfigurationValue() {
// Given
let buttonLabels = ["1", "2", "3"]
// When
sut = CalculatorViewModel(buttonConfiguration: ButtonConfiguration(labels: buttonLabels))
// Then
XCTAssertEqual(sut.buttonLabels, buttonLabels)
}
Then we test the same for the buttonColumnCount
property.
func testButtonColumnCount_buttonConfigurationValueInjected_hasValueFromInjectedButtonConfigurationValue() {
// Given
let buttonColumnCount = 2
// When
sut = CalculatorViewModel(buttonConfiguration: ButtonConfiguration(columnCount: buttonColumnCount))
// Then
XCTAssertEqual(sut.buttonColumnCount, buttonColumnCount)
}
Note that the ButtonConfiguration
initializers we have used in the last two tests each take a single parameter. This is so we can focus only on the parameter relevant to the test. But these initializers are not available in the ButtonConfiguration
implementation used in production. Since these convenience initializers will be used only for testing, we define them in the following ButtonConfiguration
extension included in the test target.
extension ButtonConfiguration {
init(labels: [String]) {
init(labels: labels, columnCount: 0)
}
init(columnCount: Int) {
init(labels: [], columnCount: columnCount)
}
}
Next we test the default values assigned to the properties of CalculatorViewModel
by its initializer. We start with a test for the default value of the calculator
property, which should be a new Calculator
instance.
func testCalculator_hasNewInstance() {
XCTAssertEqual(sut.calculator, Calculator())
}
It is pertinent to note that the remaining tests in CalculatorViewModelTests
, which we will present in the remainder of this section, would be called integration tests in the classical sense. This is because classical unit testing isolates the unit being tested from all external dependencies using mocks. This way, the unit can be tested in isolation by directly controlling and/or observing all interactions with the dependencies. Integration tests can then be performed to test how the various parts actually interact by replacing the mocks with production instances of the dependencies.
This is a topic for a separate blog post but suffice it to say that use of mocks for testing in Swift is really required when dealing with resources such as persistent storage, networks, etc., which are usually unsuitable for unit testing because either they are unavailable during testing or they may be too slow or prone to producing unexpected results or unanticipated failures. For dependencies that don’t have such constraints, which would normally be modeled in Swift using value types, it is better to dispense with mocks and use the production instances for testing, which does away with the need for separate integration testing.
Having clarified the approach, we move on to the tests, starting with a test that verifies that the default value assigned to the buttonLabels
property of CalculatorViewModel
is the value of the labels
property of the ButtonConfiguration
value decoded from the JSON
file.
func testButtonLabels_hasLabelsValueFromDecodedButtonConfiguration() {
XCTAssertEqual(sut.buttonLabels, ButtonConfiguration.decoded.labels)
}
Like the buttonLabels
property, the buttonColumnCount
property also has its default value assigned using the decoded ButtonConfiguration
value, which is the subject of the next test.
func testButtonColumnCount_hasColumnCountValueFromDecodedButtonConfiguration() {
XCTAssertEqual(sut.buttonColumnCount, ButtonConfiguration.decoded.columnCount)
}
Having tested the initializer, we test the ability of CalculatorViewModel
to perform the expected actions on its Calculator
instance. While all these tests call the performAction(forButtonLabelIndex:)
method, which invokes the command corresponding to the integer index passed as its argument, the main functionality being tested here comes from the CalculatorCommand
and CommandFactory
types used by CalculatorViewModel
.
Our first test injects via the CalculatorViewModel
initializer a ButtonConfiguration
value with a buttonLabels
array comprising string representations of all Digit
cases in reverse order. It then calls the performAction(forButtonLabelIndex:)
method with the indices of all these button labels, and verifies that the corresponding digits are set in the Calculator
instance of the view model.
func testPerformAction_digitButtonLabelsValueInjected_setsDigitsInCalculator() {
// Given
let digits = Digit.allCases.reversed()
sut = CalculatorViewModel(buttonConfiguration: ButtonConfiguration(labels: digits.map(String.init)))
// When
(0..<digits.count).forEach(sut.performAction)
// Then
XCTAssertEqual(sut.calculator, digits.reduce(into: Calculator(), { $0.setDigit($1) }))
}
Next we test that the view model can similarly set an arithmetic operation in its Calculator
instance when we inject the corresponding button label, and perform an action with the index of this button label. Note that this requires also injecting a Calculator
instance in which we have set a non-zero digit. This is because setting an operation in a Calculator
instance requires a number to be present in its number
property. Since tests for the four arithmetic operations will follow the same pattern, we create a verification method in the following extension on CalculatorViewModelTests
.
extension CalculatorViewModelTests {
private func verifyActionForOperation(_ operation: ArithmeticOperation, withButtonLabel buttonLabel: String,
file: StaticString = #file, line: UInt = #line) {
// Given
let calculator = Calculator().settingDigit(.randomNonZero)
sut = CalculatorViewModel(
calculator: calculator,
buttonConfiguration: ButtonConfiguration(labels: ["\(buttonLabel)"]))
// When
sut.performAction(forButtonLabelIndex: 0)
// Then
XCTAssertEqual(sut.calculator, calculator.settingOperation(operation), file: file, line: line)
}
}
We show below the methods in CalculatorViewModelTests
that use the above method to verify that the view model correctly sets each arithmetic operation.
func testPerformAction_additionButtonLabelInjected_setsAdditionOperationInCalculator() {
verifyActionForOperation(.addition, withButtonLabel: "+")
}
func testPerformAction_subtractionButtonLabelInjected_setsSubtractionOperationInCalculator() {
verifyActionForOperation(.subtraction, withButtonLabel: "-")
}
func testPerformAction_multiplicationButtonLabelInjected_setsMultiplicationOperationInCalculator() {
verifyActionForOperation(.multiplication, withButtonLabel: "x")
}
func testPerformAction_divisionButtonLabelInjected_setsDivisionOperationInCalculator() {
verifyActionForOperation(.division, withButtonLabel: "÷")
}
The next test follows the same pattern to validate the ability of CalculatorViewModel
to evaluate its Calculator
instance.
func testPerformAction_evaluateButtonLabelInjected_evaluatesCalculator() {
// Given
let calculator = Calculator().settingDigit(.six).settingOperation(.addition).settingDigit(.four)
sut = CalculatorViewModel(
calculator: calculator,
buttonConfiguration: ButtonConfiguration(labels: ["="]))
// When
sut.performAction(forButtonLabelIndex: 0)
// Then
XCTAssertEqual(sut.calculator, calculator.evaluating())
}
Similarly, we test that CalculatorViewModel
can clear its Calculator
instance.
func testPerformAction_allClearButtonLabelInjected_clearsCalculator() {
// Given
let calculator = Calculator().settingDigit(.randomNonZero)
sut = CalculatorViewModel(
calculator: calculator,
buttonConfiguration: ButtonConfiguration(labels: ["AC"]))
// When
sut.performAction(forButtonLabelIndex: 0)
// Then
XCTAssertEqual(sut.calculator, calculator.clearing())
}
The final view model test validates that the displayText
property of CalculatorViewModel
shows a string representation of the value of the number
property of its Calculator
instance. We do this by injecting a single digit label through the view model initializer, performing an action with this label, and verifying that value of the displayText
property of the view model is equal to the string representation of the number
property of a Calculator
instance in which the same digit has been set.
func testDisplayText_actionPerformedForDigitLabel_hasNumberOfCalculatorWithSameDigitSet() {
// Given
let digit = Digit.randomNonZero
sut = CalculatorViewModel(buttonConfiguration: ButtonConfiguration(labels: ["\(digit)"]))
sut.performAction(forButtonLabelIndex: 0)
// Then
XCTAssertEqual(sut.displayText, Calculator().settingDigit(digit).number.map(String.init))
}
4. Creating the view
Our simple application needs a single view, which is shown below.
struct ContentView: View {
@StateObject var viewModel = CalculatorViewModel()
var buttonColumns: Array<GridItem> {
Array(repeating: GridItem(.flexible()), count: viewModel.buttonColumnCount)
}
var body: some View {
VStack {
Text(viewModel.displayText ?? " ")
.displayText()
LazyVGrid(columns: buttonColumns) {
ForEach(0..<viewModel.buttonLabels.count, id: \.self) { index in
Button(viewModel.buttonLabels[index]) {
viewModel.performAction(forButtonLabelIndex: index)
}
.buttonLabel()
}
}
}
.padding()
}
}
The view uses view modifiers to format the text which represents the calculator display, and the button labels. Using view modifiers in this way helps keep formatting details separate from the view layout code, making code easier to read and facilitating consistent formatting of views. Since these view modifiers are used only by ContentView
, they are implementation details of ContentView
. Accordingly, and in keeping with our approach of encapsulating implementation details, we mark the view modifier types private
and include them in the same source file as ContentView
.
Show below is the view modifier used for the display text.
private struct DisplayText: ViewModifier {
func body(content: Content) -> some View {
content
.font(.largeTitle)
.padding()
.frame(maxWidth: .infinity, alignment: .trailing)
.background(Color(.label))
.foregroundColor(Color(.systemBackground))
.lineLimit(1)
.cornerRadius(5)
}
}
Here is the view modifier used to format button labels.
private struct ButtonLabel: ViewModifier {
func body(content: Content) -> some View {
content
.font(.title2)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.background(Color(.systemGray3))
.foregroundColor(Color(.label))
.cornerRadius(5)
}
}
The following extension on View
makes these view modifiers easier and more natural to use at the call site.
private extension View {
func displayText() -> some View {
modifier(DisplayText())
}
func buttonLabel() -> some View {
modifier(ButtonLabel())
}
}
Note that the way the application has been designed, there is no business logic in the view to be tested. The view creates an instance of the view model and connects the various UI components to the relevant properties and methods in the view model. Whether these connections have been made correctly will be validated when we perform UI testing in the next section.
5. UI testing the application
We use two test classes in our UI testing, one to test the UI components on the main screen, and the other to test the workflows. This may seem like overkill for this simple application, and it likely is. However, the separation makes sense for larger applications with many screens and more complex screen layouts, so it has been shown here for the sake of demonstrating the methodology.
The first UI test class, which is shown below, tests the main screen of the calculator to verify that the expected UI components are present. Note that the ‘system under test’ in UI test classes is the application, an instance of which is created and launched in the setUp()
method, and set to nil
in the tearDown()
method.
class MainScreenUITests: XCTestCase {
private var sut: XCUIApplication!
override func setUp() {
continueAfterFailure = false
sut = XCUIApplication()
sut.launch()
}
override func tearDown() {
sut = nil
}
}
The only test in this class verifies that the main screen has an empty display, one button each for the digits 0
to 9
, and buttons to evaluate and clear the calculator.
func testDisplayAndButtonLabels() {
// Given
let buttonLabels = (0...9).map(String.init) + ["+", "-", "x", "÷", "=", "AC"]
// Then
XCTAssertEqual(sut.staticTexts.count, 1)
XCTAssertEqual(sut.buttons.count, buttonLabels.count)
XCTAssertTrue(sut.staticTexts[" "].exists)
for buttonLabel in buttonLabels {
XCTAssertTrue(sut.buttons[buttonLabel].exists)
}
}
The various calculation workflows are tested in a separate UI test class. This includes tests for entering numbers, evaluating and chaining operations, and clearing the contents of the calculator. Here is the test class.
class CalculationUITests: XCTestCase {
private var sut: XCUIApplication!
override func setUp() {
continueAfterFailure = false
sut = XCUIApplication()
sut.launch()
}
override func tearDown() {
sut = nil
}
}
The first test in this class validates using the digit buttons to enter a number, which gets built in the display as each digit button is tapped.
func testNumberEntry() {
// Given
let buttonLabels = Array(0...9).reversed().map(String.init)
for (index, buttonLabel) in buttonLabels.enumerated() {
// When
sut.buttons[buttonLabel].tap()
// Then
let numberInDisplay = buttonLabels.prefix(through: index).joined()
XCTAssertTrue(sut.staticTexts[numberInDisplay].exists)
}
}
The next test verifies that, as arithmetic operations are entered and evaluated, the correct number appears in the display at each step.
func testArithmeticOperationSteps() {
// Given
let firstDigit = 8
let secondDigit = 2
let operationSymbols = ["+", "-", "x", "÷"]
let expectedResults = [
firstDigit + secondDigit,
firstDigit - secondDigit,
firstDigit * secondDigit,
firstDigit / secondDigit]
for (operationSymbol, expectedResult) in zip(operationSymbols, expectedResults) {
// When (first digit button tapped)
sut.buttons["\(firstDigit)"].tap()
// Then (first digit appears in the display)
XCTAssertTrue(sut.staticTexts["\(firstDigit)"].exists)
// When (operation button tapped)
sut.buttons[operationSymbol].tap()
// Then (first digit remains in the display)
XCTAssertTrue(sut.staticTexts["\(firstDigit)"].exists)
// When (second digit button tapped)
sut.buttons["\(secondDigit)"].tap()
// Then (second digit appears in the display)
XCTAssertTrue(sut.staticTexts["\(secondDigit)"].exists)
// When (evaluate button tapped)
sut.buttons["="].tap()
// Then (calculation result appears in the display)
XCTAssertTrue(sut.staticTexts["\(expectedResult)"].exists)
}
}
Note that we test all four arithmetic operations in a single test. This is because, unlike unit tests which run fast, UI tests run slowly as the application needs to be launched to run each test. This is why UI tests often combine elements of functionality that we would test in separate unit tests, as we had done when testing evaluation of the arithmetic operations by the Calculator
and CalculatorViewModel
types.
The next two tests cover chaining operations. First, we test that a user can start a new operation by using the result of a previous operation.
func testStartNewOperationUsingResultOfPreviousOperation() {
// Given
let previousFirstDigit = 5
let previousSecondDigit = 4
sut.buttons["\(previousFirstDigit)"].tap()
sut.buttons["+"].tap()
sut.buttons["\(previousSecondDigit)"].tap()
sut.buttons["="].tap()
let previousResult = previousFirstDigit + previousSecondDigit
XCTAssertTrue(sut.staticTexts["\(previousResult)"].exists)
let newDigit = 3
// When
sut.buttons["-"].tap()
sut.buttons["\(newDigit)"].tap()
sut.buttons["="].tap()
// Then
XCTAssertTrue(sut.staticTexts["\(previousResult - newDigit)"].exists)
}
Then we test that a user can start a new operation without having to evaluate the previous one, which gets automatically evaluated, and its result is used in the new operation.
func testStartNewOperationWithoutEvaluatingPreviousOperation() {
// Given
let previousFirstDigit = 5
let previousSecondDigit = 4
sut.buttons["\(previousFirstDigit)"].tap()
sut.buttons["+"].tap()
sut.buttons["\(previousSecondDigit)"].tap()
let newDigit = 3
// When
sut.buttons["-"].tap()
sut.buttons["\(newDigit)"].tap()
sut.buttons["="].tap()
// Then
XCTAssertTrue(sut.staticTexts["\(previousFirstDigit + previousSecondDigit - newDigit)"].exists)
}
Finally, we test the ability to clear the calculator. We simulate tapping the ‘AC
’ button at various stages of entering and evaluating a calculation, and verify that the display of the calculator is clear.
func testClearCalculator() {
// Given
sut.buttons["5"].tap()
// When
sut.buttons["AC"].tap()
// Then
XCTAssertTrue(sut.staticTexts[" "].exists)
// Given
sut.buttons["5"].tap()
sut.buttons["+"].tap()
// When
sut.buttons["AC"].tap()
// Then
XCTAssertTrue(sut.staticTexts[" "].exists)
// Given
sut.buttons["5"].tap()
sut.buttons["+"].tap()
sut.buttons["4"].tap()
sut.buttons["="].tap()
// When
sut.buttons["AC"].tap()
// Then
XCTAssertTrue(sut.staticTexts[" "].exists)
}
Note that, as with testing the arithmetic operations, we have combined the three scenarios into a single UI test whereas we had tested the same scenarios with separate unit tests. We have done this to avoid having too many UI tests bearing in mind the time taken by UI tests to execute.
Conclusion
Xcode provides a number of features to facilitate unit testing and UI testing. Whether we write test before, after, or in tandem with the code to be tested, it is important to have unit tests for various paths through our code, including edge cases and potential error conditions. Unit tests give developers a higher degree of confidence in correctness of the code. Having a comprehensive suite of unit tests that validate the external behaviour of the code without being coupled to its implementation also gives developers the ability to refactor the code taking advantage of the safety net provided by the tests. UI tests enable testing of the application from a user’s perspective, by simulating user interactions with relevant UI elements and validating the state of the UI during and after these interactions.
Thank you for reading! I always appreciate constructive comments and feedback. Please feel free to leave a comment in the comments section of this post, or start a conversation on Twitter.
Leave a Reply