Unlocking the Power of Unit Testing in Software Development
In the intricate world of software development, how do we ensure that every piece of code we write functions as intended? Enter Unit Testing—a rigorous method dedicated to validating each fragment, or “unit”, of software. This approach isn’t just about verifying if a class or a method operates correctly; it dives deep, scrutinizing sets of modules, associated control data, usage procedures, and even operating procedures.
But what exactly constitutes a “unit”? While often synonymous with a class, a unit could be as granular as an individual method. This meticulous level of testing falls under the umbrella of component testing—where each component is tested in isolation, disregarding its relationship with others. This concept is closely tied to Module Testing, emphasizing the discrete nature of each testable element.
However, the boundaries of unit testing can sometimes blur. Some developers expand the definition to encompass integration tests, which evaluate the interplay between multiple units or modules. While this might deviate from the strictest interpretation, it’s a testament to the versatility of unit testing.
The beauty of unit tests lies in their brevity. Typically crafted during the development phase, these concise code snippets can be influenced by the development approach, like Test-Driven Development (TDD). In TDD, the journey begins with the most basic test for a requirement, paired with its simplest implementation. As the software evolves, so do these tests.
The advantages of unit testing are manifold. Not only do they act as a protective shield against codebase regressions, but they also diminish the reliance on manual testing. By offering insights into elusive bugs and the origins of issues like memory leaks, unit tests become an indispensable tool in a developer’s arsenal.
Regression Testing
Regression means “a return to a former or less developed state” and in the context of unit testing, it means going from a point where all your test pass, to a point when one or more does not. Regression Testing is running all the app tests you have developed in one go in order to determine that no regressions have happened. If they have we look to either reverse the changes we have made (which is where good use of source control can help) or to fix our implementation code such that all the test pass again.
Test-Driven Development (TDD)
Test-Driven Development (TDD) is “a software development process that relies on the repetition of a very short development cycle [where] requirements are turned into very specific test cases, then the software is improved to pass the new tests, only”. This contrasts with a non-TDD approach where code can be added which has not been proven to meet requirements. So in short, it’s an approach that starts with testing instead of starting with coding a solution and only then testing it.
The Software Engineer Kent Beck has been credited with having either “developed” or rediscovered the technique of TDD.
Elaborating further, TDD is based on the concept of RGR i.e Red, Green and Refactor:
- Red: The first thing we do is to write a failing test case.
- Green: Next we write the minimal code required in order to pass the test.
- Refactor: We can then refactor our code as required and our test code as well (then repeat).
Using XCTest with Swift
“A unit test is essentially just a function that invokes some of our code, and then asserts that the right thing happens. These functions are implemented within special classes called test cases, which — in the case of Xcode’s default testing framework, XCTest — are subclasses of XCTestCase
“(Ref#: C).
How do we do Unit Testing in Swift?
Well, there are a number of different approaches, some of these use external unit testing frameworks and some use XCTest which is the unit testing that Apple gives us out of the box.
When using XCode, Unit Test will be run within a unit testing target. Whilst it is common practice to add this when creating a new project if you already have a project and wish to add a Unit Testing Bundle you can do this within XCode by choosing File > New > Target
and selecting that option.
Given, When, Then
A common structure for our tests is Given, When, Then. This structure is commonly used in order to make our tests easier to debug and to read for other developers. It could be written in English as ”Given these conditions, when these actions are performed, then this is the expected outcome”(Ref#: C). This formula is also used when writing acceptance tests for user stories when using an Agile development approach. This particular approach was developed by Daniel Terhorst-North and Chris Matts originally as part of the Behavior-Driven Development (BDD) approach.
What do setUp and tearDown do?
Every time a test runs, both the setup and tear down functions are called.
“XCTest automatically calls setUp
before running each test, which makes it an ideal place to reset our test case’s state and create fresh copies of any objects we need to run our tests”. setUp() happens before the test runs (it sets the test up).
Conversely tearDown() happens right after the test runs. We want to initialize the Classes which we want to test in the setup function, and we clear them out in the tearDown function. We want to have a clean baseline state for every new test that we run, and so every different function which is a test will have these functions called before it runs, and after it runs.
Unit Testing Asynchronous Code (How to test completion blocks)
“Tests execute synchronously because each test is invoked independently one after another. But more and more code executes asynchronously. To handle testing components that call asynchronously executing methods and functions, XCTest has been enhanced in Xcode 6 to include the ability to serialize asynchronous execution in the test method, by waiting for the completion of an asynchronous callback or timeout” (Ref#: I).
– Expectations
XCTestExpectation
[s] or expectation
[s] in Swift are apples recommended way to test our asynchronous code. Sometimes we see things like semaphores (instances of DispatchSemaphore
) used instead but it’s worth knowing that expectations are the preferred way.
[CODE EXAMPLE]
-Inverted expectations
“An “inverted” expectation will fail if fulfilled. It is useful when testing mutually exclusive flows or simply when a given thing should happen with one configuration but not with another” (Ref#: O).
[CODE EXAMPLE]
-dispatch queues
MeasureBlock (measure how fast a function is running)
XCTest Assertions
“Your test methods use assertions provided by the XCTest framework to present test results that Xcode displays. All assertions have a similar form: items to compare or a logical expression, a failure result string format, and the parameters to insert into the string format” (Ref#: I).
1 2 3 |
XCTAssertTrue(something == someOtherThing, "The things don't match) // There is also XCTAssertFalse, XCTAssertNil and many more which work as one might expect |
Mocking Objects
“Mocking is a key technique when it comes to writing unit tests in pretty much any language. When mocking an object, we are essentially creating a “fake” version of it – with the same API as the real one – in order to more easily be able to assert and verify outcomes in our test cases” (Ref#: F)
-Partial Vs Complete Mocking
“Mocking comes in two different flavors – partial and complete. When doing partial mocking, you are modifying an existing type to only partially behave differently in a test, while when doing complete mocking you are replacing the entire implementation” (Ref#: F).
Mocking with Protocols
Probably the best and more widely recommended method of Mocking in Swift is to make use of a combination of Protocols and Dependency Injection, which also helps us with transparency and separation of concerns as well as things like code readability.
Classes may often interact with other classes in our app or some SDK, and many “SDK Classes” can’t be directly created, a further challenge is that these classes may have delegate methods we want to test.
To solve this what we can do is to mock the interfaces of “external” classes using protocols.
An example we could use where we might be trying to mock the responses of CLLocationManager could look like this slide from a WWDC session:
In the above example, we’ve created a protocol in line with CLLocationManger’s existing interface, and to ensure that we can say that CLLocationManger conforms to this protocol, we can simply create an empty extension to CLLocationManger and state that it now conforms to our LocationFetcher protocol.
Then our class CurrentLocationProvider can have a property locationFetcher which is set on initialization, where we are then able to use Dependency Injection to provide an alternative (or mocked) version of CLLocationManger which also conforms to the LocationFetcher protocol. Now by giving it a default parameter equal to CLLocationManger we ensure that our non-test code continues to function as expected ().
[…]
Dependency Injection
“…dependency injection is a technique whereby one object (or static method) supplies the dependencies of another object. A dependency is an object that can be used (a service). An injection is the passing of a dependency to a dependent object (a client) that would use it”(Wikipedia).
It’s really not complicated though, as it really just refers to “anytime you pass a value to a method or function”.
Why is Using D.I. Important?
How can We Use this in Swift?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
//: ## Dependency Injection //: #### We have some service class Service class Service { func doSomething() { print ("hello") } } //: #### Client without DI class Client { let service: Service init() { service = Service() } func startSomething() { service.doSomething() } } // To use the above let client = Client() client.doSomething() //: #### Client with DI class DIClient { var service: Service // Our initializer injects our service, or there is a default init(service: Service = Service()) { self.service = service } func startSomething() { service.doSomething() } } let someService = Service() let dClient = DIClient(service: someService) dClient.doSomething() |
D.I. With View Controllers
1 2 3 4 5 6 7 8 |
init(networkingService: ITNetworkingEngine) { self.networking = networkingService super.init(nibName: nil, bundle: nil) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } |
What are Dependency Injection Frameworks and How Can We Use Them?
If we’re looking for a different system to help us implement D.I. in our swift apps, we might turn to a D.I. framework; examples of D.I. frameworks include Cleanse, Swinject, and Needle.
- Cleanse (https://github.com/square/Cleanse)
- Swinject (https://github.com/Swinject/Swinject)
- Needle (https://github.com/uber/needle) – this is Über’s attempt at a D.I. framework and aims to replicate some of the approaches they use in-house in parallel with their distinctive design patterns.
Code Coverage
**How do we check code coverage?**
Well from XCode 9.2 onwards code coverage tools have been improved. We can check code coverage with scripts in the command line by using **xccov**, or we can use the build-in features of Xcode by first selecting **Editor -> Show Code Coverage**, and also editing our scheme and under the “Test” section make sure that “Code Coverage” is checked (we can also select which targets are covered here). We can hit cmd U to rerun our test and enter the Report Navigator to view the results.
We run our test and then we can view our code coverage report right inside XCode and get a percentage of the estimated code coverage by our current set of unit tests. Expanding out the result, we can tap through to the individual source files and see a visual representation of code coverage there on the right-hand side of our source code. We can usually just take this a just a general guide as we need to manually judge what makes sense to test and what does not to an extent.
Updated Method ->
In order to enable coverage reports, you should edit your debug scheme options. You can restrict the coverage report to your main app by using the “Gather coverage for” option and add your main add using the plus button.
Run all your app tests with Command+U
Go to the report navigator and view the percentage coverage by file.
See the Code Coverage in Each File
Entering each file, enable Code Coverage display, and then the sections tested and untested are highlighted for you via a panel on the right of the code.
Test Selection & Ordering
From XCode 10 onwards we can now more easily select & order our test.
We can use Test Selection with our Schemes deciding which “tests to skip”
We have the option of making a new test we add opt-in tests such that we can choose whether to automatically add new tests to the set of tests we execute or not.
Test Ordering used to be just alphabetical, but this meant that there could be order effects if individual tests did not always correct setup and tare-down their state. However, in Xcode 10 we do not have the option to randomize the order in which our tests execute to hopefully account for cases where the order of test is having unforeseen side effects.
Parallel Testing
With XCTest we can talk about two kinds of parallel testing:
- Parallel Destination Testing
- Parallel Distributed Testing
Parallel Destination Testing has been around for a while where we can (from the command line) test and run concurrently on different destinations, where a destination is like a device or a simulator (something mostly useful in CI scenarios like where we are using a Jenkins pipeline).
Parallel Distribution Testing, in contrast, is a slightly newer technique that now allows us to run multiple tests on a single destination (like a single sim). This testing is often class-based (it separates out a test based on the tested class), and works through a complex process explained in this apple talk: …
iOS Testing Pyramid / The Pyramid of Tests
At WWDC 2017, Apple introduced us to their “Pyramid of Tests” which is a suggested structuring for a test suit for our app.
This “Pyramid of Tests” approach advocates that our test suite is bottom heavy – or in other words it is made up of mainly smaller unit test (testing a lot of the discrete functions etc in our app), then at the middle level we have Integration Tests (about how things are working together for a purpose), and at the top a very few “end-to-end” test by which Apple is mainly talking about things like UI test of typical things a user might do.
Conclusion
…
References
A: https://medium.com/swift-india/test-driven-development-developers-magic-wand-b81cfbfeee99
B: https://en.wikipedia.org/wiki/Test-driven_development
C: https://www.swiftbysundell.com/basics/unit-testing
D: https://www.swiftbysundell.com/posts/refactoring-swift-code-for-testability
E: https://www.swiftbysundell.com/posts/unit-testing-asynchronous-swift-code
F: https://www.swiftbysundell.com/posts/mocking-in-swift
G: https://clean-swift.com/step-by-step-walkthrough-of-ios-test-driven-development-in-swift/
H: https://www.appcoda.com/tdd-quick-nimble/
I: https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/04-writing_tests.html
J: http://www.getlaura.com/testing-unit-vs-integration-vs-regression-vs-acceptance/
K: https://github.com/uber/needle
L: https://qualitycoding.org/swift-mock-objects/
M: https://www.youtube.com/watch?v=XhuosoIrRqE
N: https://www.guru99.com/component-testing.html
O: https://medium.com/blablacar-tech/4-tips-to-master-xctestexpectation-aee2b2631d93
Comments