Mobile Testing Practices
26 Dec 2020Originally posted on Talkdesk Engineering Blog.
This article is very straightforward: I just wanted to share some guidelines we follow at Talkdesk’s Mobile team regarding tests. I’ve been working with TDD in the whole past year and this approach helped us to be very successful both in Android and iOS. We’ve achieved a great speed to add features and make changes. As time goes by, the speed does not decrease, which is the main purpose of software architecture. We do it in a clean way, where we work separately in the domain, presentation, and data, always abstracting dependencies, which makes us able to properly divide the work in order to deliver smaller pieces of code without big conflicts. I will use Swift in the examples, but it could be Kotlin though since we basically test in the same way.
Name tests by scenarios
Tests are documentation, so it’s very important they explain well what is being tested. As we know, naming classes and functions is one of the hardest things in computer science and naming tests wouldn’t be easier. The practice here is to represent the scenario (a specific case of the user-story written in a non-technical language). In our team, we use BDD with specification by example to define our acceptance criteria and it guides us through the whole development cycle, starting with a first refinement with the product team and other stakeholders. In another occasion, we do a technical refinement, where we add tech approaches - technical steps specifying what is necessary to accomplish that work with the purpose of reducing risks for the effort estimation - to the tickets and split them out as necessary to both reduce risks and parallelize the implementation, if possible. In our QA process, that could be done by a developer or any other person, those scenarios are checked as well as UI/UX aspects, and the user story is finally good to go.
Example: Navigating from Account to Home screen, BDD-way
First scenario: Correct account name input
Given I am in the account screen
When I type “talkdesk” in the accounting field
And I press the Next button
Then I see a loading indicator
And I see the Home screen
The scenario represents a behavior from a user’s perspective, so it’s written in a functional way. Chances are that many tests will be necessary to cover this behavior, usually in different layers of the software. In other words, to satisfy the acceptance criteria, you must code all the small behaviors/states contained in it, such as account name validation, user interaction, and navigation. You will cover them in isolation with unit tests. At Talkdesk, we always start from the Domain layer, so we will start from the account name validation, which contains the business logic needed here.
The acceptance criteria say that “talkdesk” should pass, so I used the “lowercase” term for that. I could use the real name here (and I first did), but the other cases (uppercase, containing special characters, numbers, etc) don’t fit well to our test name syntax. The other cases will be covered by other tests, according to their scenarios. But what is important here is to read the test name and know what are the rules. Again, tests are documentation, so the decision of allowing lowercase names as valid is documented here. Other validation cases can be covered as follows: “talk-desk” should be true:
test_GIVEN_an_account_with_hyphen_in_the_middle_of_the_name_
WHEN_is_valid_checked_
THEN_return_true
“1talkdesk” should be true:
test_GIVEN_an_account_with_an_algarism_at_the_beginning_of_the_name_
WHEN_is_valid_checked_
THEN_return_true
“ta” should be false:
test_GIVEN_an_account_with_a_name_with_less_than_three_characters_ WHEN_is_valid_checked_ THEN_return_false
Naming tests with a BDD approach is a good exercise to maintain a well documented code. When a PR is opened, the reviewer reads the test name and has no doubt of the objective of that piece of code.
I/O not allowed
Tests should be hermetic. They should run fast and they should be reliable.
If your test goes to the internet or does storage operations in a database, it will bring you different results on different occasions because it depends on non-reliable data sources. You must fake these dependencies if you want to make your test not flaky. Use test doubles, like Mocks and Stubs to supply the necessary dependencies for the expected behavior of your object under test.
There are good libraries that help to do that, but it’s not rocket science. You can start with simple fake implementations of your contracts.
Wait, contracts? Yes, contracts, interfaces, protocols… abstractions.
Let’s see how to fake things.
Fake it
Abstractions are a very important concept in software development and, combined with dependency injection, help us to write tests very easier. Considering our scenario of the account name input, we could think of a presentation layer, where we have a presenter, which is responsible to communicate with the domain (business logic) and navigate to another screen if the account name is valid when the next button is pressed.
Thinking in a TDD way, what I want to test is:
If I type “talkdesk” and press Next, I should see loading and the Home screen.
The presenter only needs one function, it should know that the next was pressed receiving a string as the account name. It’s not a concrete implementation, it’s a protocol that is a common way to create contracts in Swift. If I want to implement a concrete AccountPresenter, I just have to conform to this protocol, implementing its unique function.
I can’t instantiate the protocol in my test, so I will create a concrete implementation of this.
It’s almost a fake implementation since it does nothing. I only created this in the order I can instantiate it (Hey, Apple. Let’s work in XCode tools? In IntelliJ we can create classes and objects directly from the tests). Let’s see our test:
My test is almost there. I have my presenter instance and I let it know the Next was pressed sending the Account Name to it. The problem here is how to check that it navigated to the Home screen (note that I did not check the loading in order to assert only the navigation). The problem here is that I can’t test the navigation yet. I will then inject a navigator or router in the constructor of my presenter so I can check if it navigated when I expected.
But what’s a router? In our case, it is a class responsible to implement the navigation between screens or our app. And it’s a contract. A protocol, an abstraction. Why? Because this way I can fake it. We don’t need the concrete implementation of the router to test if it was called. An important thing to note: right now, we actually don’t need the abstraction of the AccountPresenter, since we are still testing its concrete implementation. In a pure TDD approach, we only create abstractions when we need them.
So this is our fake implementation of a Router. It does something very simple but is exactly what we need for our test.
And our test is now possible, just checking if the “showHomeScreen” was called.
Test the real instance
It may seem obvious, but sometimes it causes some confusion. It’s a good practice to have only one real instance in your test, that’s the one you are using to act on (like the presenter, in the above example). The instance dependencies should be fakes. Test doubles are pretty good, so use them.
Arrange, Act, Assert
The structure of the tests is very important to ease the readability and maintainability of the tests. Arrange, Act, Assert is a technique that helps to keep the tests clean. The method is simple:
1) Arrange: Instantiate the subject
If you have to call a function to test behavior, this is the place to instantiate the class that exposes this function. Chances are you will need to inject the dependencies for this class in order to instantiate it, so you just have to use those test doubles to instantiate it.
2) Act: Execute one action to test a behavior
The action is the way to make your class change. When we call the nextPressed with an account name, we expect to (or not to) navigate to our home screen, so our action is to call this function.
3) Assert: Verify that an expected behavior has happened It’s a good practice to maintain only one assertion per test. It makes the test very objective and clear for the reader. It can look hard to do (we tend to aggregate more assertions in the same test instead of splitting them out), but it’s in fact much better. Because we delegate the responsibility of each assertion to a unique test, which now exists only to cover that scenario, if that scenario does not exist anymore, we just remove the test.
The final result of this test is here.
Tests must be independent
Tests are not our production code. They still have to be clean, but some rules are different. Code duplication, for example. There is no problem with having two tests with exactly the same first 10 lines of arrangement if they’re testing different things. We don’t code tests in the same way we do in our production code (DRY vs. DAMP). We can’t see a file of tests as a program, that runs the before/setUp, a sequence of tests and a tearDown method. Actually, it can sound controversial, but the recommendation here is to avoid these pre and post methods. The same goes for instance variables (the fake dependencies we inject in our real instance).
Here are some reasons to instantiate everything inside your test function:
- The test does not depend on any other thing in your class
- If you move the logic being tested in your production code, you can easily move the test to the new place
- The tests can run in parallel
No logic allowed
It’s tempting to use loops, if statements, class castings, and other coding techniques in order to check things in tests, but please DON’T! The reason is simple. If we write tests to guarantee the production code works, who can guarantee that our test code works? That’s why tests should be as simple as a getter/setter function. We usually already do some logic (the less we can) in our test doubles, so let’s not add logic into our test functions.
Why so many test doubles?
As our project grows, the number of test classes grows and so the test double classes. Mocks, Stubs, Spies… If we don’t use libraries to help to create these instances we have to build them ourselves. It’s a trade-off, for sure, but there is also an advantage in it: we know exactly what’s happening and how to use it. It’s very quick to create a mock class or a stub. We just have to implement a protocol. If we have to test if a function was called, we store it in a Boolean variable. If we have to know exactly how many times a method was called, we transform the variable in an Integer. And there’s no problem in using libraries. But only use a library if you know how to do this by hand.
Some conclusions
Just to recap what I consider the main points:
- TDD made us deliver faster
- BDD eases the definition of acceptance criteria and test cases. Naming gets easier as well.
- Tests are documentation, they explain to us the code better than comments.
- Tests should be hermetic and as simple as possible. They should scream what is happening there.
- Abstractions and dependency injection help a lot. They facilitate faking dependencies and instantiating our objects under tests.
This article’s goal is to share experience exemplifying some things that work in our team. Almost nobody tests on mobile. I don’t know the reason, but that’s a fact. I’ve heard it from dozens of developers. Even in big companies, most of them say they don’t test. But, fortunately, most of them say they want to test. We have to change this scenario, step by step. I hope these topics can help more developers to move on and make us more confident about the code we write.