Approaching Outside-in TDD on Android (Pt. 3)
In the previous post, we wrote the acceptance test as a first step and started creating the most external classes of our implementation. In this post, we will finish implementing the system, and will summarize what we have learnt during the process.
To finish the BankAccount class, we need to implement its last public method, showStatement. Let's dive into the next iteration of the inner loop cycle.
- ●Red - We created a StatementFormatter to format the statement lines. We considered the statement to be a domain concept important enough to have its own class. It acts as a first class collection around the statement lines that attracts behaviour related to the statement lines.
The failing test ensures that when we show an account statement, the view is called with the appropriate statement lines.
- ●Green - The implementation is quite simple in this case. The BankAccount just needs to create a new statement with the transactions from the repository, pass it to the formatter to format and show the formatted statement using the view.
- ●Refactor - Nothing to refactor here.
As a rule of thumb, once a class is done, the next step is to run the acceptance test to check the progress and to know what is the next collaborator to implement.
If we executed the acceptance test at this point, we would see that the TransactionRepository throws an UnsupportedOperationException. As we stated before, throwing an exception in the methods that we have not implemented yet will guide us through the feature implementation and will point us to the next collaborator to implement. It is quite important, as doing so, we have greater control over the current feature progress. We just need to follow the exceptions until the feature is fully implemented.
- ●Red - TransactionRepository is a leaf node in the tree of collaborators. Meaning that, it does not collaborate with other classes. Leaf nodes can not be tested using mocks as there is no collaboration, so we need to flip to the classicist style of TDD and test through state rather than through behaviour. TransactionRepository is going to be implemented as an in-memory storage, so we decided to test it by storing a transaction and checking that the list returned in the transactions method contains the previously stored transaction.
- ●Green - To make the test pass we just need to return the current list of transactions, as best practice we recommend to return an immutable list to protect it for clients that could try to modify it.
- ●Refactor - Some readability improvements in TransactionRepository test. Remember that readability is important both in production and test code.
Now that TransactionRepository is done, if we execute the acceptance test, StatementFormatter throws an UnsupportedOperationException. That means that it should be the next one to be implemented.
- ●Red - StatementFormatter is responsible for creating ViewStatementLines from a given Statement, and sorting them in reverse chronological order. In order to do so, the StatementFormatter has to take the StatementLines from the statement, map them to ViewStatementLines and sort them. This is tested by stubing the StatementLines that the Statement returns and asserting that the output of the format method contains the expected ViewStatementLine information and order.
Note that we are not going to fulfil the statementLines method yet. Instead we mock it and throw an UnsupportedOperationException accordingly. We will jump there when needed.
- ●Green - As described before, the production code for the StatementFormatter takes the lines from the statement, sort them in inverse chronological order and map them them to ViewStatementLines.
- ●Refactor - We extracted a method that maps a line in the production code and do some clean up in the test code.
Once again, we run the acceptance test to check the progress and now it guide us to the lines method in the Statement class (The one that we just mocked in the previous inner loop to implement the StatementFormatter). Let's get rid of the exception and jump into the next inner loop.
- ●Red - Statement has to map every transaction to a StatementLine that contains amount, date and running balance.
As in the case of the TransactionRepository, Statement is a leaf node. Therefore, it is tested using the classicist approach by asserting the state of the StatementLines returned in the lines method.
- ●Green - The implementation just map all Statement's transactions to StatementLines, calculating their current balance.
- ●Refactor - We extracted some methods in the production code and improved test readability.
We are now done with the Statement class. Let's run the acceptance test again to find out that the next class that needs to be implemented is the show method of the ShowStatementActivity.
- ●Red - During most part of the implementation we were not dealing with any Android code, but at this level we moved back to the to UI layer and, as the code is running inside an Activity, we have to write the unit test using Espresso. Here we need to test that once the show method is called, the RecyclerView contains a list of rows representing the information contained in the given ViewStatementLines.
- ●Green - In order to show the required rows in the RecyclerView we need to make some steps. First we need to create a RecyclerView.Adapter that holds the dataset and creates the required ViewHolder associated with every row contained in the RecyclerView.
- ●Refactor - Improves test readability.
If we execute the acceptance test at this point, we can see that it passes. We can consider the feature DONE as it passes the given acceptance criteria in an automated fashion.
To wrap-up the series, we are going to summarize what we have learnt and make some comments about our implementation.
We would like to mention that we have used Espresso to assert the state of the views in Android, but you could use the testing framework that works best for you, i.e. Cucumber.
That being said, we have found that outside-in is an approach that requires good design skills and that we need to have a good idea of how the design of the system will look like beforehand. It usually means that we have built a similar feature or system in the past and therefore we have an overall idea of what we need and where we are led to.
- The public interface of every class is always designed to serve an existing client. As we work from the outside in, clients are implemented first, and they define the public API of the classes they collaborate with. The resulting design should read better, adhere well to the “tell don't ask” principle, and tend to avoid feature envy and anaemic domain models.
- Benefits of acceptance tests included in the outside-in flow:
- Automated requirements validation.
- Offer a way to check the progress of a feature at any given time.
- Offer a way to validate our system in an end-to-end fashion.
- Are written in a non-technical language, therefore can be understood by the business.
- The addition of acceptance + unit test offers a complete validation that our system works and fulfils the requirements as expected.
- Outside-in offers a common workflow for developers
- Because outside-in focus on how the different parts collaborate, the implementation details of the design are part of the tests. As a result, the tests are coupled to the implementation. This situation introduce a bigger risk to refactoring as the tests have to change when the design of the system changes.
- Features are guided by acceptance tests. That is fine, but remember that acceptance tests tend to be slower than a unit test and will make the test suite slower as we add more features. It is a trade-off that we need to consider. A possible approach is to include acceptance tests only for the features that are critical to the business.
- It is usually harder to write behaviour test than state tests
These series have now come to an end with this third post.
We hope you enjoyed and learnt something.