How We Ensure Long-Term Quality for iOS Apps (Series 1/3)

Thomas Lombard
LumApps Experts
Published in
7 min readMay 28, 2019

Introduction

This article starts a miniseries of 3 articles from the LumApps iOS team in which we will discuss the quality and maintainability of our mobile application.

First things first, let me introduce the iOS team: we have Nico Lourenço, Jonathan Arnal and myself. LumApps native application development started in March 2017 and the first release was in September 2017. Since then we have been working hard to update our application every 6 weeks and trying to make a great mobile experience for our users.

Today we will focus on 2 things:

  • How we maintain a clean project and code base
  • Unit test and code coverage

Project

Clean architecture

We organize our layers following the clean architecture diagram. It was a decision we made right from the start because we already had some experience with it and we find it very useful.

Learn more on The Clean Code Blog.

(I don’t want to go into details for this part because it would be too long and warrants a whole other article on its own.)

Entities Layer

Contains all structure and classes for our LumApps business objects (such as content, community, post…) Those objects are framework free.

Use Cases

This part defines our product. By reading all Use Cases you should be able to know exactly what our application does without even launching it. It’s the most important layer.

Presenters

Presenters are used to convert our business objects into ViewModels. Those ViewModels will be used by our ViewControllers to show information to our users.

This architecture helps us in many ways:

  • Split code by layers and create small PR instead of creating a complete feature all at once
  • Isolate business logic to be fully testable
  • Easier for a new colleague to follow a define organisation

PR review

All mobile developers (including the Android team) follow this particular rule from our mobile manager Mathieu Calba:

“Code is a shared responsibility. The developer owns 51%, the reviewers 49%”

It means that the review (both technical and functional) is as important as the implementation:

  • As a developer, it gives us the opportunity to have a second opinion on our code and functional testing
  • As a reviewer, we know exactly the content on a particular feature even if we didn’t write any line of code.

Of course, by involving everyone we’re also creating a stronger code and more stable application when going into QA before shipping a new version.

Lint and swift guide

One last word on our project — we use Swiftlint with custom rules in our project, it’s a tool that creates warnings if our defined lint rules are broken. It helps us avoid as much lint errors as we can before sending our code in for review.

We also follow a specific set of rules for formatting, right now we use Google swift guide.

You might think those 2 things are optional but trust me, when you don’t have to focus your energy on lint and formatting it makes your code review much more efficient.

Unit testing

Let’s move now to the second part of this article. I want to mention that we don’t use some development methods such as TDD (Test Driven Development) but we want to test ALL our business logic for different reasons.

First, as I mentioned earlier, when implementing a new feature we want to split our code to avoid massive PR. So when creating a new feature for our application we can create a PR for each layer needed:

  • Business object (potentially associated API object and Database object)
  • UseCase
  • Presenter
  • ViewController(s)

It helps both developer and reviewer to split a feature into multiple PR. The downside is you can only do functional testing on your feature once the last PR is committed. It can seem like a big issue if you don’t use any unit test but as you know by now, we want our business logic to be 100% tested.

Let’s move on to a User example created with APIUser

struct APIUser: Codable {
let id: String?
let email: String?
let name: String?
}

This is a user from our API. All arguments are optional, but in our application we don’t want to have a user without id or email because it will cause some unwanted behaviour.

struct User {
let id: String
let email: String
let name: String?
}
extension User {
init(_ apiModel: APIUser) throws {
guard
let identifier = apiModel.id,
let emailUser = apiModel.email {
throw UserMappingError.incorrectData
}
id = identifier
email = emailUser
name = apiModel.name
}
}

When initialising our User from an APIUser an error is thrown if there is no id or email.

extension APIUser {

static var mockFullUser: APIUser = {
return APIUser(
id: "123",
email: "test@test.fr",
name: "Test",
)
}()

static var mockUserWithoutIdentifier: APIUser = {
return APIUser(
id: nil,
email: "test@test.fr",
name: "Test",
)
}()
static var mockUserWithoutEmail: APIUser = {
return APIUser(
id: "123",
email: nil,
name: "Test",
)
}()
static var mockUserWithoutName: APIUser = {
return APIUser(
id: "123",
email: "test@test.fr",
name: nil,
)
}()
}

In order to test our mapping, we can create mock APIUser data and associated tests.

class UserMappingTests: XCTestCase {  func testMappingSuccess() {
XCTAssertNotNil(try? User(APIUser.mockFullUser))
XCTAssertNotNil(try? User(APIUser.mockUserWithoutName))
}
func testMappingFailure() {
XCTAssertNil(try? User(APIUser.mockUserWithoutIdentifier))
XCTAssertNil(try? User(APIUser.mockUserWithoutEmail))
}
}

Then we make sure to have a user created with our mockFullUser and mockUserWithoutName and we want our User to be nil with mockUserWithoutIdentifier and mockUserWithoutEmail. We could even check error type when failing.

This example shows step by step how we add a new entity to our project. Every object creation, API parsing, CoreData mapping have their associated tests before going into review.

In order to test our entire business logic, the last step is to have unit testing on all our UseCases. Again, let’s start by an example.

protocol UserUseCasesProtocol {
func getUserList() -> Promise<[User]>
}
final class UserUseCases: UserUseCasesProtocol { private var userStorage: UserStorageProtocol
private var userRequests: UserRequestProtocol
init(
userStorage: UserStorageProtocol = CoreDataStorage(),
userRequests: UserRequestProtocol = APIRequest()
) {
self.userStorage = userStorage
self.userRequests = userRequests
}
func getUserList() -> Promise<[User]> {
return userRequests.fecthUsers().then { users in
return userStorage.storeUsers(users)
}
}
}

We use protocols on our classes to help us with testing. As you can see our UseCase is created with injection of two classes: one for our API and another for our CoreDataStorage.

NB: In order to use promises we integrated a framework made by Soroush Khanlou

class MockUserRequests: UserRequestProtocol {
var users: [User] = []

func fecthUsers() -> Promise<[User]> {
return Promise<[User]>.successPromise(users)
}
}
class MockUserRequestsFailure: UserRequestProtocol {
func fecthUsers() -> Promise<[User]> {
return Promise<[User]>.failingPromise(SomeError())
}
}

Mock Classes are used to simulate different behaviors. MockUserStorage can be used to retrieve a defined User List from our storage (empty or not) and MockUserStorageFailure is used to return a specific error.

class UserUseCasesTests: XCTestCase {

func testGetUsers() {

let userList = [User.mock1, User.mock2]
let userRequests = MockUserRequests()
userRequests.users = userList
let userUseCases = UserUseCases(
userStorage: MockUserStorage()
userRequests: userRequests
)
let promise = userUseCases.getUserList()
promiseShouldSucceed(
promise,
successHandler: { users in
XCTAssertEqual(users, userList)
}
}
func testFailureGetUsers() {
let userUseCases = UserUseCases(
userStorage: MockUserStorageFailure()
userRequests: MockUserRequestsFailure()
)

let promise = userUseCases.getUserList()
promiseShouldFail(promise)
}
}

First test inject a list of users in our MockUserRequests class then check that the response of our UseCase is correct. The second one checks that our UseCase is failing if our UserRequest failed. In order to test all scenarios we would need to do the same with storage and add more tests.

By using mock data and protocols, we manage to have full coverage on our UseCases. It allows us to simply test all different scenarios such as web service error, corrupted data, no cache data, correct behaviour…

To continue, in every project you always need some kind of helper or utils classes (whatever you like to call it); we have both in our code base.

Those classes are independent and are created to do one specific action or treatment. You guessed it! You need to unit test them! Because those classes are isolated from the rest of the project it is very easy to do it.

Let’s have a simple example with an extension on Int which increment value

extension Int {
func increment() -> Int {
return self + 1
}
}
class IncrementIntTests: XCTestCase {
func testIncrementInt() {
let int = 0
XCTAssertEqual(int.increment, 1)
XCTAssetNotEqual(int.increment, 0)
}
}

Before using this kind of function in your code base it is very simple to do some test to make sure that it works as expected.

When creating those kinds of classes it is much faster to create associated tests than having to check behaviour in our code base. It also secures us from a future refactor or evolution.

Let’s finish by code coverage. Our aim here is not to have 100% code coverage from unit tests in our Xcode project because it would involve too much work and also too much maintainability. So far we cover a bit more than 30% of our entire code base, we want to keep this number and if possible increase it. We make sure to follow previous rules for every code we add to our project. Those tests don’t cover the behaviour of our application, as I’m writing this, it is mostly manual (review, QA session, non-regression tests) but we have a CI setup and we will make sure in the future to have UI tests to avoid as much regression as we can.

Conclusion

Before concluding I want to mention that we didn’t have everything that I wrote about today (and what’s coming in the next articles) right from the start. Our application has to be clean and stable for years, so every day we think about how we can improve what we already have. It takes time to set up those methods but it is essential for a long-term project.

By using a good architecture, paying attention to reviews and having unit tests on our business logic we give ourselves:

  • A clean and maintainable project
  • More safety when introducing new features
  • Possibility to refactor an existing part of our code base
  • Avoid regression

Thanks for reading! On upcoming articles, we will discuss continuous integration and UI tests.

Thomas Lombard, Lead iOS developer at LumApps

--

--