Android Test-Driven Development by Tutorials
Android Test-Driven Development by Tutorials
Android Test-Driven Development by Tutorials
Test-Driven
Development
by
Tutorials
By Lance Gleason, Victoria Gonda & Fernando
Sproviero
Licensing
Android Test-Driven Development by
Tutorials
By Lance Gleason, Victoria Gonda and Fernando
Sproviero
Notice of Rights
All rights reserved. No part of this book or
corresponding materials (such as text, images,
or source code) may be reproduced or
distributed by any means without prior written
permission of the copyright owner.
Notice of Liability
This book and all corresponding materials (such
as source code) are provided on an “as is” basis,
without warranty of any kind, express of
implied, including but not limited to the
warranties of merchantability, fitness for a
particular purpose, and noninfringement. In no
event shall the authors or copyright holders be
liable for any claim, damages or other liability,
whether in action of contract, tort or otherwise,
arising from, out of or in connection with the
software or the use of other dealing in the
software.
Trademarks
All trademarks and registered trademarks
appearing in this book are the property of their
own respective owners.
Dedications
"There are many people who helped to make
this book possible. My other half Marlene was
the one who initially suggested that I try out for
the Ray Wenderlich team. She gave me lots of
encouragement and, even before the editors saw
my work, edited every chapter to make sure my
sentences were coherent. My late mother passed
on her love of reading and many creative skills
for which I will always be grateful. The many
strong women and family members in my life
who taught me to live life with honesty and
conviction and were encouraging of my work. I
owe a debt of gratitude to the Ruby community
for teaching me about TDD and infecting me
with enthusiasm. I’d also like to thank Ray
Wenderlich and the team for giving me the
chance to share my love of TDD with the world.
Finally, I’d like to thank all of the editors and
co-authors of this book. It has been a very
rewarding experience working with everybody
on the team."
— Lance Gleason
"To my family, friends, and especially my
partner, who supported me while writing this
book. Tyler, thanks for all your encouragement
and patience you gave me as I spent evenings in
front of my laptop turning thoughts into words."
— Victoria Gonda
— Fernando Sproviero
About the Authors
https://store.raywenderlich.com/products/andro
id-test-driven-development-by-tutorials
Forums
We’ve also set up an official forum for the book
at forums.raywenderlich.com. This is a great
place to ask questions about the book or to
submit any errors you may find.
Digital book editions
We have a digital edition of this book available
in both ePUB and PDF, which can be handy if
you want a soft copy to take with you, or you
want to quickly search for a specific term within
the book.
https://store.raywenderlich.com/products/a
ndroid-test-driven-development-by-
tutorials.
www.raywenderlich.com/newsletter
About the Cover
Ravens are one of the smartest animals in the
world. They can solve puzzles, trick other
animals to gain an advantage, and are even
better communicators than apes! They're also
great planners: Ravens don’t leave anything to
chance and think ahead to ensure the best
chance of success.
Section 1
Chapter 1: Introduction
Section 2
Chapter 4: The Testing Pyramid
Chapter 8: Integration
Section 3
Chapter 12: Common Legacy App Problems
Key points
There are different places you can start
reading this book, depending on what you
already know.
From now on, when mentioning anything related to writing a test this
book will be referring to the automatic procedure form of a test.
This book primarily focuses on writing tests first versus writing them
after a feature has been implemented.
Change/refactor confidence
You have probably run into a scenario in which you have a section of your
application that works correctly before adding new functionality to the
application. After adding new functionality, either in Quality Assurance
(QA) or after it is released to customers you discover that this new
functionality broke the previously working section. That is called a
regression.
Having good, reliable, effective tests would have caught that at the
moment the bug was introduced saving time in QA and preventing
preventable bugs from making it to your users. Another related scenario
is where you have a section of your application that is working correctly,
but could use some refactoring to use a new library, break things up to
follow a more readable architectural pattern, etc. A good test suite will
provide you with the confidence to make those changes without having
to do time consuming manual QA regression test cycles to ensure
everything is still working correctly.
However, you should always bear in mind that this is not a 100%
"insurance". No matter how many tests you write, there could be edge
cases that the tests don't catch. Even so, it's absolutely safer to have tests
that catch most issues than not having them at all!
Usually, you will write tests for the most common scenarios your user
may encounter. Whenever someone finds a bug that your tests didn't
catch, you should immediately add a test for it.
Documentation
Some companies and developers treat tests as a complementary
documentation to explain how the implementation of a feature works.
When you have well-written tests, they provide an excellent description
of what your code should do. By writing a test, its corresponding
implementation and repeating this until a feature is completed, bearing
in mind that these tests can be treated as specifications, will help you
and your team when a refactor or a modification of the feature is
required.
When you're working on a piece of code, you can look at the tests to help
you understand what the code does. You can also see what the code
should not do. Because these are tests rather than a static document, as
long as the tests are passing you can be sure this form of documentation
is up-to-date!
fun whenAnsweringCorrectly_shouldIncrementCurrentScore() {
...
}
This test's name represents the state of what you are testing and the
expected behavior. This is useful when looking at the report after
running a test suite.
Short and simple: You should aim to write tests that focus on a
narrow piece of functionality. As a rule of thumb, if your test
methods get long, and have multiple assertion statements to check
conditions of the system, it may be trying to test too many things. In
that scenario it may be a good idea to break up that test into
multiple, more narrowly focused tests. Take a look at this test:
fun whenIncrementingScore_shouldIncrementCurrentScore() {
val score = Score(0)
score.increment()
if (score.current == 1) {
print("Success")
} else {
throw AssertionError("Invalid score")
}
}
The test only has seven lines of code to bring the SUT in the desired state
and check the expected behavior.
Check one single thing: Check one thing at a time. If you need to
test multiple things, write an additional test similar to the one
you've just previously run, but change the check:
fun whenIncrementingScore_aboveHighScore_shouldAlsoIncrementHighScore()
val score = Score(0)
score.increment()
if (score.highest == 1) {
print("Success")
} else {
throw AssertionError("Invalid high score")
}
}
As you can see, this test is very similar to the previous one; however, the
check is different. In the first test, you checked that the score of the quiz
game incremented correctly. Now, you check that the highest score also
increments along with the score.
Persist data.
Interact with device sensors.
Having tests for the logic of your application should be your main goal,
however, bear also in mind the following:
However, all the stakeholders need to understand that all the code (or
almost everything) you wrote will be thrown away. In this case, it doesn't
make sense to write any kind of tests.
On a new greenfield project, writing tests can double the amount of time
to get features out in the short term. But, as the project gets larger, the
tests end up saving time. Writing tests has its benefits; however, it'll take
time to write and maintain tests. You and your team will need to make
sure that you understand the trade-offs when determining which path
you want to take.
A Note on Technical Debt
When you take out a financial loan, you get the benefit of an
immediate infusion of cash. But a lender charges you interest on the
loan in addition to the principal, all of which you will need to pay
back. If you take on too much debt, you can end up in a situation
where it is impossible to pay back the loan. In this case, you might
have to declare bankruptcy.
Technical debt has many parallels to financial debt. With technical
debt you make trade offs in your code, such as not writing unit tests,
not refactoring, having less stringently quality standards, etc. to get
features out quicker. This is analogous to getting a financial cash
infusion. But as the code base grows, the lack of tests increase the
number of regressions, bugs, time it takes to refactor and QA time.
This is analogous to interest on a financial loan. In order to pay off
that debt you start to add unit tests to your code. That is analogous
to paying down the principal on a loan. Finally, if too many
shortcuts are taken for too long, the project may reach a point where
it is more advantageous to scrap the entire project and start with a
clean slate. That is the same as declaring bankruptcy to get relief
from too much financial debt.
Code spikes
At some point, you may find yourself working with a new library or,
perhaps, you may realize that you aren't sure how to implement
something. This makes it very difficult to write a test first because you
don't know enough about how you are going to implement the
functionality to write a meaningful failing test. In these instances, a code
spike can help you figure things out.
You may have asked yourself how many tests should you write. As
mentioned before, you should at least write those that cover the most
common scenarios.
Criterion
To measure, there are several coverage criterion that you may choose.
The most common are:
For example, suppose that the following code is part of a feature of your
app:
To satisfy the condition coverage criteria, you need tests that call
getFullname(null, "Smith") and getFullname("Michael", null) so that each
subcondition, firstName != null and lastName != null would evaluate to
true and false.
Tools
There are tools that can assist you to measure the test coverage metric.
This library generates a report for you to check which lines were covered
by your tests (green) and which ones were not (red or yellow).
Android Studio also comes with a built-in feature to run tests with
Coverage.
100% coverage?
In real-world apps, reaching a test coverage of 100%, no matter which
criterion you use, is almost impossible to achieve. It often doesn't add
value to test all methods, of all the classes, all of the time.
For example, suppose you have the following class:
fun whenCreatingPetWithName_shouldTheNameSetFromTheConstructor() {
val aName = "Rocky"
val aPet = Pet(aName)
if (aPet.name == aName) {
print("Success\n")
} else {
throw AssertionError("Invalid pet name")
}
}
In this case, you are testing a feature (getting and setting a property) of a
Kotlin data class that is auto-generated for you!
Test coverage gives you an exact metric of how much of your code has
not been tested. If you have a low measure, then you can be confident
that the code isn't well tested. The inverse however is not true. Having a
high measure is not sufficient to conclude that your code has been
thoroughly tested.
If you try to reach 100% test coverage, you'll find yourself writing
meaningless, low-quality tests for the sake of satisfying this goal.
Neither you nor any team member should be obsessed with a test
coverage of 100%. Instead, make sure you test the most common
scenarios and use this metric to find untested code that should be tested.
If you feel that writing a particular test is taking too long, you might
want to take a step back and evaluate if that test is adding enough value
to justify the effort. Also, if a simple fix is causing a lot of changes to
your tests, you may need to look at refactoring your tests or
implementation to make them less brittle.
At the end of the day, your goal is to create software that provides value
to its users. If you are doing TDD well, as your project gets larger, the
total amount of effort spent on tests, implementation and QA should be
the same or less than if you were creating the same product, with the
same level of quality without doing TDD. That said, a project that is
doing a good job at TDD may still take more development effort than a
project that is not because the project with TDD will have a higher level
of quality. The key is finding the right balance for your project.
Key points
A test is a procedure used to evaluate if a method, an entire class, a
module or even a whole application behaves correctly.
The tests you write should be short, simple to read and easy to
follow.
You should only write tests related to the logic of your application.
You can use test coverage tools to find untested code that should be
tested.
In the next chapter, you'll find out what Test Driven Development (TDD)
is and what the benefits are of writing tests before writing the feature. In
the following chapters, you'll also start writing apps with their
corresponding tests.
Chapter 3: What Is TDD?
Once you have decided to add tests to your project, you
need to start thinking about how and when to integrate
them into your work. You want to make sure that you add
tests for any new code and the tests you add provide value.
You also want to consider how to add tests for existing code.
Finally, you want to feel confident that the tests you add
will catch any regressions in the future.
TDD is a process in which you write the tests for the code
you are going to add or modify before you write the actual
code. Because it's a process and not a library, you can apply
it to any project, be it Android, iOS, web or anything else.
Getting started
You'll start from scratch using pure Kotlin independent of
any framework to learn the steps of TDD. You're not looking
at Android or any testing tools here, so you can focus purely
on TDD.
You'll write the tests, and then the code, for a helper
function that returns the Google shopping search URL to
open in the browser when a list item is tapped.
fun main() {
Practicing Red-Green-Refactor
In this chapter, you will learn the basics of the TDD process
while walking through the Red-Green-Refactor steps. Red-
Green-Refactor describes the steps that you follow when
practicing the TDD process.
Red: Start any new task by writing a failing (red) test. This is
for any new feature, behavior change, or bug fix that doesn't
already have a test for what you will be doing. You only
write the tests and the bare minimum code to make it
compile. Make sure you run the test, and see it fail.
Refactor: Now is when you can prettify your code, and make
any other changes you want to make sure your code is clean.
You know that your changes are safe and correct as long as
your tests stay green. Having these tests to ensure the code
meets the specifications helps you do this refactoring with
confidence.
Here, you:
Now that you have eliminated any compiler errors, click the
Run button to see your test fail!
This test is very similar to the one you wrote before. You:
Run the code again. You will see your first test pass, while
your second, new test fails.
Making it pass
Now, write the minimum amount of code to make your new
test pass. Change the body of getSearchUrl() to return the
query String.
return query
False positives
While testing saves you from many pitfalls, there are things
that can go wrong as well, especially when you don't write
the test first. One of these is the false positive. This
happens when you have a test that is passing, but really
shouldn't be.
To see this, next, test that the URL returned includes the
search query. Break the rules a little bit, and add this line to
the top of getSearchUrl():
Now, add this test to make sure the result contains the
query:
// Test getSearchUrl result contains query
// 1
val result = getSearchUrl("toaster")
if (result?.contains("toaster") == true) {
// 2
print("Success\n")
} else {
// 3
throw AssertionError("Result did not contain query")
}
Oh, no! You can't see the result of the new test because the
first one (as you probably predicted) is failing now! If you
were using a testing framework such as JUnit, as you will
starting in Chapter 5, "Unit Tests," all the tests would run
even if some failed. When JUnit continues running the rest
of your tests like this, it helps you to have a comprehensive
understanding of what is broken. This is because your well
defined tests will tell you which parts of your code are
working, and which are not. What you know now looking at
these results is that the function is no longer returning null
if the input is null. This mean you need to update the
function so it returns null again when given null as a
parameter while ensuring your newest test is passing. That's
next!
return query?.let {
"https://www.google.com/search?q=$query"
}
This should now return null if the query is null, and the
URL containing the query otherwise. Run the tests and see
them pass to confirm this!
When starting out with TDD, you will make many mistakes.
You might change a small piece of functionality that will
make half your tests break when the functionality is correct,
for example. Or you'll spot false positives. By making these
mistakes, you'll grow and learn how to write better tests and
better code.
Only after you write your test and see it fail do you
write your new code or change your existing code.
You should have lots of small unit tests, some integration and
fewer UI tests.
Unit tests
Unit tests are the quickest, easiest to write and cheapest to
run. They generally test one outcome of one method at a time.
They are independent of the Android framework.
The System Under Test (SUT) is one class and you focus only
on it. All dependencies are considered to be working correctly
— and ideally have their own unit tests — so they are mocked
or stubbed. This way, you have complete control of how the
dependencies behave during the test.
These tests are the fastest and least expensive tests you can
write because they don’t require a device or emulator to run.
They are also called small tests. To give an example of an unit
test, consider a game app.
class Game() {
var score = 0
private set
var highScore = 0
private set
fun incrementScore() {
// Increment score and highscore when needed
}
}
fun shouldIncrementHighScore_whenIncrementingScore() {
val game = Game()
game.incrementScore()
if (game.highScore == 1) {
print("Success")
} else {
throw AssertionError("Score and HighScore don't match")
}
}
If you run this test, you'll see the test doesn't pass. We now
have our failing (red) test. You can then fix this to get our
passing (green) test by writing the actual method for the Game
class:
fun incrementScore() {
score++
if (score > highScore) {
highScore++
}
}
Some common libraries for unit testing are JUnit and Mockito.
You'll explore both of these in later chapters.
Integration tests
Integration tests move beyond isolated elements and begin
testing how things work together. You write these type of tests
when you need to check how your code interacts with other
parts of the Android framework or external libraries. Usually,
you'll need to integrate with a database, filesystem, network
calls, device sensors, etc. These tests may or may not require a
device or emulator to run; they are a bit slower than unit tests.
They are also called medium tests.
Note: If you mock the JSON parser and verify only the
transformation to your domain model you would be
creating a unit test. You should create unit tests for both
the repository and also the JSON parser to ensure they
work as expected in isolation. Then, you can create
integration tests to verify they work together correctly.
// 2
val expectedIntent = Intent(detailActivity,
LoginActivity::class.java);
// 3
val actualIntent = getNextStartedActivity()
if (expectedIntent == actualIntent) {
print("Success")
} else {
throw AssertionError("LoginActivity wasn't launched")
}
}
You can still use JUnit and Mockito to create integration tests
to verify state and behavior of a class and its dependencies.
You'll focus on these kind of tests in later chapters. You can
also use Robolectric for tests involving the Android framework
but run locally without a device or emulator.
UI Tests: 10%
Key points
Testing is commonly organized into the testing pyramid.
The further down you get, the more focused and the more
tests you need to write, be mindful of how expensive the
test is to perform.
Testing Fundamentals:
https://developer.android.com/training/testing/fundamen
tals#testing-pyramid
UI Tests: https://www.youtube.com/watch?
v=pK7W5npkhho&start=1838
Learn what unit tests are and what are the best places to use them.
Find the starter project for this application in the materials for this
chapter and open it in Android Studio. Build and run the application.
You'll see a blank screen.
You'll start writing tests and classes for the application and, by the end of
Chapter 7, "Introduction to Mockito," the application will look like this:
When to use unit tests
Unit tests are the fastest and easiest tests to write. They also are the
quickest to run. When you want to ensure that a class or method is
working as intended in isolation — this means with no other dependent
classes — you write unit tests.
Before writing any feature code, you should first write a unit test for one
of the classes that will compose your feature. Afterwards, you write the
class that will pass the test. After repeating this procedure, you'll have a
completed, testable feature.
Setting up JUnit
You're going to write a unit test for the first class of the cocktail game,
which is a Game class. This first test will be a JUnit test, so, open
app/build.gradle and add the following dependency:
dependencies {
...
testImplementation 'junit:junit:4.12'
}
Note: When creating a new project, you'll find that this dependency
is already there. You're adding it here manually for educational
purposes.
class GameUnitTests {
// 1
@Test
fun whenIncrementingScore_shouldIncrementCurrentScore() {
// 2
val game = Game()
// 3
game.incrementScore()
// 4
Assert.assertEquals(1, game.currentScore)
}
}
2. Create an instance of the Game class — the one that will be tested.
There's also the possibility to write a message so that, when the test fails,
you'll see this message. For example:
Set Up: You first have a phase where you arrange, configure or set
up; in this case, you instantiate a class.
Assertion: You execute the method that you want to test and you
assert the result.
class Game() {
var currentScore = 0
private set
fun incrementScore() {
// No implementation yet
}
}
Run the test. There are several ways to run the tests.
Or, if you want to run all the tests (currently you have just one), you can
right-click over the app ‣ src ‣ test ‣ java ‣ com ‣ raywenderlich ‣ android ‣
cocktails ‣ game ‣ model package and select Run 'Tests' in 'model':
Either way, you should see that it doesn't pass:
This is because you didn't increment the current score yet. You'll fix that
soon.
You can also open the Terminal going to View ‣ Tool Windows ‣ Terminal
and run the tests from the command line executing:
$ ./gradlew test
Notice how the expected value is one and the actual value is zero. If we
had reversed the order of our expected and actual values in our assertion,
this would show up incorrectly. You'll also see that it generates a report
under /app/build/reports/tests/testDebugUnitTest/index.html; if you
open it in your preferred browser, you'll see the following:
class Game() {
var currentScore = 0
private set
fun incrementScore() {
currentScore++
}
}
$ ./gradlew test
It'll generate this report:
@Test
fun whenIncrementingScore_aboveHighScore_shouldAlsoIncrementHighScore()
val game = Game()
game.incrementScore()
Assert.assertEquals(1, game.highestScore)
}
Again if you try to compile it'll fail because the highestScore property is
missing.
var highestScore = 0
private set
fun incrementScore() {
currentScore++
highestScore++
}
However, you should also test that, when the highest score is greater
than the current score, incrementing the current score won't also
increment the highest score, so add the following test:
@Test
fun whenIncrementingScore_belowHighScore_shouldNotIncrementHighScore(
val game = Game(10)
game.incrementScore()
Assert.assertEquals(10, game.highestScore)
}
Here, the intention is to create a Game with a highscore of 10. The test
won't compile because you need to modify the constructor to allow a
parameter. Because you need to start with a highest score greater than
the default, which is 0, you need to alter the constructor like this:
Now, run all the tests and see that the last one doesn't pass. You can use
the green arrow button on the left-side of the class definition.
The last one doesn't pass because you're incrementing both the current
score and highest score regardless of their values. Fix that by replacing
the incrementScore() function with the following:
fun incrementScore() {
currentScore++
if (currentScore > highestScore) {
highestScore = currentScore
}
}
Build and run the last test to see the satisfying green checkmark.
JUnit annotations
For this project, you're creating a trivia game. Trivias have questions, so
you'll now create unit tests that model a question with two possible
answers. The question also has an "answered" option to model what the
user has answered to the question. Create a file called
QuestionUnitTests.kt in the app ‣ src ‣ test ‣ java ‣ com ‣ raywenderlich ‣
android ‣ cocktails ‣ game ‣ model directory.
@Test
fun whenCreatingQuestion_shouldNotHaveAnsweredOption() {
val question = Question("CORRECT", "INCORRECT")
Assert.assertNull(question.answeredOption)
}
}
If you try to run this test it won't compile because the Question class
doesn't exist. So, create the Question class under the directory app ‣ src ‣
main ‣ java ‣ com ‣ raywenderlich ‣ android ‣ cocktails ‣ game ‣ model
and add the following to make it compile:
@Test
fun whenAnswering_shouldHaveAnsweredOption() {
val question = Question("CORRECT", "INCORRECT")
question.answer("INCORRECT")
Assert.assertEquals("INCORRECT", question.answeredOption)
}
This test will check that, when you add the user's answer to a question,
the user's answer is saved in the answeredOption property.
You'll get a compilation error since you haven't written the answer()
method yet. Add the following to the Question class to make it compile:
Now run the test and you'll see that it doesn't pass.
Assert.assertTrue(result)
}
Notice, here, that you're using assertTrue. It checks for a Boolean result.
Running this test will get you a compilation error since the answer()
method doesn't return a Boolean. So, modify the Question class so that the
answer() method returns a Boolean. For now, always return false:
return false
}
return true
}
Assert.assertFalse(result)
}
Now that we have tests for when the answer is correct and when the
answer is not correct, we can fix the code:
Run all the Question tests and verify they all pass correctly.
Finally, you should ensure that the answer() method only allows valid
options. Add this test:
@Test(expected = IllegalArgumentException::class)
fun whenAnswering_withInvalidOption_shouldThrowException() {
val question = Question("CORRECT", "INCORRECT")
question.answer("INVALID")
}
Notice, here, that the @Test annotation allows to expect an exception. If
that exception occurs, the test will pass. This will save you from writing
try/catch. If you run the test now, it will fail because the answer() method
doesn't throw the exception:
answeredOption = option
answeredOption = option
return isAnsweredCorrectly
}
Run all the tests again to see that everything is still working after the
refactor.
Refactoring the unit tests
Notice that each test repeats this line of code:
This makes the tests bloated with boilerplate code that makes them hard
to read. To improve this, JUnit tests can have a method annotated with
@Before. This method will be executed before each test and it's a good
place to set up objects.
Modify the QuestionUnitTests test class, adding the following to the top:
@Before
fun setup() {
question = Question("CORRECT", "INCORRECT")
}
@Test
fun whenAnswering_shouldHaveAnsweredOption() {
question.answer("INCORRECT")
Assert.assertEquals("INCORRECT", question.answeredOption)
}
@Test
fun whenAnswering_withCorrectOption_shouldReturnTrue() {
val result = question.answer("CORRECT")
Assert.assertTrue(result)
}
@Test
fun whenAnswering_withIncorrectOption_shouldReturnFalse() {
val result = question.answer("INCORRECT")
Assert.assertFalse(result)
}
@Test(expected = IllegalArgumentException::class)
fun whenAnswering_withInvalidOption_shouldThrowException() {
question.answer("INVALID")
}
Now, run all the tests again to make sure you didn't break them while
refactoring. All tests still pass — great!
JUnit also has other similar annotations:
@After: The method will be executed after each test. You can use it
to tear down anything that you set up in @Before.
@AfterClass: To execute a method only once after all the tests are
executed, use this one. For example, closing a file, a connection or a
database that is shared in all the tests.
Challenge
Challenge: Testing questions
You have the Game and Question classes. The Game class should contain a
list of questions. For now, these are the requirements:
The game should have a list of questions; so, when getting the next
question, the game should return the first.
When getting the next question, without more questions, the game
should return null.
Write a test for each one and add the corresponding functionality to the
Game class progressively to make each test pass.
Remember the TDD procedure: write a test, see it fail, write the
minimum amount of code to make it pass and refactor if needed.
Key points
Unit tests verify how isolated parts of your application work.
Using JUnit, you can write unit tests asserting results, meaning, you
can compare an expected result with the actual one.
Every test has three phases: set up, assertion and teardown.
In TDD, you start by writing a test. You then write the code to make
the test compile. Next you see that the test fails. Finally, you add the
implementation to the method under test to make it pass.
In the next chapter, "Architecting for Testing," you'll learn about good
practices and design patterns, that will ensure a good architecture and
encourage testability. Afterwards, you'll continue working on this
project, creating unit tests using a complementary library called
Mockito.
For additional resources, there's a Google library you can use called
Truth, similar to JUnit. It has a couple of notable benefits:
A poorly architected app may have all of its logic contained within a
single method that consists of many lines; or it may have one large class
with too many responsibilities. Both of these scenarios make it
impossible to test groups or units of logic independently.
Apps that are architected for testing separate their code into groups of
logic using multiple methods and classes to collaborate. With this type of
architecture, developers can test each public method and class in
isolation.
You also need to consider the effort it takes when adding or modifying an
app’s features. In TDD, this process starts with creating new tests or
modifying existing ones. While it may take some additional time to do
this, adding and updating tests shouldn’t be a painful process. If it is,
you’ll eventually stop writing tests and avoid TDD all together. To
encourage TDD, it’s better to think of a software architecture that
encourages and facilitates the creation of tests.
But it’s not only testing that matters:
Early design decisions: When you create a new app, one of the first
decisions is to decide on the architecture you’re going to use. These
early design decisions are important because they’ll set constraints
on your implementation, such as the way your classes will interact
and their responsibilities. Early decisions will also organize your
codebase a specific way and may even organize the members of your
team. For example, on a given architecture, you may divide your
team between people who only write domain classes and others who
only write visually-related code.
Design patterns
It's not uncommon for developers to encounter the same problems in
different projects and platforms, and to solve these problems using
similar solutions. Over time, certain developers started formalizing these
patterns into templates or solutions that other developers could reuse if
they found themselves in a similar context or situation.
Most of the time, these solutions are not specific blocks of code for a
specific platform. Instead, they’re diagrams, ideas and descriptions of
how to proceed when faced with similar circumstances. They tend to
show relationships and collaboration between classes. When reviewed
carefully, you’re able to then take this information and implement
solutions in your own language and platform.
According to the Gang of Four (GoF: Erich Gamma, Richard Helm, Ralph
Johnson and John Vlissides) you can classify design patterns into the
following categories: creational, structural and behavioral.
Creational
The patterns in the Creational category describe solutions related to
object creation.
Singleton
The Singleton design pattern specifies that only one instance of a certain
class may exist, known as a singleton. Usually, it’s possible to access the
singleton globally.
object MySingleton {
private var status = false
private var myString = "Hello"
...
}
You can use MySingleton by invoking:
MySingleton.validate()
This line creates the MySingleton object. If the object already exists, it
uses the existing one, so there’s no worry about creating more than one.
class MyClass {
fun methodA() {
...
if (MySingleton.validate()) {
...
}
...
}
}
You won’t be able to test methodA() properly because you’re using the
actual MySingleton object, which means you can’t force validate() to
return true or false.
Builder
The Builder design pattern abstracts the construction of a complex
object, joining several parts. For example, think of a restaurant that
serves different menus depending on the day.
class Chef {
fun createMenu(builder: MenuBuilder): Menu {
builder.buildMainDish()
builder.buildDessert()
return builder.menu
}
}
Notice how Chef calls the corresponding methods to build the menu.
With this implementation, it’s easy to test the separated parts. You can
test Chef to verify that it calls the right methods, and you can also test
each class that inherits from MenuBuilder by asserting the state of the
resulting Menu. You’ll see how to perform this kind of test in the next
chapter.
AlertDialog.Builder(this)
.setTitle("Error!")
.setMessage("There was an error, would you like to retry?")
.setNegativeButton("Cancel", { dialogInterface, i ->
...
})
.setPositiveButton("Retry", { dialogInterface, i ->
...
})
.show()
Dependency Injection
The Dependency Injection design pattern is crucial to having a testable
architecture.
class Vehicle() {
private val engine = CombustionEngine()
You can solve these types of problems using the dependency injection
design pattern, which describes that collaborators are provided to an
object that requires them, instead of this object directly instantiating
them internally.
If you combine the Builder design pattern with the dependency injection
design pattern, you end up with something like this:
class CombustionVehicleBuilder {
fun build(): Vehicle {
val engine = CombustionVehicleEngine()
...
return Vehicle(engine)
}
}
In this example, you aren’t injecting the engine here, so you may also
want to inject the engine to the builder. You could do that. However, at
some point someone or something needs to instantiate the class.
Usually, it's the entity that creates objects and provides their
dependencies. This entity is known as the injector, assembler, provider,
container or factory.
class Vehicle {
var engine: Engine? = null
...
fun start(): Boolean {
engine?.let {
return engine.start()
}
return false
}
...
}
In this case, you create a Vehicle without an Engine. You can then set the
Engine type later.
In Android, Dagger2 and Koin are libraries that help you inject objects.
Structural
Structural design patterns ease the design to establish relationships
between objects.
For example, in Android, when you have a list of contacts that you want
to show in a RecyclerView, the RecyclerView doesn’t know how to show
objects of the class Contact. That’s why you need to use a ContactsAdapter
class:
class ContactsAdapter(private val contacts: List<Contact>):
RecyclerView.Adapter<ContactViewHolder>() {
Here, bind() sets the views (TextView, ImageView, and so on) using the
Contact model.
Facade
The Facade design pattern defines a high-level interface object which
hides the complexity of underlying objects. Client objects prefer using
the facade instead of the internal objects because the facade provides a
cleaner, easier-to-use interface.
Composite
The intent of the Composite design pattern is to construct complex
objects composed of individual parts, and to treat the individual parts
and the composition uniformly.
In Android, View, ViewGroup and the rest of classes that inherit from View
— like TextView and ImageView — create a composite pattern because
ViewGroup inherits from View, and contains a list of child View objects.
Note: This is not the actual Android implementation; it’s simplified
for illustration purposes.
When you ask a ViewGroup to draw(), it iterates through all of its children
asking them to draw(). A child can be anything that inherits from View —
even other ViewGroup objects.
Behavioral
Behavioral design patterns explain how objects interact and how a task
can be divided into sub-tasks among different objects. While creational
patterns explain a specific moment of time (the creation of an instance),
and structural patterns describe a static structure, behavioral patterns
describe a dynamic flow.
Observer
The Observer design pattern gives you a way to communicate between
objects where one object informs others about changes or actions.
There’s an observable object which you can observe, and there’s one or
more observer objects that you use to subscribe to the observable.
button.setOnClickListener(object: View.OnClickListener {
override fun onClick(v: View?) {
// Perform some operation
}
})
In this case, the observable is the button, and you subscribe to it with an
observer, which is the anonymous class that performs some operation
when you click the button.
Command
The Command design pattern describes the encapsulation of an
operation without knowing the real content of the operation or the
receiver.
MVC
Model-View-Controller (MVC) states that each class you write should be
part of one of the following layers:
Model: The data classes that model your business belong to this
layer. They usually contain data and business logic. This layer also
contains the classes that fetch and create objects of those business
classes, networking, caching and handling databases.
View: This layer displays data from the Model. It doesn’t contain any
business logic.
The View knows about the Model, and the Controller has a reference to
both the View and the Model.
3. The Controller updates the Model and notifies the View that should
update.
Using this MVC implementation in Android, the Model classes are unit
testable, and the Controller classes also are unit testable because they
don’t extend or use any Android UI classes. For the View classes, you can
create UI tests.
This pattern doesn’t explicitly state which layer should handle UI logic.
For example, if you have the following class, part of the Model layer:
Suppose in the View layer you need to display it with the following
format: "$number, $street, $zipCode". You could do the following in your
Activity:
addressView.text =
"${address.street}, ${address.number}, ${address.zipCode}"
But, if you want to create a unit test, you may instead add a property to
the Model, like this:
addressView.text = address.description
Now, you could create a unit test for the Model. However, you’d be
making the Model dependent on the View layer.
The View simply knows too much: It knows about the Controller and the
Model. The Activity, Fragment or custom view knows what to display and
how to display it. Even more, if you have a direct reference to the Model,
you might be tempted to go directly to the Model to obtain data from an
API or database — without going through the correct flow, using the
Controller.
MVP
Model-View-Presenter (MVP) has these layers:
Usually, there’s an interface for the View and an interface for the
Presenter, and these are written in a single file or package as a sort of
contract.
For example, think about a Login flow where you might have the
following contract interfaces:
interface LoginPresenter {
fun login(username: String, password: String)
}
interface LoginView {
fun showLoginSuccess()
fun showLoginError()
fun showLoading()
}
class LoginPresenterImpl(
private val repository: LoginRepository,
private val view: LoginView): LoginPresenter {
loginButton.setOnClickListener {
presenter.login(usernameEditText.text.toString(),
passwordEditText.text.toString())
}
}
Differences between the MVC and MVP patterns are that in MVP, the
View doesn’t have a direct reference to the Model, meaning it is loosely-
coupled. Instead, the Presenter brings the transformed or simplified
Model, ready to be displayed to the View. MVP also proposes that the
Presenter should handle everything related to the presentation of the
View. In MVC, it's not clear where the UI logic should be, compared to
MVP where it’s common to put it in the Presenter. Because of this, the
Presenter could grow a lot, converting it into another anti-pattern called
God-class (you’ll learn more about this concept later). It’s a good practice
to create small classes (e.g. an AddressFormatter) with single
responsibilities to have better maintainability and unit testing. These
collaborator objects could be injected in the constructor, as explained
before.
presenter.login("Foo", "1234")
Assert.assertTrue(didShowLoading)
}
Note: In the context of TDD, you should first create this test, and
afterward, the corresponding implementation.
MVVM
Model-View-ViewModel (MVVM) contains the following layers:
As you can see, this is an event based approach, where the ViewModel
produces data and the View consumes it. The ViewModel doesn’t know
about the consumer, it just exposes streams of data. The View subscribes
and unsubscribes to that data as needed.
The ViewModel layer is composed of classes that don’t extend or use any
class related to the Android UI framework. Usually, the mechanism of
exposing data, observing and updating it, is done using reactive libraries.
The most common are:
class LoginViewModel(
private val repository: LoginRepository): ViewModel() {
viewModel.getLoginStatus().observe(this, Observer {
when(it) {
is LoginStatus.Loading -> ...
is LoginStatus.Success -> ...
is LoginStatus.Error -> ...
}
})
loginButton.setOnClickListener {
viewModel.login(usernameEditText.text.toString(),
passwordEditText.text.toString())
}
}
}
Suppose that login() from the repository returns a data User object, and
you need to show that in the UI, you could instead use another approach.
Having various LiveData objects exposed, for example, one
LiveData<Boolean> for the loading state, another one for the error state
and another one LiveData<User>, in the View you would need to observe
them and react accordingly.
S.O.L.I.D principles
TDD is closely related to good programming practices. Writing tests
before the actual feature makes you think on how the interface of a class
will be. Therefore, you’ll be exposing only those methods really needed.
On the contrary, without using TDD, sometimes you’ll find yourself
creating a class and exposing methods and properties that you don’t
need them to be public. Using TDD will also make you think on how the
classes will collaborate. After writing several features, while your app
grows, there will be times when you realize that things should be
refactored. Thanks to having tests you can refactor with confidence.
You’ll often find classes that clearly violate this principle, however, you’ll
also find that sometimes is not that clear. Whenever you’re refactoring or
evolving your code you may realize that the class you’re modifying starts
to have multiple responsibilities.
Thanks to TDD, you may realize a class is becoming a god class when you
spot some of the following signs:
When you have a class A that depends on a class B, and the tests of A
start to require you to stub many methods of B, B is turning into a
god class. You’ll see more about stubbing in a later chapter.
Open-closed
This principle was actually first defined by Bertrand Meyer in his book
Object-Oriented Software Construction.
The software entities of your app: classes, methods, etc. should be open
for extension but closed for modification. This means that you should
design them in such a way that adding new features or modifying
behavior shouldn’t require you to modify too much of your existing code
but instead add new code or create new classes.
For example, suppose you’re writing an app for a house architect who
tells you that he needs to calculate the total area of each room for a given
blueprint of a house. You may end up with the following solution:
class ArchitectUtils {
...
fun calculateArea(rooms: List<Room>) {
var total = 0
for (room in rooms) {
total += room.width * room.height
}
return total
}
}
The architect is happy with that, however, the next week comes and he
tells you that now he needs to add the area of the yard of the house. The
yard could be circular.
class ArchitectUtils {
...
fun calculateArea(spaces: List<Space>) {
var total = 0
for (space in spaces) {
if (space is SquareSpace) {
total += space.width * space.height
} elseif (space is CircularSpace) {
total += space.radius * space.radius * PI
}
}
return total
}
}
This code above is violating the principle, because it’s not closed for
modification, you’re always modifying existing code to support new
types.
class ArchitectUtils {
...
fun calculateArea(spaces: List<Space>) {
var total = 0
for (space in spaces) {
total += space.area()
}
return total
}
}
As you can see, if you need to support new types, you can just create a
new class that implements the Space interface with its area() method.
You won’t need to modify anything else! This is what "closed for
modification but open for extension" means.
Following this principle will give you a strong code base that almost
never changes but enables extension. This is even more noticeable if
you’re writing a library because you can’t change your interface with the
clients of your library. In that case, the clients won’t need to change
anything in their code and at the same time allow them to use new
features.
When using TDD, you’ll write a new test to check the new feature or
change an existing test to verify some behavior that now has to deal with
more use cases. While writing the test you may notice that it starts to
become too complex. That may be a sign you need to introduce a new
class that inherits from the old one or use composition to handle each
use case.
Liskov substitution
Also called design by contract, was initially introduced by Barbara Liskov
in a 1987 conference keynote titled Data abstraction and hierarchy.
Basically, it states that an app that uses an object of a base class should
be able to use objects of derived classes without knowing about that and
continue working. Therefore, your code should not be checking the
subtype. In the subclass you can override some of the parent methods as
long as you continue to comply with its semantics and maintain the
expected behavior. As you can see, if you respect the contract, the app
should continue to work.
In your tests created using TDD, everything that you verified for your
base class should also be verified for your new child class.
@Test
fun testAreaRectangle() {
val rectangle = Rectangle()
rectangle.width = WIDTH
rectangle.height = HEIGHT
@Test
fun testAreaSquare() {
val square = Square()
square.width = WIDTH
square.height = HEIGHT // This will also set square.width to HEIGHT
interface Repository {
fun findContactOrNull(id: String): Contact?
}
As you can see, the base interface declares a method that indicates that it
would return a Contact object by id or null if it doesn’t find it. Later, the
implementations, an in-memory DB and a Sql DB do what they have to
do to return the Contact. Neither of them change the semantic of the
interface. If instead, for example, an implementation removes a contact
and then returns it, it would be violating the principle because you
wouldn’t be maintaining the expected behavior.
Interface segregation
This principle encourages you to create fine grained interfaces that are
client specific. Suppose you have a class with a few methods, one part of
your app may only need to access a subset of your methods and other
part may need to access another subset. This principle encourages you to
create two interfaces. Clients should have access to only what they need
and nothing more.
For example, suppose you have an app where the user has to register and
login to use it. You may have the following interface:
interface Membership {
fun login(username: String, password: String): User
fun logout(user: User)
fun register(username: String, password: String)
fun forgotPassword(username: String)
}
You may have a screen that, after login, only deals with showing the user
data and enables them to logout.
You may have another screen to register and finally another one to let
the user recover their password if it was forgotten.
So instead of all those screens using the fat Membership interface, it’s
better to segregate it into the following interfaces:
interface Login {
fun login(username: String, password: String): User
fun logout(user: User)
}
The screen that handles login and shows user data, enables to logout
should use this interface.
interface Register {
fun register(username: String, password: String)
}
interface Forgot {
fun forgotPassword(username: String)
}
The screen that handles recovering the password should use this last
interface.
You may then have a class that implements all of these interfaces if it
needs to. But if it doesn’t, each screen should use the corresponding
interface. Another example where it would be good to segregate
interfaces is the following: suppose you’re writing an app that will allow
you to send a file to a printer, scan a document to use it in your app and
send a file to an email address. You may implement it like this:
interface Printer {
fun print(file: File)
fun scan(): Bitmap
fun sendTo(file: File, email: String)
}
interface Printer {
fun print(file: File)
}
interface Scanner {
fun scan(): Bitmap
fun sendTo(file: File, email: String)
}
Dependency inversion
This principle states that a concrete class A should not depend on a
concrete class B, but an abstraction of B instead. This abstraction could
be an interface or an abstract class.
class ApiDataFetcher {
fun fetch(): Data {
// Implementation that retrieves data from an API
}
}
Now, when you create the Presenter, you can still pass the ApiDataFetcher
as a parameter. However, the presenter doesn’t know about it, it just
depends on an abstraction, the DataFetcher interface. So it will be easy to
change it to a SharedPreferencesDataFetcher or a RoomDataFetcher class as
long as those classes implement the DataFetcher interface.
Key points
Use software architecture to communicate development standards
between team members.
There are other user interface design patterns such as MVC, MVP
and MVVM.
Learn what mocking and stubbing are and when to use these
techniques.
Why Mockito?
If you remember from a previous chapter, whenever you create a test, you
must:
Finally, verify the result by checking the state of the object under
test. This is called state verification or black-box testing. This is
what you've done using JUnit.
Setting up Mockito
Open the application's build.gradle file and add the following
dependency:
dependencies {
...
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.
}
// 2
game.answer(question, "OPTION")
// 3
verify(question, times(1)).answer(eq("OPTION"))
}
Note: When importing mock, verify, times and eq, you should choose
the options starting with com.nhaarman.mockitokotlin2.*.
In this test:
1. You set up the test. The answer() method of Game will call answer on
the Question, so you create a mock, which you can later verify
against.
3. Verify the method answer() was called on the Question mock. You
used the times(1) verification mode to check that the answer()
method was called exactly one time. You also used the eq argument
matcher to check that the answer() method was called with a String
equal to OPTION.
You can omit times(1) as it's the default. So modify the code to the
following:
verify(question).answer(eq("OPTION"))
So, open the Game class and create the answer() method:
This is because Kotlin classes and methods are final by default. Mockito
won't work with final classes/methods out of the box. To fix this you have
the following options:
Add the open keyword to classes and methods that you'll mock.
Stubbing methods
The Game should increment the current score when answered correctly, so
add the following test:
@Test
fun whenAnsweringCorrectly_shouldIncrementCurrentScore() {
// 1
val question = mock<Question>()
whenever(question.answer(anyString())).thenReturn(true)
// 2
game.answer(question, "OPTION")
// 3
Assert.assertEquals(1, game.currentScore)
}
Note: You could choose to use a specific String matcher here, which
would make the test stronger.
Run the test, and you will see that it fails. Add the following code to the
answer() method of the Game class:
Now, run the test again and you will see that it passes.
You are also going to want to check that it doesn't increment the score
when answering incorrectly. To do that, add the following test:
g y g
@Test
fun whenAnsweringIncorrectly_shouldNotIncrementCurrentScore() {
val question = mock<Question>()
whenever(question.answer(anyString())).thenReturn(false)
val game = Game(listOf(question))
game.answer(question, "OPTION")
Assert.assertEquals(0, game.currentScore)
}
Here, instead, you are stubbing the answer() method to always return
false.
Run the test and you will see that it fails. It's a good thing you checked
for that boundary condition! To fix this, replace your answer() method
with the following:
This adds a check to only increment the score if the answer is correct.
Now, run both tests and you will see them pass.
Refactoring
Open the Game class. Notice that this class knows about the score and a
list of questions. When requesting to answer a question, the Game class
delegates this to the Question class and increments the score if the
answer was correct. Game could also be refactored to delegate the logic of
incrementing the current score and highest score to a new class, Score.
Create a Score class in the same package as the Game class with the
following content:
class Score(highestScore: Int = 0) {
var current = 0
private set
fun increment() {
current++
if (current > highest) {
highest = current
}
}
}
fun incrementScore() {
score.increment()
}
...
Run the tests again and verify that everything is still working.
With that change, however, take another look at the following unit tests
from GameUnitTests.kt:
@Test
fun whenIncrementingScore_shouldIncrementCurrentScore() {
val game = Game(emptyList(), 0)
game.incrementScore()
Assert.assertEquals(
"Current score should have been 1",
1,
game.currentScore)
}
@Test
fun whenIncrementingScore_aboveHighScore_shouldAlsoIncrementHighScore()
val game = Game(emptyList(), 0)
game.incrementScore()
Assert.assertEquals(1, game.highestScore)
}
@Test
fun whenIncrementingScore_belowHighScore_shouldNotIncrementHighScore()
val game = Game(emptyList(), 10)
game.incrementScore()
Assert.assertEquals(10, game.highestScore)
}
In order to keep your tests at the unit level, remove these tests from
GameUnitTests.kt and create a new file called ScoreUnitTests.kt with the
following content:
class ScoreUnitTests {
@Test
fun whenIncrementingScore_shouldIncrementCurrentScore() {
val score = Score()
score.increment()
Assert.assertEquals(
"Current score should have been 1",
1,
score.current)
}
@Test
fun whenIncrementingScore_aboveHighScore_shouldAlsoIncrementHighScore
val score = Score()
score.increment()
Assert.assertEquals(1, score.highest)
}
@Test
fun whenIncrementingScore_belowHighScore_shouldNotIncrementHighScore(
val score = Score(10)
score.increment()
Assert.assertEquals(10, score.highest)
}
}
This gets your tests back to the unit level because you test the methods
of the Score object without dependent classes.
With that refactor, the only method that is still using the
incrementScore() method in your Game class is the answer() method. Let's
simplify this. Remove the incrementScore() method and change the
answer() method as follows:
fun answer(question: Question, option: String) {
val result = question.answer(option)
if (result) {
score.increment()
}
}
Now, because you removed the public scoreIncrement() method, the only
way to increment the score in your Game class is by answering questions.
@Test
fun whenAnsweringCorrectly_shouldIncrementCurrentScore() {
val question = mock<Question>()
whenever(question.answer(anyString())).thenReturn(true)
val game = Game(listOf(question))
game.answer(question, "OPTION")
Assert.assertEquals(1, game.currentScore)
}
@Test
fun whenAnsweringIncorrectly_shouldNotIncrementCurrentScore() {
val question = mock<Question>()
whenever(question.answer(anyString())).thenReturn(false)
val game = Game(listOf(question))
game.answer(question, "OPTION")
Assert.assertEquals(0, game.currentScore)
}
You may have guessed that now these are integration tests. This is
because you are asserting game.currentScore that internally depends on a
Score class from your refactor. To convert them to unit tests, you will
need to change them to verify that the increment() method on the Score
class was or wasn't called. To do that, replace them with the following:
@Test
fun whenAnsweringCorrectly_shouldIncrementCurrentScore() {
val question = mock<Question>()
whenever(question.answer(anyString())).thenReturn(true)
val score = mock<Score>()
val game = Game(listOf(question), score)
game.answer(question, "OPTION")
verify(score).increment()
}
@Test
fun whenAnsweringIncorrectly_shouldNotIncrementCurrentScore() {
val question = mock<Question>()
whenever(question.answer(anyString())).thenReturn(false)
val score = mock<Score>()
val game = Game(listOf(question), score)
game.answer(question, "OPTION")
verify(score, never()).increment()
}
You'll see that it doesn't compile now, because you're passing a list of
questions and a score to the Game class constructor, but it doesn't support
that yet. To fix that, open your Game class and change the constructor to
the following:
Once that is done, remove the old score, currentScore and highestScore
properties as they are not needed anymore. Your modified Game class
should be the following:
class Game(private val questions: List<Question>,
val score: Score = Score(0)) {
Run the tests and everything should now pass. Congratulations, you have
successfully refactored your tests and kept them at the unit level.
Verifying in order
To save and retrieve the high score, you'll need to add functionality to a
repository. From the Project view, create a new package common ‣
repository under app ‣ src ‣ test ‣ java ‣ com ‣ raywenderlich ‣ android ‣
cocktails. Create a new file called RepositoryUnitTests.kt and add the
following code:
class RepositoryUnitTests {
@Test
fun saveScore_shouldSaveToSharedPreferences() {
val api: CocktailsApi = mock()
// 1
val sharedPreferencesEditor: SharedPreferences.Editor =
mock()
val sharedPreferences: SharedPreferences = mock()
whenever(sharedPreferences.edit())
.thenReturn(sharedPreferencesEditor)
val repository = CocktailsRepositoryImpl(api,
sharedPreferences)
// 2
val score = 100
repository.saveHighScore(score)
// 3
inOrder(sharedPreferencesEditor) {
// 4
verify(sharedPreferencesEditor).putInt(any(), eq(score))
verify(sharedPreferencesEditor).apply()
}
}
}
class CocktailsRepositoryImpl(
private val api: CocktailsApi,
private val sharedPreferences: SharedPreferences)
: CocktailsRepository {
Run the test and see that it fails. To fix it, add the following code to the
CocktailsRepositoryImpl class:
class CocktailsRepositoryImpl(
private val api: CocktailsApi,
private val sharedPreferences: SharedPreferences)
: CocktailsRepository {
...
You are also going to want to have a way to read the high score from the
repository. To get started, add the following test:
@Test
fun getScore_shouldGetFromSharedPreferences() {
val api: CocktailsApi = mock()
val sharedPreferences: SharedPreferences = mock()
repository.getHighScore()
verify(sharedPreferences).getInt(any(), any())
}
interface CocktailsRepository {
...
fun getHighScore(): Int
}
class CocktailsRepositoryImpl(
private val api: CocktailsApi,
private val sharedPreferences: SharedPreferences)
: CocktailsRepository {
...
Run the test, see it fail, and then add the following code to the
CocktailsRepositoryImpl class to see it pass:
If you look at these two tests, you may notice that you have some code
that is repeated in both of them. Let's DRY this up by refactoring your
RepositoryUnitTests so that it looks like the following:
class RepositoryUnitTests {
private lateinit var repository: CocktailsRepository
private lateinit var api: CocktailsApi
private lateinit var sharedPreferences: SharedPreferences
private lateinit var sharedPreferencesEditor: SharedPreferences.Edito
@Before
fun setup() {
api = mock()
sharedPreferences = mock()
sharedPreferencesEditor = mock()
whenever(sharedPreferences.edit())
.thenReturn(sharedPreferencesEditor)
@Test
fun saveScore_shouldSaveToSharedPreferences() {
val score = 100
repository.saveHighScore(score)
inOrder(sharedPreferencesEditor) {
verify(sharedPreferencesEditor).putInt(any(), eq(score))
verify(sharedPreferencesEditor).apply()
}
}
@Test
fun getScore_shouldGetFromSharedPreferences() {
repository.getHighScore()
verify(sharedPreferences).getInt(any(), any())
}
}
Spying
Suppose you want to only save the high score if it is higher than the
previously saved high score. To do that, you want to start by adding the
following test to your RepositoryUnitTests class:
@Test
fun saveScore_shouldNotSaveToSharedPreferencesIfLower() {
val previouslySavedHighScore = 100
val newHighScore = 10
val spyRepository = spy(repository)
doReturn(previouslySavedHighScore)
.whenever(spyRepository)
.getHighScore()
spyRepository.saveHighScore(newHighScore)
verify(sharedPreferencesEditor, never())
.putInt(any(), eq(newHighScore))
}
In this test you are stubbing the getHighScore() method but you also need
to call the real saveHighScore() method on the same object, which is a real
object, CocktailsRepositoryImpl. To do that you need a spy instead of a
mock. Using a spy will let you call the methods of a real object, while also
tracking every interaction, just as you would do with a mock. When
setting up spies, you need to use doReturn/whenever/method to stub a
method. Try running the test and you will see that it fails.
In order to make games for a user, you'll need a factory to build a Game
with questions, which will map the cocktails returned by the API. Create
a CocktailsGameFactoryUnitTests.kt file under app ‣ src ‣ test ‣ java ‣
com ‣ raywenderlich ‣ android ‣ cocktails ‣ game ‣ factory. Add the
following code:
class CocktailsGameFactoryUnitTests {
@Before
fun setup() {
repository = mock()
factory = CocktailsGameFactoryImpl(repository)
}
@Test
fun buildGame_shouldGetCocktailsFromRepo() {
factory.buildGame(mock())
verify(repository).getAlcoholic(any())
}
}
With this test, you are checking that buildGame is calling getAlcoholic
from the repository.
Create the following interface and class to make it compile, under app ‣
src ‣ main ‣ java ‣ com ‣ raywenderlich ‣ android ‣ cocktails ‣ game ‣
factory:
interface CocktailsGameFactory {
interface Callback {
fun onSuccess(game: Game)
fun onError()
}
}
class CocktailsGameFactoryImpl(
private val repository: CocktailsRepository)
: CocktailsGameFactory {
Stubbing callbacks
Create a new test that verifies that the callback is called when the
repository returns successfully with a list of cocktails:
private val cocktails = listOf(
Cocktail("1", "Drink1", "image1"),
Cocktail("2", "Drink2", "image2"),
Cocktail("3", "Drink3", "image3"),
Cocktail("4", "Drink4", "image4")
)
@Test
fun buildGame_shouldCallOnSuccess() {
val callback = mock<CocktailsGameFactory.Callback>()
setUpRepositoryWithCocktails(repository)
factory.buildGame(callback)
verify(callback).onSuccess(any())
}
Run the test and it will fail. Now, modify the code to the onSuccess
callback of the buildGame() so that buildGame() looks like the following:
override fun buildGame(callback: CocktailsGameFactory.Callback) {
repository.getAlcoholic(
object : RepositoryCallback<List<Cocktail>, String> {
override fun onSuccess(cocktailList: List<Cocktail>) {
callback.onSuccess(Game(emptyList()))
}
Run your test again and it will pass. Now, let's do the same with the
onError case to ensure you test the error path as well as success. First, add
the following test:
@Test
fun buildGame_shouldCallOnError() {
val callback = mock<CocktailsGameFactory.Callback>()
setUpRepositoryWithError(repository)
factory.buildGame(callback)
verify(callback).onError()
}
The following tests are similar to what you've been writing, they ensure
that CocktailsGameFactoryImpl builds a Game using the high score and maps
the list of Cocktail objects to Question objects. They are here to give you
more practice, but if you are really anxious to move on you can skip to
the next section "Testing ViewModel and LiveData".
Create the following tests that verify the factory creates a Game using the
repository.getHighScore() method:
@Test
fun buildGame_shouldGetHighScoreFromRepo() {
setUpRepositoryWithCocktails(repository)
factory.buildGame(mock())
verify(repository).getHighScore()
}
@Test
fun buildGame_shouldBuildGameWithHighScore() {
setUpRepositoryWithCocktails(repository)
val highScore = 100
whenever(repository.getHighScore()).thenReturn(highScore)
factory.buildGame(object : CocktailsGameFactory.Callback {
override fun onSuccess(game: Game)
= Assert.assertEquals(highScore, game.score.highest)
As you should always do, run the tests once to make sure that they fail.
To make them pass, modify your buildGame() method so that it is as
follows:
Now, create the following test that verifies the factory creates a Game
mapping a list of cocktails to a list of questions:
@Test
fun buildGame_shouldBuildGameWithQuestions() {
setUpRepositoryWithCocktails(repository)
factory.buildGame(object : CocktailsGameFactory.Callback {
override fun onSuccess(game: Game) {
cocktails.forEach {
assertQuestion(game.nextQuestion(),
it.strDrink,
it.strDrinkThumb)
}
}
Here, you are asserting that the image of the question that will be shown
in the UI corresponds to the cocktail image, the correct option
corresponds to the name of the drink, and also that the incorrect option
is not the name of the drink.
If you run this, the test will not compile, so add the imageUrl property to
the Question class:
Now run the test, which compiles but now fails. To make it pass, replace
your buildGame() method with the following:
override fun buildGame(callback: CocktailsGameFactory.Callback) {
repository.getAlcoholic(
object : RepositoryCallback<List<Cocktail>, String> {
override fun onSuccess(cocktailList: List<Cocktail>) {
val questions = buildQuestions(cocktailList)
val score = Score(repository.getHighScore())
val game = Game(questions, score)
callback.onSuccess(game)
}
dependencies {
...
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
testImplementation 'androidx.arch.core:core-testing:2.0.1'
}
Next, create a package called viewmodel under app ‣ src ‣ test ‣ java ‣
com ‣ raywenderlich ‣ android ‣ cocktails ‣ game. Now, create a
CocktailsGameViewModelUnitTests.kt file under this viewmodel
directory you just created with the following code:
class CocktailsGameViewModelUnitTests {
@get:Rule
val taskExecutorRule = InstantTaskExecutorRule()
}
You may have noticed @get:Rule. This is a test rule. A test rule is a tool to
change the way tests run, sometimes adding additional checks or
running code before and after your tests. Android Architecture
Components uses a background executor that is asynchronous to do its
magic. InstantTaskExecutorRule is a rule that swaps out that executor and
replaces it with synchronous one. This will make sure that, when you're
using LiveData with the ViewModel, it's all run synchronously in the tests.
Now that you have your test scaffolding, add the following to your test
file:
private lateinit var repository: CocktailsRepository
private lateinit var factory: CocktailsGameFactory
private lateinit var viewModel: CocktailsGameViewModel
private lateinit var game: Game
private lateinit var loadingObserver: Observer<Boolean>
private lateinit var errorObserver: Observer<Boolean>
private lateinit var scoreObserver: Observer<Score>
private lateinit var questionObserver: Observer<Question>
@Before
fun setup() {
// 1
repository = mock()
factory = mock()
viewModel = CocktailsGameViewModel(repository, factory)
// 2
game = mock()
// 3
loadingObserver = mock()
errorObserver = mock()
scoreObserver = mock()
questionObserver = mock()
viewModel.getLoading().observeForever(loadingObserver)
viewModel.getScore().observeForever(scoreObserver)
viewModel.getQuestion().observeForever(questionObserver)
viewModel.getError().observeForever(errorObserver)
}
In the above:
2. You'll use a Game mock to stub some of its methods and verify you call
methods on it.
3. You need a few mocked observers because the Activity will observe
LiveData objects exposed by the ViewModel. In the UI, you'll show a
loading view when retrieving the cocktails from the API and an error
view if there's an error retrieving the cocktails, score updates and
questions. Because there's no lifecycle here, you can use the
observeForever() method.
Note: Ensure to import androidx.lifecycle.Observer.
To make the test compile, create a class under app ‣ src ‣ main ‣ java ‣
com ‣ raywenderlich ‣ android ‣ cocktails ‣ game ‣ viewmodel called
CocktailsGameViewModel with the following content:
class CocktailsGameViewModel(
private val repository: CocktailsRepository,
private val factory: CocktailsGameFactory) : ViewModel() {
You'll use these methods to stub the buildGame() method from the
CocktailsGameFactory class.
verify(factory).buildGame(any())
}
Here, you're verifying that calling initGame on the ViewModel will call
buildGame from the factory.
fun initGame() {
// TODO
}
fun initGame() {
factory.buildGame(object : CocktailsGameFactory.Callback {
override fun onSuccess(game: Game) {
// TODO
}
You are going to want to show a loading view and remove the error view
while building the game. To get started with that, add the following
tests:
@Test
fun init_shouldShowLoading() {
viewModel.initGame()
verify(loadingObserver).onChanged(eq(true))
}
@Test
fun init_shouldHideError() {
viewModel.initGame()
verify(errorObserver).onChanged(eq(false))
}
In both tests, you verify that initGame publishes the correct data. When
the program posts a value to a LiveData, the object calls onChanged() with
the value. This is the function you are checking for.
Note: There are multiple ways you can verify the result. For
example, instead of using
verify(loadingObserver).onChanged(eq(true)), you could replace it
with Assert.assertTrue(viewModel.getLoading().value!!) instead to
achieve the same result. This alternative compares the last value of
the LiveData to the expected one instead of making sure a method
was called with that data.
As always, you run your new tests to ensure that they fail. To fix them,
modify your initGame() method by adding the following two lines as
follows:
fun initGame() {
loadingLiveData.value = true
errorLiveData.value = false
factory.buildGame(...)
}
You are also going to want to show the error view and stop showing the
loading view when there's a problem building the game. To get started
add the following tests:
@Test
fun init_shouldShowError_whenFactoryReturnsError() {
setUpFactoryWithError()
viewModel.initGame()
verify(errorObserver).onChanged(eq(true))
}
@Test
fun init_shouldHideLoading_whenFactoryReturnsError() {
setUpFactoryWithError()
viewModel.initGame()
verify(loadingObserver).onChanged(eq(false))
}
Run the tests to ensure that they fail. To fix them, modify your onError()
callback in initGame() as follows:
Another scenario that you will want to cover is to hide the error and
loading views when the factory builds a game successfully. To get started,
add these tests:
@Test
fun init_shouldHideError_whenFactoryReturnsSuccess() {
setUpFactoryWithSuccessGame(game)
viewModel.initGame()
verify(errorObserver, times(2)).onChanged(eq(false))
}
@Test
fun init_shouldHideLoading_whenFactoryReturnsSuccess() {
setUpFactoryWithSuccessGame(game)
viewModel.initGame()
verify(loadingObserver).onChanged(eq(false))
}
Here, you check the error is set to false two times. The first false value is
before calling the repository to build the game, and the second one is set
when the game couldn't be built because of an error.
Run the tests to ensure that they fail. To fix these tests, modify your
onSuccess() callback in initGame as follows:
Another requirement is to show the score when the game is built. Start
by adding the following test:
@Test
fun init_shouldShowScore_whenFactoryReturnsSuccess() {
val score = mock<Score>()
whenever(game.score).thenReturn(score)
setUpFactoryWithSuccessGame(game)
viewModel.initGame()
verify(scoreObserver).onChanged(eq(score))
}
Run it to make sure it doesn't pass. Now, modify your onSuccess()
callback in initGame() as follows:
You are going to want to show the first question when the game is built.
Start by adding this test:
@Test
fun init_shouldShowFirstQuestion_whenFactoryReturnsSuccess() {
val question = mock<Question>()
whenever(game.nextQuestion()).thenReturn(question)
setUpFactoryWithSuccessGame(game)
viewModel.initGame()
verify(questionObserver).onChanged(eq(question))
}
Run it to make sure that if fails. Now, modify the onSuccess() callback of
initGame as follows:
You are going to want to show the next question when calling
nextQuestion. Once again, you will start by adding a test as follows:
@Test
fun nextQuestion_shouldShowQuestion() {
val question1 = mock<Question>()
val question2 = mock<Question>()
whenever(game.nextQuestion())
.thenReturn(question1)
.thenReturn(question2)
setUpFactoryWithSuccessGame(game)
viewModel.initGame()
viewModel.nextQuestion()
verify(questionObserver).onChanged(eq(question2))
}
Here, you can see you're stubbing the nextQuestion() method from a Game
to first return question1 and then question2.
fun nextQuestion() {
// TODO
}
Now run your test to make sure that it fails. To fix it, replace your
nextQuestion() with the following implementation:
fun nextQuestion() {
game?.let {
questionLiveData.value = it.nextQuestion()
}
}
@Test
fun answerQuestion_shouldDelegateToGame_saveHighScore_showQuestionAndSc
val score = mock<Score>()
val question = mock<Question>()
whenever(game.score).thenReturn(score)
setUpFactoryWithSuccessGame(game)
viewModel.initGame()
viewModel.answerQuestion(question, "VALUE")
Notice, here, that you're using inOrder() again to check the methods are
called exactly in the specified order.
Now, run the test to make sure that it fails. Finally, add the
corresponding implementation:
fun answerQuestion(question: Question, option: String) {
game?.let {
it.answer(question, option)
repository.saveHighScore(it.score.highest)
scoreLiveData.value = it.score
questionLiveData.value = question
}
}
Mockito annotations
Instead of calling the mock() and spy() methods, you can use annotations.
For example, open RepositoryUnitTests.kt and modify the class
definition, variable definitions and setup functions to look like the
following:
@RunWith(MockitoJUnitRunner::class)
class RepositoryUnitTests {
private lateinit var repository: CocktailsRepository
@Mock
private lateinit var api: CocktailsApi
@Mock
private lateinit var sharedPreferences: SharedPreferences
@Mock
private lateinit var sharedPreferencesEditor:
SharedPreferences.Editor
@Before
fun setup() {
whenever(sharedPreferences.edit())
.thenReturn(sharedPreferencesEditor)
You've been doing a lot of work getting logic correct in your app. To see it
work in the UI, un-comment the commented implementation in
CocktailsGameActivity.kt, CocktailsGameViewModelFactory.kt and
CocktailsApplication.kt and run the app.
You now have a well tested working cocktail game with the help of TDD.
Challenge
Challenge: Writing another test
When answering incorrectly three times, it should finish the game.
When answering correctly three times sequentially, it should start
giving double score.
Write a test for each one and add the corresponding functionality
progressively to make each test pass.
Key points
With JUnit you can do state verification, also called black-box
testing.
Check the materials for the final and challenge versions of the code of
this chapter.
Unit tests are great for ensuring that all your individual pieces
work. Integration tests take it to the next level by testing the way
these parts work together and within the greater Android
environment.
Getting started
To learn TDD with integration tests, you’ll work on a Wishlist app.
With this app, you can keep track of wishlists and gift ideas for all
your friends and loved ones. You will continue working on this
app in the next chapter.
Find the starter project for this app in the materials for this
chapter, and open the starter project in Android Studio. Build and
run the app. You'll see a blank screen with a button to add a list
on the bottom. Clicking the button, you see a field to add a name
for someone's wishlist. Enter all you want right now, but it won't
save yet! You'll be able to see it when you finish the
implementation. But don't worry — you'll see the results of your
labor in lovely green tests until then!
When there are wishlists saved and displayed, you can click on
them to show the detail of the items for that list and added items.
You will write tests for the ViewModel of this detail screen in this
chapter. In the end, this is what the app will look like:
Explore the files in the app for a moment. The ones you need to be
familiar with in this chapter are:
Generally, when you want to create a unit test but can't quite test
it in isolation, you want to use an integration test. Sometimes you
can get away with extracting the logic out so it can be unit tested
(and is preferable), but the integration test is inevitable at times.
While balancing the lean towards more unit tests, you also need
to rely on integration tests to make sure that each of your
thoroughly tested units works well together with the others. If
your database works perfectly, as does your view model, it's still
useless if the code linking them fails!
When running tests that require the Android framework you have
two options:
2. Use Robolectric.
class DetailViewModelTest {
}
Great! Now, make sure you have what you need to run your
ViewModel test. Add the following test rule to your test class:
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()
// 1
private val wishlistDao: WishlistDao = Mockito.spy(
Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().context,
WishlistDatabase::class.java).build().wishlistDao())
// 2
private val viewModel = DetailViewModel(
RepositoryImpl(wishlistDao))
Using Robolectric
If you want to use Robolectric to run your tests, make sure your
test is using test in the package structure instead of androidTest.
dependencies {
testImplementation 'org.robolectric:robolectric:4.2'
}
@RunWith(RobolectricTestRunner::class)
mock-maker-inline
Dexmaker only works on device, and this will allow you to spy on
that final class while using Robolectric off device.
@Test
fun saveNewItemCallsDatabase() {
// 1
viewModel.saveNewItem(Wishlist("Victoria",
listOf("RW Android Apprentice Book", "Android phone"), 1),
"Smart watch")
// 2
verify(wishlistDao).save(any())
}
Here, you:
1. Call the saveNewItem() function with data. You can use your
own name and wishes if you like!
Build and run your test to see it fail. Remember, you need to run
these tests on a device or emulator with Android P or higher
because of the use of Dexmaker.
This error is saying that the save() function was never called on
the wishlistDao. You know what you need to do to make it pass —
keep moving on!
repository.saveWishlist(Wishlist("", listOf()))
// 3
val mockObserver = mock<Observer<Wishlist>>()
// 4
wishlistDao.findById(wishlist.id)
.observeForever(mockObserver)
verify(mockObserver).onChanged(
wishlist.copy(wishes = wishlist.wishes + name))
}
2. Create a new item name for the list and call saveNewItem().
4. Query the database and ensure that the wishlist you just
saved returns, signaling it saved correctly. When the program
posts a value to a LiveData, the object calls onChanged() with
the value. This is the function you are checking for.
It's the same pattern: creating test data as your set up, calling the
function, then verifying the result.
Build and run the test to make sure it fails before modifying the
DetailViewModel to make it pass.
It's the expected error you see. The function called save(), but it
saved the wrong thing!
Once you see that it's failing, change the body of saveNewItem() to
make it pass:
repository.saveWishlistItem(
wishlist.copy(wishes = wishlist.wishes + name))
@Test
fun getWishListCallsDatabase() {
viewModel.getWishlist(1)
verify(wishlistDao).findById(any())
}
This looks very similar to your first test in this class. Run it and
see it fail.
There's the message that says you need to implement that call to
the wishlistDao.
Once you've run your failing test, change the code in getWishList()
to make it pass:
return repository.getWishlist(0)
Run all your tests that you've written in this chapter. Hooray! All
three tests are passing!
Testing the data returned
Your last test in this chapter is to make sure getWishlist() returns
the correct data. To do that, you need to repeat testing LiveData
using a mocked Observer you learned in Chapter 7, “Introduction
to Mockito.”
@Test
fun getWishListReturnsCorrectData() {
// 1
val wishlist = Wishlist("Victoria",
listOf("RW Android Apprentice Book", "Android phone"), 1)
// 2
wishlistDao.save(wishlist)
// 3
val mockObserver = mock<Observer<Wishlist>>()
viewModel.getWishlist(1).observeForever(mockObserver)
// 4
verify(mockObserver).onChanged(wishlist)
}
return repository.getWishlist(id)
Run the test again. You can do this by clicking on the Play button
near the name of the test class.
There are three files you need to change to refactor this, and it
won't compile until you've done all three.
repository.saveWishlistItem(wishlist, name)
Run the tests to make sure nothing broke during the refactor.
It didn't — congratulations!
They are slower than unit tests, and should therefore only be
used when you need to test how things interact.
There's much more that you can do with integration tests than
ViewModel tests. In Chapter 9, "Testing the Persistence Layer,"
you'll learn how you can test your persistence layer, and Chapter
10, "Testing the Network Layer," introduces the network layer.
For more about integration testing, you can look at the Android
documentation:
https://developer.android.com/training/testing/fundamentals
#medium-tests
https://developer.android.com/training/testing/integration-
testing/
Chapter 9: Testing the
Persistence Layer
In most apps you'll build, you will store data in one way or
another. It might be in shared preferences, in a database, or
otherwise. No matter which way you're saving it, you need to be
confident it is always working. If a user takes the time to put
together content and then loses it because your persistence code
broke, both you and your user will have a sad day.
You have the tools for mocking out a persistence layer interface
from Chapter 7, "Introduction to Mockito." In this chapter you will
take it a step further, testing that when you interact with a
database, it behaves the way you expect.
Getting started
To learn about testing the persistence layer you will write tests
while building up a Room database for the Wishlist app. This app
provides a place where you can keep track of the wishlists and the
gift ideas for all your friends and loved ones.
To get started, find the starter project included for this chapter
and open it up in Android Studio. If you are continuing from
Chapter 8, "Integration," notice there are a couple differences
between the projects. It is recommended you continue by using
the starter project for this chapter. If you choose to continue with
your project from Chapter 8, "Integration," copy and override the
files that are different from the starter project for this chapter.
The files you'll need to copy are WishlistDao.kt, KoinModules.kt,
RepositoryImpl.kt and WishlistDatabase.kt.
Build and run the app. You'll see a blank screen with a button to
add a list on the bottom. Clicking the button, you see a field to
add a name for someone's wishlist. However, if you try to save
something right now it won't work! You will implement the
persistence layer to save the wishlist in this chapter.
When there are wishlists saved and displayed, you can click on
them to show the detail of the items for that list, and add items.
By the end of this chapter, this is what the app will look like:
Time to get familiar with the code.
@RunWith(AndroidJUnit4::class)
class WishlistDaoTest {
}
Continuing your set up, add the following test rule to your test
class:
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()
Almost done setting up! After your tests finish, you also need to
close your database. Add this to your test class:
@After
fun closeDb() {
wishlistDatabase.close()
}
Writing a test
Test number one is going to test that when there's nothing saved,
getAll() returns an empty list. This is a function for fetching all of
the wishlists from the database. Add the following test, using the
imports androidx.lifecycle.Observer for Observer, and
com.nhaarman.mockitokotlin2.* for mock() and verify():
@Test
fun getAllReturnsEmptyList() {
val testObserver: Observer<List<Wishlist>> = mock()
wishlistDao.getAll().observeForever(testObserver)
verify(testObserver).onChanged(emptyList())
}
You have one error right now, that getAll() is unresolved. Add the
following to WishlistDao:
Hmmm. The next minimum thing to make this test run is to add a
@Query annotation. Add an empty query annotation to getAll():
@Query("")
Try running it with this change. If you are familiar with Room,
you may know what's coming.
Run your test and it finally compiles! But... it's passing. When
practicing TDD you always want to see your tests fail first. You
never saw a state where the test was compiling and failing. You
were so careful to only add the smallest bits until it compiled.
Room made it really hard to write something that didn't work.
Maybe the real question is "Should you be testing this?" The
answer to this question is important.
Sometimes this is a gray line to try to find, and it's one of the
things that makes testing persistence difficult. It's a part of your
code that likely relies heavily on a framework.
In this case it's not up to you to test the Room framework. It's
those writing Room's responsibility to make sure that when the
database is empty, it returns nothing. Instead you want to test
that your logic, your queries, and the code that depends on them
are working correctly.
@Test
fun saveWishlistsSavesData() {
// 1
val wishlist1 = Wishlist("Victoria", listOf(), 1)
val wishlist2 = Wishlist("Tyler", listOf(), 2)
wishlistDao.save(wishlist1, wishlist2)
// 2
val testObserver: Observer<List<Wishlist>> = mock()
wishlistDao.getAll().observeForever(testObserver)
// 3
val listClass =
ArrayList::class.java as Class<ArrayList<Wishlist>>
val argumentCaptor = ArgumentCaptor.forClass(listClass)
// 4
verify(testObserver).onChanged(argumentCaptor.capture())
// 5
assertTrue(argumentCaptor.value.size > 0)
}
Here you:
4. Test that the result from the database is a non empty list. At
this point you care that data was saved and not what was
saved, so you're checking the list size only.
Great! Next, to make it compile and run, you need to add a save()
function to the WishlistDao:
@Delete
fun save(vararg wishlist: Wishlist)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun save(vararg wishlist: Wishlist)
val listClass =
ArrayList::class.java as Class<ArrayList<Wishlist>>
val argumentCaptor = ArgumentCaptor.forClass(listClass)
verify(testObserver).onChanged(argumentCaptor.capture())
val capturedArgument = argumentCaptor.value
assertTrue(capturedArgument
.containsAll(listOf(wishlist1, wishlist2)))
}
This is almost the same as your previous test with the exception
of the final line. In that line you're testing that the list result
contains the exact wishlists you expect.
Build and run your tests. It may come as a surprise, but they
failed! Why is that? Insert a debugger breakpoint on the assertion
line and inspect the capturedArgument at that point when you run it
again, using the debugger. Huh! Somehow there is a list with an
empty string in it.
if (!string.isNullOrBlank()) string?.split("|")?.toMutableList()
else mutableListOf()
Here you:
One way to abstract this work and make your data random, is by
using a Factory. A Factory object will create instances of your data
class with random values for the properties. This will make your
tests stronger and easier to write!
object WishlistFactory {
}
If you use this in other places as well, you can move this Factory
to a more convenient location. Since you're only using it in this
one test right now, this location works great.
You need a way to create random values for your data class
properties. A simple way to do this is to create helper methods to
create them, one for each type of property you need. Again, you
could place these in a reusable location, but because you're only
using them here right now, they can share the WishlistFactory.
In your Wishlist you need two types of data: String and Int. Add
these methods to your WishlistFactory:
// 1
private fun makeRandomString() = UUID.randomUUID().toString()
// 2
private fun makeRandomInt() =
ThreadLocalRandom.current().nextInt(0, 1000 + 1)
These are simple, built in ways to create random values. You can
use similar ways to create helpers for Long, Boolean, etc.
You use the random value methods you just created to set the
properties, knowing you will likely have a completely different
Wishlist every time you create one. They won't look anything like
what's on your wishlist, but they will be unique. Well, the Wishlist
won't have what you want unless you want a UUID for your
birthday.
Using a Factory in your test
You now have an easy way to create test data, so why not use it?
Refactor your tests so that each time you create a Wishlist, you
use the factory instead. It should look like this in each of your
tests:
Key points
Persistence tests help keep your user's data safe.
There are tools that you can use to test your network layer
without hitting the network, which is what you will focus on in
this chapter. You will look at three tools to add to your testing
toolbox:
Getting started
In this chapter, you will work on the network layer for an app
called Punchline. This is an app that will show you a new, random
joke every time you press a button. To start, find the starter
project in the materials for this chapter and open it in Android
Studio.
There will be no UI for you to play with until the end of Chapter
11, "User Interface." Until then, you'll see your progress made in
the form of green tests for the network layer!
The Punchline app has a single call to the network: the one that
fetches a random joke. You will test this request three times, each
using a different tool. There are a couple of files that you should
have on hand before you get started with your tests:
Joke.kt: Your Joke data model lives here. You can see that it
has values for the ID and joke.
The call you are implementing is rather simple. You make a call to
"random_joke.json", and get a JSON response back. The JSON looks
as follows:
{
"id":17,
"joke":"Where do programmers like to hangout? The Foo Bar.",
"created_at":"2018-12-31T21:08:53.772Z",
"updated_at":"2018-12-31T21:36:33.937Z",
"url":"https://rw-punchline.herokuapp.com/jokes/17.json"
}
You only care about the first two properties for this example —
"id" and "joke" — and will ignore the rest.
Now you have all the knowledge you need to get started with your
test writing!
Using MockWebServer
The first tool you will learn is MockWebServer. This is a library
from OkHttp that allows you to run a local HTTP server in your
tests. With it, you can specify what you want the server to return
and perform verifications on the requests made.
The dependency for MockWebServer is already added in the
project for you. You can see it in app ‣ build.gradle as:
testImplementation "com.squareup.okhttp3:mockwebserver:3.12.0"
To start, you need a test class. Add this empty class to your test
file:
class JokeServiceTestUsingMockWebServer {
}
Setting up MockWebServer
MockWebServer has a test rule you can use for your network tests.
It is a scriptable web server. You will supply it responses and it
will return them on request. Add the rule to your test class:
@get:Rule
val mockWebServer = MockWebServer()
3. Build it!
You then use retrofit to create your JokeService! Add this code:
There's nothing in it yet, but run the test anyway. There's some
interesting output in the console.
Scripting a response
Now that you have MockWebServer set up to receive requests, it's
time to script something for it to return!
There are two ways to set up the JSON to script a response with.
One way is to pull in the JSON from a file. This is a great option if
you have a long expected response or you have a real response
from a request that you want to copy paste in. You won't use this
first way in this chapter, but know that you can place this JSON in
a *.json file under app ‣ src ‣ test ‣ resources, then use
getJson("file/path.json") from MockWebServer to fetch it. For
example, if you had a app ‣ src ‣ test ‣ resources ‣ joke ‣
random_joke.json, you would call
getJson("joke/random_joke.json").
Because it's such a short response and because it will allow you to
dynamically build it, you will create your JSON String in your test
file.
Start by creating a property with a JSON string at the class level:
Notice the use of triple quotes to create raw strings. By using raw
strings for your JSON Strings, you don't need to worry about
escaping characters such as the quotes around the JSON
properties.
You have to admit, this JSON is pretty boring, but later you'll spice
it up! For now, it's time to learn how to use this JSON to script a
response!
// 1
mockWebServer.enqueue(
// 2
MockResponse()
// 3
.setBody(testJson)
// 4
.setResponseCode(200))
1. Use the testJson that you created as the body of the response.
There are many other things you can set on this MockResponse to
test different situations. For example, you can set headers and use
throttleBody() to simulate a slow network!
One other thing to note, as the name suggests, you can enqueue
multiple responses in a row to return with each new request. This
could be helpful when you have an integration test that hits
multiple different endpoints and combines the results.
// 1
val testObserver = jokeService.getRandomJoke().test()
// 2
testObserver.assertValue(Joke("1", "joke"))
Here, you:
2. Verify that the value that returns is a Joke object with the
same values you placed in the testJson and enqueued with
MockWebServer.
Next step of the TDD process: Write just enough code so you can
compile and run your test. Add getRandomJoke() to the JokeService
interface with a return value of Single<Joke>:
Build and run your test! As you may have expected if you went
through Chapter 9, "Testing the Persistence Layer," and have some
familiarity with Retrofit, you'll get an error that Retrofit requires
an HTTP method annotation for your new method:
Your goal is to make this run, so next you add an annotation! Add
the @GET annotation to your JokeService method:
@GET("https://raywenderlich.com")
fun getRandomJoke(): Single<Joke>
testObserver.assertValue(Joke(id, joke))
3. You can also use the testObserver to make sure there were no
errors emitted.
4. Here's what you're writing this for! You can use the
mockWebServer to get the path that was requested to compare it
to what you expect. There are many other things other than
the request path you can test this way!
Build and run your test. It passes! You now know your JokeService
uses the correct endpoint. That's all you'll use of MockWebServer for
this chapter, but you can see how it is a powerful and robust tool
for testing network requests! But what if you don't need that
much detail? That's what you'll learn how to do next.
To start, create a new test class to write your Mockito tests in. This
class can be in the same file as your MockWebServer test if you like:
class JokeServiceTestMockingService {
}
You technically don't need to put your new tests in a new test
class (as long as the test methods themselves have different
names). However, by creating this separate class, it helps keep the
topics divided and allows you to run the new tests without the
server running behind them.
Next, you need to set up your test subject. Add this to your
JokeServiceTestMockingService class. When prompted, import
com.nhaarman.mockitokotlin2.mock:
Now, the test you've been waiting for! Add this test method:
@Test
fun getRandomJokeEmitsJoke() {
// 1
val joke = Joke(id, joke)
// 2
whenever(jokeService.getRandomJoke())
.thenReturn(Single.just(joke))
// 3
val testObserver = repository.getJoke().test()
// 4
testObserver.assertValue(joke)
}
Here, you:
1. Create the Joke object to use in this test. Notice that it's using
the constant ID and joke you set up while writing the
MockWebServer tests.
4. Assert that the repository's getJoke() emits the same Joke that
comes from the JokeService you mocked.
You can see this is testing the interactions with the network layer
instead of the network calls themselves.
return service.getRandomJoke()
Now, you're calling the JokeService as expected. Run that test and
see it pass this time!
With this strategy, you can't (and don't need to) test for the
endpoint. That means you get to move onto the next tool to learn!
This is quite a change from the last chapter when you learned to
use Factories to create random test data! How would you like to
learn another way to generate test data? Now's the time! Your test
will look very similar to the one you just wrote, but with more fun
test data.
One library that helps with this is Faker. With this library, you can
generate data from names and addresses to Harry Potter and
Hitchhiker's Guide to the Galaxy. You can see the full list at
https://github.com/DiUS/java-faker#fakers. The library is already
added to the project for you. You can see it in app ‣ build.gradle
as:
testImplementation 'com.github.javafaker:javafaker:0.16'
class JokeServiceTestUsingFaker {
}
Then, your setup is like the one for the last test with one addition:
Final test for this chapter! Add this to your new test class:
@Test
fun getRandomJokeEmitsJoke() {
val joke = Joke(
faker.idNumber().valid(),
faker.lorem().sentence())
whenever(jokeService.getRandomJoke())
.thenReturn(Single.just(joke))
val testObserver = repository.getJoke().test()
testObserver.assertValue(joke)
}
Everything is the same as the last test you write except for the
creation of your Joke object at the start. For this, you're using
faker, which you instantiated above. Many of the Faker methods
return an object that's part of the library. It's from these objects
that you can get a value. For example, the IdNumber object that's
returned from idNumber() has methods to get both valid and
invalid IDs, as well as other forms of ID such as SSN.
Play around with what values you can get from Faker for your
Joke. You don't need to stick with the ID and Lorem ipsum
prescribed here.
There are some patterns, many of which you see in this book, that
guide your testing decisions. There are also some restrictions
from the libraries themselves on how they can be used.
Ultimately, you have to figure out what works best for your needs
and for your team. Don't be afraid to try out new things or think
again when something isn't working. Where are the holes in your
tests where you aren't catching the bugs? What tests are brittle
and hard to maintain? On the other hand, what tests have been
consistently saving you from deploying buggy code? Watching for
these things will help you grow to understand how to make
testing work for your app.
Key points
To keep your tests repeatable and predictable, you shouldn't
make real network calls in your tests.
How you create and maintain test data will change depending
on the needs for your app.
You can mock the network layer with Mockito if you don't
need the fine-grained control of MockWebServer.
Deciding which tools are right for the job takes time to learn
through experiment, trial, and error.
Almost all Android apps have a UI, and subsequently, an essential layer
for testing. UI testing generally verifies two things:
2. That the correct events happen when the user interacts with the
screen.
With UI tests, you can automate some of the testing you might
otherwise need to do with tedious, manual click-testing. A step up from
integration tests, these test your app most holistically.
Following the TDD process requires running your tests frequently while
building, so you won’t want to lean too heavily on UI tests. The length
of time it takes to run them will increase the time it takes to write them.
Test the things you need to test with UI tests, and push what you can
into integration or unit tests. A good rule of thumb is the 10/20/70 split
mentioned in Chapter 4, “The Testing Pyramid,” which explains that
10% of your tests should be UI tests. The idea is that you test for the
main flows, putting whatever logic you can into classes that you can
verify using a faster test.
Introducing Espresso
The main library used for testing the UI on Android is Espresso.
Manually click-testing all parts of your app is slow and tedious. With
Espresso, you can launch a screen, perform view interactions and verify
what is or is not in view. Because this is common practice, Android
Studio automatically includes the library for you when generating a new
project.
Note: Google’s motivation behind this library is for you “to write
concise, beautiful and reliable Android UI tests.”
Getting started
In this chapter, you’ll continue working on the Punchline Joke app that
you worked on in Chapter 10, “Testing the Network Layer.” This is an
app that shows you a new, random joke each time you press a button.
Open the project where you left off in Android Studio, or find the starter
project in the materials for this chapter and open that.
Build and run the app. There’s not much to see yet because it’s your job
to add the UI in this chapter.
Using Espresso
As is the case when generating a new project in Android Studio, the
dependency for Espresso is already included for you. Open app ‣
build.gradle, and you’ll see the following testing dependency alongside
the other testing dependencies:
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
This is the primary library that you’ll be using in this chapter. You’ll be
using this alongside some of the libraries and techniques you used from
previous chapters.
@RunWith(AndroidJUnit4::class)
class MainActivityTest: KoinTest {
}
In this test, you’ll mock the repository so that you’re not hitting the
network layer. This helps with speed and stability.
@Before
fun setUp() {
declareMock<Repository>()
}
You’ll need a reference to this repository so that you can stub methods
onto it later. Luckily, Koin will deliver it to you — all you need to do is
ask. Add this property to your class, importing org.koin.test.inject:
One last thing to set up before writing tests. You’ll use the Faker library
you learned in Chapter 10, “Testing the Network Layer” to generate
random test data. Add the property code to prepare this:
Writing a UI test
This Joke app has a button that makes a new joke appear, so the first
test you’ll add checks if this button is visible. Following the usual
pattern, this test will have setup, actions and verifications.
Start with the setup. Create a new test function with the following stub:
@Test
fun onLaunchButtonIsDisplayed() {
whenever(mockRepository.getJoke())
.thenReturn(Single.just(Joke(
faker.idNumber().valid(),
faker.lorem().sentence())))
}
Here, you’re stubbing out the repository so that you don’t hit the
network layer. It’s building on the skills you learned in the previous
chapters using Mockito to stub a function that returns an RxJava Single.
You’re also using Faker to generate random test data for you. Have fun
with the different values you can generate. Just make sure the type is
correct for creating your Joke.
Next, you need to open the activity and perform your verification. Add
these lines to the bottom of your new test. Use the suggested
androidx.test.espresso.* imports, and know that buttonNewJoke will be
unresolved in the beginning:
// 1
ActivityScenario.launch(MainActivity::class.java)
// 2
onView(withId(R.id.buttonNewJoke))
.check(matches(isDisplayed()))
2. This is where you use the Espresso library. You’re passing the
ViewMatcher, withId(), to onView to find the view with the ID
buttonNewJoke. You haven’t created it yet, so there’s an error here.
Then, you’re using the ViewAssertion matches() to assert that this
view is also matched by the ViewMatcher isDisplayed().
You can almost run this test, but you need to write enough code to make
it compile. Add the button to activity_main.xml, inside the
ConstraintLayout tag:
<Button
android:id="@+id/buttonNewJoke"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
/>
Android Studio might complain that you haven’t added constraints yet,
but now’s not the time to make it pretty. You’re adding just enough code
to make it compile and run. Notice that you did include
android:visibility="gone". This is so you can see the test fail first, which
lets you know that your test is working.
Before you run your test, turn off animations on your testing device.
Whether it’s an emulator or physical device you’re working with, go to
Settings ‣ Developer options and set all of the following to off:
While you’re there, make sure you have “Don’t keep activities” disabled;
otherwise, the ActivityScenario cannot run.
Build and run the test. Android Studio might prompt you to pick a
device on which to run the test, so take your pick. After you run it, you’ll
see a long error that includes something like this:
This indicates the test found a button with the ID buttonNewJoke, but it’s
not being displayed. It’s an easy fix to make it pass. Remove the
visibility attribute from the XML:
android:visibility="gone"
Run the test again, and it passes. You can now move on to making sure
the joke shows up.
ActivityScenario.launch(MainActivity::class.java)
// 2
onView(withId(R.id.textJoke))
.check(matches(withText(joke.joke)))
}
This test is similar to the first test with some small but significant
differences:
2. This verification uses many of the same elements you saw before,
but this time you’re using withText() instead of isDisplayed() to
match the text of the joke. withText() accepts both a String literal
and a String reference ID.
Deciding which Matcher to use
Did you notice how many autocomplete options appeared after you
typed “with” of withText() or withId()? With so many options, how
do you know which to choose?
First, you must watch for accuracy and brittleness. Then, make sure
what you’re matching can only match the one view you want to
test.
This could tempt you to be very specific, but you also want to make
sure your tests aren’t breaking with any little change to the UI. For
example, what if you’re matching with the String literal "Okay" and
it’s later changed to "OK" and then changed again to "Got it"? You’d
have to update the test with each change.
This is why matching using IDs is common. Once you have an ID
set, it’s likely the only view on screen with that ID and unlikely to
frequently change — unless it’s a collection.
Here’s Google’s handy Espresso Cheat Sheet for possible Matchers,
Actions and Assertions:
https://developer.android.com/training/testing/espresso/cheat-
sheet
You’re almost ready to run this test. There’s no view with the ID
textJoke yet, so add that to the XML immediately below your button:
<TextView
android:id="@+id/textJoke"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
/>
Once again, you have the bare minimum to make it compile and run.
Everything will be scrunched into the corner, but don’t worry, you’ll fix
that soon.
Open MainActivity.kt and find showJoke(). render() already calls this for
you when a Joke is loaded, so you don’t need to worry about the logic,
only the UI (which makes sense with this being a chapter about UI
testing).
With everything set up, all you need to do is connect the data to the
view. Add this line to showJoke() using the suggested synthetic import:
textJoke.text = joke.joke
Refactoring
Run the app to see how it’s looking so far. It may not be pretty, but it
sure is testable!
Now that you have some UI tests in place, you can take a break from test
writing and do some refactoring in activity_main.xml.
Add these attributes to the TextView:
style="@style/TextAppearance.AppCompat.Title"
android:gravity="center_horizontal"
android:padding="16dp"
app:layout_constraintBottom_toTopOf="@+id/buttonNewJoke"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
android:text="@string/new_joke"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textJoke"
This adds a little style, along with some descriptive text and constraints.
Run it again to see the changes.
Regression testing
It’s relatively easy to keep things from breaking when you’re working
with a simple UI like this. But these tests are extremely helpful when
you’re working with a complicated UI with nested, reused views.
Because you want to limit your UI tests, you may fall into a pattern of
introducing regression tests.
Regression tests are tests that you add when something goes wrong. You
can start by adding a couple tests for the happy path in the UI layer. If
you find something that's broken, write a test for it to make sure it
doesn't break, or regress, again. You can use this at any layer of the
testing pyramid, and it might look like this when paired with TDD:
2. You write a test for the expected behavior that broke. It’s likely that
there’s not already a test for this; otherwise, it would have caught
the issue ahead of time.
3. Fix the issue. Do what you need to do to fix the bug, make the test
pass, and keep all other tests green.
4. You now have a regression test to make sure the same issue doesn’t
come up again. No zombie bugs coming back to life here.
These types of tests are especially helpful when working with legacy
systems that aren’t well tested. It’s a way to start introducing valuable
tests alongside your fixes. While you’re in there writing the regression
test, you might take a few minutes to add other tests for vulnerable
nearby areas.
Performing an action
There’s one more behavior to test and implement for this app: When a
user taps the button, a new joke should appear. This is the final and
most complex test you’ll write for this chapter.
@Test
fun onButtonClickNewJokeIsDisplayed() {
// 1
whenever(mockRepository.getJoke())
.thenReturn(Single.just(Joke(
faker.idNumber().valid(),
faker.lorem().sentence())))
ActivityScenario.launch(MainActivity::class.java)
// 2
val joke = Joke(
faker.idNumber().valid(),
faker.lorem().sentence())
whenever(mockRepository.getJoke())
.thenReturn(Single.just(joke))
// 3
onView(withId(R.id.buttonNewJoke))
.perform(click())
// 4
onView(withId(R.id.textJoke))
.check(matches(withText(joke.joke)))
}
2. Because you want a new joke to show when the button is pressed,
stub the repository with a new Joke.
3. This is where you use a ViewAction for the first time. You locate the
view using a ViewMatcher, then pass in the ViewAction click() to
perform() to perform that action.
It looks like the new joke never showed up! That’s because nothing is
happening when you click the button. Don’t worry, you can fix that.
buttonNewJoke.setOnClickListener {
viewModel.getJoke()
}
Again, all of the logic is already there for you to fetch the new joke. Call
showJoke() when it’s finished. Run your test to see it pass.
You made it! You finished Punchline with fully functioning UI tests. Run
your app and play around with it.
You can now refactor the UI and make it as visually appealing as you’d
like. Just remember to run the tests periodically. Oh yeah, and do your
best to remember the jokes — you may need them for your next party! :]
Note: If you drag and drop a test from test/ or androidTest/ into
sharedTest/, Android Studio might have some problems running it
because of caching issues.
You then need to make sure that any libraries you use in your tests are
in your dependencies list with both androidTestImplementation and
testImplementation. To save you some work, this is done for you. You’ll
see the duplicates if you open app ‣ build.gradle. Just remember to do a
Gradle Sync while your there.
The only thing left is learning how to run your shared tests. By default,
you don’t have the local vs. device control you want. You can only run
the shared tests as Android tests. There are two ways you can get this
control:
class JokeTest {
@Test
fun jokeReturnsJoke() {
val title = faker.book().title()
val joke = Joke(faker.code().isbn10(), title)
assert(title == joke.joke)
}
}
./gradlew test
This runs all of the tests you have in test/ and sharedTest/. You can use
this even if you don’t have shared tests as it will run the tests you have
in test/ only.
./gradlew connectedAndroidTest
Likewise, this one will run the tests you have in androidTest/ and
sharedTest/. This also works if you don’t have any shared tests.
Test kind: Select "All in directory" to run all of your tests. You can
change this to be more specific if you want even more control over
which tests run.
Directory: Select the path to src/ if you want to run both test/ and
sharedTest/, or sharedTest/ if you only want to run the ones in that
directory.
Click OK.
This option is now available in the drop-down if you want to run all of
your shared tests with Robolectric from Android Studio.
Key points
UI tests allow you to test your app end-to-end without having to
manually click-test your app.
You can find out about Espresso’s helper libraries and more by reading
their documentation:
https://developer.android.com/training/testing/espresso/
If you want more practice, you can look at Espresso Testing and Screen
Robots: Getting Started: https://www.raywenderlich.com/949489-
espresso-testing-and-screen-robots-getting-started
To take your UI tests to the next level, you can learn how to use Kakao
for even more elegant UI tests by reading UI Testing with Kakao Tutorial
for Android: Getting Started: https://www.raywenderlich.com/1505688-
ui-testing-with-kakao-tutorial-for-android-getting-started
Finally, this chapter uses Espresso and ActivityScenario, which are both
a part of AndroidX Test. To learn more about this suite of testing
libraries, you can watch Getting Started with AndroidX Test:
https://vimeo.com/334519652
This is the end of this section, and you’ve learned a lot about how to test
new apps and features. But what if you’re working on an existing app?
In the next section, you’ll learn some techniques for working with
legacy code.
Section III: TDD on
Legacy Projects
Now that you have an understanding of TDD
and have different tools at your disposal, you’ll
learn how to apply these techniques to projects
that weren't created using TDD and do not have
sufficient test coverage. You’ll work through
Furry Coding Companion Finder on your way to
becoming a TDD guru.
In the real world, many, if not most, apps have technical debt that you
will need to work around. In this chapter, you will learn about some of
the more common issues you will run into with legacy applications.
Then, in subsequent chapters, you will learn how to address these
when working on legacy projects.
Java was 12 years old at that time and pre-dated TDD. It had become a
mature technology that large conservative enterprises were using to
run mission-critical software. As a result, most Java developers, with
the exception of those who were working at cutting edge Agile
development shops, were not practicing TDD.
1. All known requirements for the entire project (i.e., a project that
will take months of development effort) are gathered and written
in a requirements document before coding begins.
For example, let's say that you want to be able to search for any pets
that share traits with a specific pet — in this case you will use the name
of the pet. One implementation might look like this:
class Cat(
val queenName: String,
val felineFood: String,
val scratchesFurniture: Boolean,
val isLitterTrained: Boolean)
class Dog(
val bestFriendName: String,
val food: String,
val isHouseTrained: Boolean,
val barks: Boolean)
@Test
fun `find pets by cats name`() {
val catNamedGarfield = Cat("Garfield", "Lasagne", false, false)
assertEquals(2, findPetsWithSameName(catNamedGarfield).size)
}
This test will pass. The problem is that there is a bug in your
implementation, because the code for the dog is retrieving the search
name from the wrong field. To get full code coverage, and find this
issue, you need to write an additional test that also passes in a Dog.
There is also a boundary condition that has not been addressed if
someone passes in different class, like a Lion:
@Test
fun `find pets by dogs name`() {
val dogNamedStay = Dog("Stay", "Blue Buffalo", false, false)
assertEquals(5, findPetsWithSameName(dogNamedStay).size)
}
@Test
fun `find pets by lions name`() {
val lionNamedButterCup = Lion("Buttercup", "Steak", false, false)
assertEquals(2, findPetsWithSameName(lionNamedButterCup).size)
}
In total, you needed three unit tests for this method. A less coupled
implementation might look like this:
class Cat(
name: String,
food: String,
var scratchesFurniture: Boolean,
var isLitterTrained: Boolean): Pet(name, food)
class Dog(
name: String,
food: String,
var isHouseTrained: Boolean,
var barks: Boolean): Pet(name, food)
With the new version of this code, you only need to write one test to
test the functionality of findPetsWithSameName(petToFind: Pet):
@Test
fun `find pets by cats name`() {
val catNamedGarfield = Cat("Garfield", "Lazagne", false, false)
assertEquals(2, findPetsWithSameName(catNamedGarfield).size)
}
You could refine this test further to mock a pet, taking away any
dependency on implementations of Cat. Highly coupled code can also
lead to situations where one component changes or you do a small
refactoring in one area that leads to large changes throughout the app
(or even the tests). While an app's architecture doesn't guarantee that
this won't happen, the less consistent the architecture, the more likely
you are to see this.
fun startCar() {
ignition.position = "on"
starter.crank()
engineRPM = 1000
oilPressure = 10
engineTemperature = 60
}
fun startDriving() {
if(leftDoorStatus.equals("closed") &&
rightDoorStatus.equals("closed")) {
steeringAngle = 0L
setThrottle(5)
}
}
setThrottle() has to check that the ignition position is on and that the
engineRPM and oilPressure are above 0. If they are, it sets the throttle
position to the value passed in, and sets the engine RPM and oil
pressure to a multiplier of the throttle position.
With a car, you could have multiple engine choices. For example, your
current car runs on fuel, but what if you wanted to switch the current
care out for an electric one? Things such as the engineRPM and
oilPressure would not be needed — these are really details of the
engine. As a result of this, your class currently has low cohesion.
Since this is an incomplete car, before it'll be usable, you will need to
add things such as brakes and tires, which will make Car a very big (and
complex) class.
fun startEngine() {
ignition.position = "on"
starter.crank()
engineRPM = 1000
oilPressure = 10
}
class Car {
val engine = Engine()
var steeringAngle = 0L
var leftDoorStatus = "closed"
var rightDoorStatus = "closed"
fun startCar() {
engine.startEngine()
}
fun startDriving() {
if (leftDoorStatus.equals("closed") &&
rightDoorStatus.equals("closed")) {
steeringAngle = 0L
engine.setThrottle(5)
}
}
}
Here, you have the same functionality, but classes have more of a
single purpose.
If you've been in enough legacy code-bases, you will run across
components like the first example in which you have large classes that
are doing a lot of different things. The more lines of code that a class
has, the more likely it is to have low cohesion.
Your sample projects for this book will not be that large, but you will be
using the same approach that you will want to use for these projects —
namely, focusing on one section of the app and working through the
others over time.
Old libraries
This happens a lot: A developer needs to add functionality to an app.
Instead of reinventing the wheel, they use a library from the Internet.
Time passes and the library version included with the app is not
updated. If you are lucky, the library is still being supported and there
are no breaking changes when you update it.
Even if you used TDD with the initial version of the library, there is not
much you can practically do to prevent this, outside of timing when
you do your upgrade.
If the library is open source, you could decide to take over maintenance
of it. Alternatively you will need to migrate to a new library. If your app
already has a lot of unit tests, this will break them as you add support
for the new library.
1. If your project has more than one developer, TDD will not add as
much value to the project unless the entire development team is
dedicated to practicing it.
2. Unless your project is small, the first passes at TDD will take a
non-trivial amount of effort to set up.
3. Rome wasn't built in a day; neither will your test suite be.
4. You probably will not have the luxury of stopping new feature
development for several months to add test coverage to your entire
project.
Lore has it that the Strangler pattern got its name from a vine called
the strangler vine. These vines seed themselves in fig trees. Over time,
they grow into their own plants surrounding and killing the tree.
Likewise, you components will surround the initial implementation,
eventually killing off the original one.
Key points
Lean and Extreme Programming have a lot of interdependencies.
2. If you are new to the project and it is fairly large, it can take you a
while to get your head around the architecture of the system as a
whole.
A holistic approach allows you to get functionality of the app under test
before making code changes. This gives you a number of quick wins,
including:
1. The ability to get a section of the app under test before adding
features or refactoring the code.
3. Providing you with test coverage for when you do start to refactor
your code for testability at a lower level.
Getting started
To explore UI tests, you are going to work on an app called Coding
Companion Finder.
The story
This app was created by a developer who practices pair programming, in
which two developers work side by side on a problem at the same time.
This technique has a number of benefits, including helping to improve
the quality of the software you are working on. Unfortunately, this
person often works from home where it may not always be possible to
have a human partner to pair up with.
Other developers have been in the same situation, and they had
developed a technique called purr programming, in which a human
development pair is substituted with a cat. The developer loved cats,
adopted one, began to regularly purr program, and noticed that the
quality of their software written at home dramatically improved. Beyond
the benefits of pair programming, the developer also gained a loving
companion, and soon realized that it could be used with dogs as well.
Whenever the developer was at meetup groups or work, they'd tell
friends about the benefits of purr programming and a related practice
called canine coding.
One day, when the developer was at the pet store, they noticed that a
local pet shelter was hosting an "Adopt a Pet" day and immediately
thought: "What if there was an app to help match fellow programmers to
these pets?" They decided to partner with the shelter to create the
Coding Companion Finder.
The app has been successful at placing companions into loving homes,
but many pets are still without homes and many developers have yet to
discover this technique. After getting feedback from users, the shelter
has some ideas for the app, but the original developer is too busy, so they
have reached out to you!
If you are not in the US, Canada or Mexico, choose the United States for
your location and 30354 as your zipcode. Once your account is created,
go here https://www.petfinder.com/user/login/, log in, and then create a
API key by entering a Application Name, Application URL, accept the
Terms of Service and click the GET A KEY button.
Once you request a key, you will be redirected to a page that will show
you an API Key and an API Secret. Copy the API key value.
Now, import the starter project and open up MainActivity.kt. At the top
of this file you will see the following:
Replace the string with the key that you've just copied. Run the app.
// carouselview library
implementation "com.synnapps:carouselview:0.1.5"
// retrofit
implementation "com.squareup.okhttp3:logging-interceptor:3.11.0"
implementation 'com.squareup.retrofit2:retrofit:2.5.0'
implementation 'com.squareup.retrofit2:converter-gson:2.5.0'
implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adap
if (petFinderService == null) {
val logger = HttpLoggingInterceptor()
logger.level = HttpLoggingInterceptor.Level.BODY
val client = OkHttpClient.Builder()
.addInterceptor(logger)
.connectTimeout(60L, TimeUnit.SECONDS)
.readTimeout(60L, TimeUnit.SECONDS)
.addInterceptor(AuthorizationInterceptor(this))
.build()
petFinderService = Retrofit.Builder()
.baseUrl("http://api.petfinder.com/v2/")
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.client(client)
.build().create(PetFinderService::class.java)
}
val bottomNavigation =
findViewById<BottomNavigationView>(R.id.bottomNavigation)
NavigationUI.setupWithNavController(bottomNavigation, navHostController
This is using the Jetpack Navigation Library to set up your
BottomNavigationView and hook it up to a fragment element in your
activity_main.xml layout. Open up that file and you will see the
following:
<fragment
android:id="@+id/mainPetfinderFragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@id/bottomNavigation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph"
/>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNavigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="1"
app:menu="@menu/bottom_navigation_menu"/>
app:navGraph="@navigation/nav_graph"
<item
android:id="@id/randomCompanionFragment"
android:enabled="true"
android:icon="@drawable/ic_featured_pet_black_24dp"
android:title="@string/featured_pet"
app:showAsAction="ifRoom" />
<item
android:id="@id/searchForCompanionFragment"
android:enabled="true"
android:icon="@drawable/ic_search_black_24dp"
android:title="@string/find_pet"
app:showAsAction="ifRoom" />
</menu>
<navigation
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/randomCompanionFragment">
<fragment
android:id="@+id/randomCompanionFragment"
android:name="com.raywenderlich.codingcompanionfinder.randomcompanio
android:label="fragment_random_pet"
tools:layout="@layout/fragment_random_companion"/>
<fragment
android:id="@+id/searchForCompanionFragment"
android:name="com.raywenderlich.codingcompanionfinder.searchforcompa
android:label="fragment_search_for_pet"
tools:layout="@layout/fragment_search_for_companion"/>
</navigation>
When you have multiple screens in your app, there are three main ways
that you might choose to do it:
1. Multiple activities, one for each screen. When your user navigates to
another screen, a new activity is shown.
Other than one additional activity that is used for the splash screen, that
assumption appears to be correct. Since you're going to be working on
the search functionality, open up SearchForCompanionFragment.kt and
have a look around.
In your onActivityCreated method, you are using findViewById to get a
reference to your search button. This probably means that the app does
not use data binding or a helper library such as Butterknife to get
references to objects in your view.
When the search button is tapped, a call is made to a local method called
searchForCompanions().
A rule of thumb when writing Espresso tests is to not have tests that
make network requests or access external resources. In the case of your
app, your boundary is the Petfinder service. Pets are constantly being
added and removed from the service, and some calls, such as the one for
a featured pet, provide different data every time you call it. Beyond the
network latency, those changes would make it very difficult to create
meaningful repeatable tests. As you get the app under test, you will be
adding a mock of this to address that.
androidTestImplementation "androidx.test:rules:1.2.0"
androidTestImplementation "androidx.test.ext:junit:1.1.1"
androidTestImplementation "android.arch.navigation:navigation-testing:1
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:3.12.0'
androidTestImplementation "androidx.test.espresso:espresso-contrib:3.2.
companion object {
// 1
val server = MockWebServer()
// 2
val dispatcher: Dispatcher = object : Dispatcher() {
@Throws(InterruptedException::class)
override fun dispatch(
request: RecordedRequest
): MockResponse {
return CommonTestDataUtil.dispatch(request) ?:
MockResponse().setResponseCode(404)
}
}
@BeforeClass
@JvmStatic
fun setup() {
// 3
server.setDispatcher(dispatcher)
server.start()
// 4
startIntent =
Intent(ApplicationProvider.getApplicationContext(),
MainActivity::class.java)
startIntent.putExtra(MainActivity.PETFINDER_URI,
server.url("").toString())
}
}
object CommonTestDataUtil {
fun dispatch(request: RecordedRequest): MockResponse? {
when (request.path) {
else -> {
return MockResponse()
.setResponseCode(404)
.setBody("{}")
}
}
}
}
This is the beginning of a helper method that will look at the request
coming in, and respond based on the request parameters. Don't worry
about the warning that the when clause can be simplified for now.
petFinderService = Retrofit.Builder()
.baseUrl("http://api.petfinder.com/v2/")
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.client(client)
.build().create(PetFinderService::class.java)
}
petFinderService = Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.client(client)
.build().create(PetFinderService::class.java)
}
This checks the intent for a value stored under PETFINDER_URI, and if one
is present uses it instead of your hard-coded URI.
companion object {
val PETFINDER_URI = "petfinder_uri"
val PETFINDER_KEY = "petfinder_key"
}
This creates two constants for your Intent keys. Finally, paste the
following into the top part of your onCreate function:
intent.getStringExtra(PETFINDER_KEY)?.let{
apiKey = it
}
This looks for an API key being passed into your MainActivity via an
Intent, and if there is one, sets your key to that value instead of your
hard-coded one.
To get started, you are going to add tests around the "search for
companion" section of your app. When you first start the app, you are
taken to the Featured Companion page.
Pressing the Find Companion button takes you to the find page.
For your first test, you are going to open up the app, press the Find
Companion bottom item and verify that you are on the "Find
Companion" page.
To get started, make sure that the app is running on your device and use
the Layout Inspector in your Android Studio Tools menu to get a
snapshot of the screen. Now, highlight that menu item to find the ID of
that menu item.
Your SearchForCompanionFragment is the entry point for your search page. It
has a view called fragment_search_for_companion. Open it up and get the ID
of your Find button:
<com.google.android.material.button.MaterialButton
android:id="@+id/searchButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Find"
app:layout_constraintBottom_toBottomOf="@+id/searchField"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/searchField"
app:layout_constraintTop_toTopOf="@id/searchField" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/searchFieldText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Enter US Location"
android:textColor="@color/primaryTextColor" />
Putting this all together, when you enter the app, you are going to click
on a button with an ID of searchForCompanionFragment. Then, you will
check that items with the ID of searchButton and searchFieldText are
visible, in order to verify that your app does indeed go to the next screen.
@Test
fun pressing_the_find_bottom_menu_item_takes_the_user_to_the_find_page()
testScenario = ActivityScenario.launch(startIntent)
onView(withId(R.id.searchForCompanionFragment))
.perform(click())
onView(withId(R.id.searchButton))
.check(matches(isDisplayed()))
onView(withId(R.id.searchFieldText))
.check(matches(isDisplayed()))
testScenario.close()
}
Note: You may have noticed that you are not really mocking
anything required for the Featured Companion screen. This is
because you are not testing this screen, and it is not necessary to
populate data for it to test your Find Companion screen.
Now that you have a passing test, you are going to want to have a basic
test to exercise performing a search and tapping on a result.
@GET("animals")
fun getAnimals(
@Header("Authorization") accessToken: String,
@Query("limit") limit: Int = 20,
@Query("location") location: String? = null
) : Deferred<Response<AnimalResult>>
To get an idea of how the data looks, you can use a tool such as Postman,
found here https://www.getpostman.com/, to explore the Petfinder
documents located here https://www.petfinder.com/developers/v2/docs/.
Looking at the output from Postman with the list of pets contracted, you
will see a list with 20 animals.
For your test, you will only need to have one or two pets. Each pet record
will also have several photos.
These photos reference URLs on the web. For the time being, you are not
going to test the photo loading and are going to want to have records
without photos. A relatively straight-forward way to do this is to copy the
fully expanded formatted text into a text file, edit out the data you don't
want, and save it. To save you some time, we have included a file called
search_30318.json in app/src/androidTest/assets.
@Throws(IOException::class)
private fun readFile(jsonFileName: String): String {
val inputStream = this::class.java
.getResourceAsStream("/assets/$jsonFileName")
?: throw NullPointerException(
"Have you added the local resource correctly?, "
+ "Hint: name it as: " + jsonFileName
)
val stringBuilder = StringBuilder()
var inputStreamReader: InputStreamReader? = null
try {
inputStreamReader = InputStreamReader(inputStream)
val bufferedReader = BufferedReader(inputStreamReader)
var character: Int = bufferedReader.read()
while (character != -1) {
stringBuilder.append(character.toChar())
character = bufferedReader.read()
}
} catch (exception: IOException) {
exception.printStackTrace()
} finally {
inputStream.close()
inputStreamReader?.close()
}
return stringBuilder.toString()
}
This is opening up your file, reading it, and returning it as a string. Next,
replace your dispatch function with the following:
// 2
onView(withId(R.id.searchFieldText))
.perform(typeText("30318"))
onView(withId(R.id.searchButton))
.perform(click())
// 3
onView(withId(R.id.searchButton))
.check(matches(isDisplayed()))
// 4
onView(withText("KEVIN")).perform(click())
// 5
onView(withText("Rome, GA")).check(matches(isDisplayed()))
testScenario.close()
}
3. It makes sure that you are still on the Find Companion screen.
5. Verifies that you are on the new page by looking for a data item that
was not in the list of items. In this case, the city of Rome, GA.
IdlingResources
When using Espresso, your test suite is running in a different thread
from your app. While there are some things related to the lifecycle of
your activity that Espresso will be aware of, there are other things that it
is not. In your case, in order to make your tests and test data readable,
you are putting your data in a separate JSON file that needs to be read in.
While this is not a big hit on the performance of your tests, a file read is
slower than the execution time of your Espresso statements.
Your tests may not need the amount of time you specified on a
device, and will thus run slower than needed.
Your tests may need more time than you specified on other devices
which makes them unreliable.
To get started, create a new Kotlin file in your test directory called
SimpleIdlingResource.kt and enter the following:
class SimpleIdlingResource : IdlingResource {
// 1
@Nullable
@Volatile
private var callback: IdlingResource.ResourceCallback? = null
// 2
// Idleness is controlled with this boolean.
var activeResources = AtomicInteger(0)
// 3
override fun isIdleNow(): Boolean {
return activeResources.toInt() < 1
}
// 4
fun incrementBy(incrementValue: Int) {
if (activeResources.addAndGet(incrementValue) < 1 &&
callback != null) {
callback!!.onTransitionToIdle()
}
}
}
implementation 'org.greenrobot:eventbus:3.1.1'
EventBus posts and receives messages as data objects (often called Plain
Old Java Objects, or POJOs in Java). These objects need to be in your app.
Under com.raywenderlich.codingcompanionfinder in the main source
set, create a new package called testhooks. In that package, create a
Kotlin file called IdlingEntity.kt, and add the following content:
// 1
@Subscribe
fun onEvent(idlingEntity: IdlingEntity) {
// noop
}
// 2
override fun onStart() {
super.onStart()
EventBus.getDefault().register(this)
}
// 3
override fun onStop() {
super.onStop()
EventBus.getDefault().unregister(this)
}
This is doing three things:
EventBus.getDefault().post(IdlingEntity(1))
EventBus.getDefault().post(IdlingEntity(-1))
GlobalScope.launch {
accessToken = (activity as MainActivity).accessToken
(activity as MainActivity).petFinderService
?.let { petFinderService ->
// increment the IdlingResources
EventBus.getDefault().post(IdlingEntity(1))
val getAnimalsRequest = petFinderService
.getAnimals(accessToken, location = companionLocation)
if (searchForPetResponse.isSuccessful) {
searchForPetResponse.body()?.let {
GlobalScope.launch(Dispatchers.Main) {
if (it.animals.size > 0) {
noResultsTextView?.visibility = INVISIBLE
viewManager = LinearLayoutManager(context)
companionAdapter = CompanionAdapter(it.animals,
searchForCompanionFragment)
petRecyclerView = view?.let {
it.findViewById<RecyclerView>(
R.id.petRecyclerView
).apply {
layoutManager = viewManager
adapter = companionAdapter
}
}
} else {
noResultsTextView?.visibility = VISIBLE
}
}
}
} else {
noResultsTextView?.visibility = VISIBLE
}
// Decrement the idling resources.
EventBus.getDefault().post(IdlingEntity(-1))
}
}
}
You're almost there! Open up your FindCompanionInstrumentedTest.kt
and add a line to create a SimpleIdlingResource as a property at the class
level:
@Subscribe
fun onEvent(idlingEntity: IdlingEntity) {
idlingResource.incrementBy(idlingEntity.incrementValue)
}
Next, in
searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_compan
ion_details(), add these two lines after you launch your activity:
EventBus.getDefault().register(this)
IdlingRegistry.getInstance().register(idlingResource)
This registers your test class with EventBus and registers the idling
resources. Finally, add these two lines at the end of that function before
testScenario.close():
IdlingRegistry.getInstance().unregister(idlingResource)
EventBus.getDefault().unregister(this)
Now that you have all of this in place, it's time to add the shelter
information to your companion details page. The test for this will be very
similar to the last test you added. You are going to go to your Find
Companion page, search by a location, select a companion, and then
verify that the contact information is correct. The only difference will be
what you are checking for.
Looking at your current tests, you have the following code at the
beginning of both:
testScenario = ActivityScenario.launch(startIntent)
testScenario.close()
@Before
fun beforeTestsRun() {
testScenario = ActivityScenario.launch(startIntent)
}
The @Before annotation tells the test suite to run that function before
every test. Now, add the following method:
@After
fun afterTestsRun() {
testScenario.close()
}
testScenario = ActivityScenario.launch(startIntent)
And:
testScenario.close()
Your next test is going to use the IdlingRegistry. Having that run before
your pressing_the_find_bottom_menu_item_takes_the_user_to_the_find_page
will not affect that test. It will also make
searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_compan
ion_details and your next test more readable. Move the following two
lines from
searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_compan
ion_details to the end of the beforeTestRun() function:
EventBus.getDefault().register(this)
IdlingRegistry.getInstance().register(idlingResource)
IdlingRegistry.getInstance().unregister(idlingResource)
EventBus.getDefault().unregister(this)
onView(withId(R.id.searchForCompanionFragment))
.perform(click())
onView(withId(R.id.searchFieldText))
.perform(typeText("30318"))
onView(withId(R.id.searchButton)).perform(click())
onView(withId(R.id.searchButton))
.check(matches(isDisplayed()))
onView(withText("KEVIN")).perform(click())
@Test
fun searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_c
find_and_select_kevin_in_30318()
onView(withText("Rome, GA")).check(matches(isDisplayed()))
}
Finally, run the tests and make sure everything is still green — it is
important to check your refactors haven't accidentally broken anything.
@Test
fun verify_that_companion_details_shows_a_valid_phone_number_and_email(
find_and_select_kevin_in_30318()
onView(withText("(706) 236-4537"))
.check(matches(isDisplayed()))
onView(withText("[email protected]"))
.check(matches(isDisplayed()))
}
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/city"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/city_placeholder"
app:layout_constraintBottom_toBottomOf="@id/breed"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/breed"
app:layout_constraintTop_toTopOf="@+id/breed" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/age"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/age_placeholder"
app:layout_constraintBottom_toTopOf="@id/meetTitlePlaceholder"
app:layout_constraintEnd_toStartOf="@id/sex"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/breed" />
With:
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/breed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/breed_placeholder"
app:layout_constraintBottom_toTopOf="@+id/email"
app:layout_constraintEnd_toStartOf="@id/city"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/petCarouselView" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/city"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/city_placeholder"
app:layout_constraintBottom_toBottomOf="@id/breed"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/breed"
app:layout_constraintTop_toTopOf="@+id/breed" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/email"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="email placeholder"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@+id/age"
app:layout_constraintEnd_toStartOf="@id/telephone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/breed" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/telephone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="telephone placeholder"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/email"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/email"
app:layout_constraintTop_toTopOf="@+id/email" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/age"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/age_placeholder"
app:layout_constraintBottom_toTopOf="@id/meetTitlePlaceholder"
app:layout_constraintEnd_toStartOf="@id/sex"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/email" />
Next, open ViewCompanionFragment.kt and add the following to the
populatePet() function:
populateTextField(R.id.email, animal.contact.email)
populateTextField(R.id.telephone, animal.contact.phone)
Congratulations! Your app is under test and the shelter has a new feature
that will help them to place more coding companions!
Key points
When working with a legacy app, start by abstracting away external
dependencies.
Don't try to get everything under test at one time.
When getting a legacy app under test you will probably end up
needing to use IdlingResources.
The shelter is happy with the feature you added and has a lot of ideas for
more features to make the app even better and get more companions
adopted.
Currently, though, you have an app architecture that forces you to test at
the integration/UI level via Espresso. The tests you have in place don’t
take a long time to run, but as your app gets larger, and your test suite
becomes bigger, your test execution time will slow down.
Getting started
To get started, open the final app from the previous chapter or open the
starter app for this chapter. Then, open
FindCompanionInstrumentedTest.kt located inside the androidTest
source set.
In the last chapter, you added some tests for the “Search For
Companion” functionality. You can find this test inside
FindCompanionInstrumentedTest.kt having the name
searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_compan
ion_details.
1. It starts the app’s main activity, which takes the user to the Random
Companion screen; this screen is backed by RandomCompanionFragment.
1. Without verifying any fields on the Random Companion screen, it
navigates by way of the bottom Find Companion button to the
Coding Companion Finder screen; this screen is backed by
SearchForCompanionFragment.
Before you start to refactor, you need to make sure you have tests around
everything that you’re changing. This helps to ensure that your
refactoring doesn’t accidentally break anything. Because you’re changing
things to an MVVM architecture, you’re going to touch all of the data
elements this fragment displays.
@Test
fun verify_that_companion_details_shows_a_valid_phone_number_and_email(
find_and_select_kevin_in_30318()
onView(withText("(706) 236-4537"))
.check(matches(isDisplayed()))
onView(withText("[email protected]"))
.check(matches(isDisplayed()))
}
This is testing some of the fields in the View Companion details, but not
all of them. Because Espresso tests are slow, it’s better to add these
checks to one of your existing tests.
@Test
fun searching_for_a_companion_and_tapping_on_it_takes_the_user_to_the_c
find_and_select_kevin_in_30318()
onView(withText("Rome, GA")).check(matches(isDisplayed()))
onView(withText("Domestic Short Hair")).check(matches(isDisplayed()))
onView(withText("Young")).check(matches(isDisplayed()))
onView(withText("Female")).check(matches(isDisplayed()))
onView(withText("Medium")).check(matches(isDisplayed()))
onView(withText("Meet KEVIN")).check(matches(isDisplayed()))
}
transaction.replace(R.id.viewCompanion, viewCompanionFragment).addToBac
This FrameLayout also has a higher Z value, which makes it display over
the ConstraintLayout.
Look at the setupClickEvent in CompanionViewHolder, and you’ll see that
you’re doing a transaction to replace R.id.viewCompanion with a
ViewCompanionFragment.
transaction.replace(R.id.viewCompanion, viewCompanionFragment)
.addToBackStack("companionView")
.commit()
The issue is most likely that two views show this information — but one
is hiding below the other. One way to fix this problem might be to also
match on the ID of the field.
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/breed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/breed_placeholder"
app:layout_constraintBottom_toTopOf="@+id/email"
app:layout_constraintEnd_toStartOf="@id/city"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/petCarouselView" />
Open nav_graph.xml inside res ‣ navigation and add the following inside
the <navigation> element:
<fragment
android:id="@+id/viewCompanion"
android:name="com.raywenderlich.codingcompanionfinder.searchforcompani
android:label="fragment_view_companion"
tools:layout="@layout/fragment_view_companion" >
<argument
android:name="animal"
app:argType="com.raywenderlich.codingcompanionfinder.models.Animal"
</fragment>
Next, replace:
<fragment
android:id="@+id/searchForCompanionFragment"
android:name="com.raywenderlich.codingcompanionfinder.searchforcompani
android:label="fragment_search_for_pet"
tools:layout="@layout/fragment_search_for_companion" />
<fragment
android:id="@+id/searchForCompanionFragment"
android:name="com.raywenderlich.codingcompanionfinder.searchforcompani
android:label="fragment_search_for_pet"
tools:layout="@layout/fragment_search_for_companion" >
<action
android:id="@+id/action_searchForCompanionFragment_to_viewCompanion
app:destination="@id/viewCompanion" />
</fragment>
To pass the arguments with Jetpack Navigation, you’ll use Safe Args. If
you’ve not used this before, you can learn more about it at
https://developer.android.com/guide/navigation/navigation-pass-
data#Safe-args.
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.1.0
Next, open your app level build.gradle and add the following to the top of
the file:
This is adding the Jetpack Lifecycle components. Next, add the following
Android section below buildTypes in the same file to enable data binding:
dataBinding {
enabled = true
}
<data>
<variable
name="viewCompanionViewModel"
type="com.raywenderlich.codingcompanionfinder.searchforcompanion.V
</data>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/secondaryTextColor"
android:translationZ="5dp"
tools:context=".randomcompanion.RandomCompanionFragment">
.
.
.
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
This adds the ability to bind data from the ViewCompanionViewModel to this
view.
Now, bind each attribute of the view model to each element with a
corresponding ID by replacing the text with the binding. For example, for
the element with an ID of “name”, you’ll replace the text with
@{viewCompanionViewModel.name}.
<data>
<variable
name="viewCompanionViewModel"
type="com.raywenderlich.codingcompanionfinder.searchforcompanion.V
</data>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="htt
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/secondaryTextColor"
android:translationZ="5dp"
tools:context=".randomcompanion.RandomCompanionFragment">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/petName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginBottom="5dp"
android:text="@{viewCompanionViewModel.name}"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@id/petCarouselView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.synnapps.carouselview.CarouselView
android:id="@+id/petCarouselView"
android:layout_width="0dp"
android:layout_height="200dp"
android:layout_marginBottom="5dp"
app:fillColor="#FFFFFFFF"
app:layout_constraintBottom_toTopOf="@id/breed"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/petName"
app:layout_constraintWidth_percent=".6"
app:pageColor="#00000000"
app:radius="6dp"
app:slideInterval="3000"
app:strokeColor="#FF777777"
app:strokeWidth="1dp" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/breed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{viewCompanionViewModel.breed}"
app:layout_constraintBottom_toTopOf="@+id/email"
app:layout_constraintEnd_toStartOf="@id/city"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/petCarouselView" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/city"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{viewCompanionViewModel.city}"
app:layout_constraintBottom_toBottomOf="@id/breed"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/breed"
app:layout_constraintTop_toTopOf="@+id/breed" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/email"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{viewCompanionViewModel.email}"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@+id/age"
app:layout_constraintEnd_toStartOf="@id/telephone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/breed" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/telephone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{viewCompanionViewModel.telephone}"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/email"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/email"
app:layout_constraintTop_toTopOf="@+id/email" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/age"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{viewCompanionViewModel.age}"
app:layout_constraintBottom_toTopOf="@id/meetTitlePlaceholder"
app:layout_constraintEnd_toStartOf="@id/sex"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/email" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/sex"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{viewCompanionViewModel.sex}"
app:layout_constraintBottom_toBottomOf="@id/age"
app:layout_constraintEnd_toStartOf="@id/size"
app:layout_constraintStart_toEndOf="@id/age"
app:layout_constraintTop_toTopOf="@id/age" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/size"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{viewCompanionViewModel.size}"
app:layout_constraintBottom_toBottomOf="@id/age"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/sex"
app:layout_constraintTop_toTopOf="@id/age" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/meetTitlePlaceholder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{viewCompanionViewModel.title}"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@+id/descriptionScroll"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/age" />
<androidx.core.widget.NestedScrollView
android:id="@+id/descriptionScroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:paddingStart="30dp"
android:paddingEnd="30dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent=".25"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintVertical_bias="1.0">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{viewCompanionViewModel.description}" />
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Build and run to make sure the app compiles and that there are no errors.
There’s still one other piece of this refactor that you’ll need to do to wrap
things up. With your data binding, you no longer need populatePet() or
populateTextField(...), so delete them.
Looking through the tests, two of the three tests are referencing the
following method:
private fun find_and_select_kevin_in_30318() {
onView(withId(R.id.searchForCompanionFragment))
.perform(click())
onView(withId(R.id.searchFieldText))
.perform(typeText("30318"))
onView(withId(R.id.searchButton)).perform(click())
onView(withId(R.id.searchButton))
.check(matches(isDisplayed()))
onView(withText("KEVIN")).perform(click())
}
This does a good job of testing most of the three scenarios with one
exception: It does not verify all of the data when you list the results of a
search. To fix that, you’ll write a test, but there’s one small change you
need to make to your test data first.
If you look at your search results data, you have two animals that are
both females. But earlier, you learned about it being difficult to match
multiple elements with the same value/ID. To make things easier to test,
you’ll change the sex of one of the companions.
@Test
fun searching_for_a_companion_in_30318_returns_two_results() {
onView(withId(R.id.searchForCompanionFragment))
.perform(click())
onView(withId(R.id.searchFieldText))
.perform(typeText("30318"))
onView(withId(R.id.searchButton)).perform(click())
onView(withId(R.id.searchButton))
.check(matches(isDisplayed()))
onView(withText("Joy")).check(matches(isDisplayed()))
onView(withText("Male")).check(matches(isDisplayed()))
onView(withText("Shih Tzu")).check(matches(isDisplayed()))
onView(withText("KEVIN")).check(matches(isDisplayed()))
onView(withText("Female")).check(matches(isDisplayed()))
onView(withText("Domestic Short Hair"))
.check(matches(isDisplayed()))
}
This verifies all of the data elements for the search results without
clicking on one like the other tests are doing.
Finally, run the test, and everything will be green.
Note: For the sake of brevity, you’re not breaking these test
conditions before making them pass. Before you move on, however,
a good exercise is to try changing various data elements to ensure
that each assertion breaks before setting the data back to a state
that makes the test pass.
This displays by going to the app and searching for companions under an
invalid location.
There are two scenarios for which you need to add coverage:
1. When the user enters a valid location, but there are no results.
This adds a mock for a zip code location of 90210 that returns no results.
@Test
fun searching_for_a_companion_in_90210_returns_no_results() {
onView(withId(R.id.searchForCompanionFragment))
.perform(click())
onView(withId(R.id.searchFieldText))
.perform(typeText("90210"))
onView(withId(R.id.searchButton)).perform(click())
onView(withId(R.id.searchButton))
.check(matches(isDisplayed()))
onView(withId(R.id.noResults))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
}
Finally, uncomment that line, run the test again — and this time, it
passes.
@Test
fun searching_for_a_companion_in_a_call_returns_an_error_displays_no_re
onView(withId(R.id.searchForCompanionFragment))
.perform(click())
onView(withId(R.id.searchFieldText)).perform(typeText("dddd"))
onView(withId(R.id.searchButton)).perform(click())
onView(withId(R.id.searchButton))
.check(matches(isDisplayed()))
onView(withId(R.id.noResults))
.check(matches(withEffectiveVisibility(Visibility.VISIBLE)))
}
Run this test without commenting out the implementation, and you’ll
see a failure message that reads:
Test failed to run to completion. Reason: 'Instrumentation run failed d
Test running failed: Instrumentation run failed due to 'Process crashed
if (searchForPetResponse.isSuccessful) {
searchForPetResponse.body()?.let {
// This is a bug, the scope should be at a higher level.
GlobalScope.launch(Dispatchers.Main) {
if (it.animals.size > 0) {
noResultsTextView?.visibility = INVISIBLE
viewManager = LinearLayoutManager(context)
companionAdapter = CompanionAdapter(it.animals,
searchForCompanionFragment)
petRecyclerView = view?.let {
it.findViewById<RecyclerView>(R.id.petRecyclerView)
.apply {
layoutManager = viewManager
adapter = companionAdapter
}
}
} else {
noResultsTextView?.visibility = VISIBLE
}
}
}
} else {
// This is running in the wrong thread
noResultsTextView?.visibility = VISIBLE
}
There’s a bug in the app where a “no results” scenario causes the app to
crash. This happens because it’s trying to set a value in the view outside
of the main thread.
Refactoring SearchForCompanionFragment
Now that you have adequate coverage for this section, it’s time to do
some refactoring.
<layout>
<data>
<variable
name="searchForCompanionViewModel"
type="com.raywenderlich.codingcompanionfinder.searchforcompanion.S
</data>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="htt
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".searchforcompanion.SearchForCompanionFragment">
.
.
.
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
android:text="@={searchForCompanionViewModel.companionLocation}"
android:visibility="invisible"
android:visibility="@{searchForCompanionViewModel.noResultsViewVisiblit
The final fragment_search_for_companion.xml will look like this:
<?xml version="1.0" encoding="utf-8"?>
<layout>
<data>
<variable
name="searchForCompanionViewModel"
type="com.raywenderlich.codingcompanionfinder.searchforcompanion.S
</data>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="htt
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".searchforcompanion.SearchForCompanionFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/searchForCompanion"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/searchField"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/petRecyclerView"
app:layout_constraintEnd_toStartOf="@id/searchButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent=".7">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/searchFieldText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@={searchForCompanionViewModel.companionLocatio
android:hint="Enter US Location"
android:textColor="@color/primaryTextColor" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/searchButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Find"
app:layout_constraintBottom_toBottomOf="@+id/searchField"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/searchField"
app:layout_constraintTop_toTopOf="@id/searchField" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/petRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent=".8"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/searchField" />
<TextView
android:id="@+id/noResults"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="No Results"
android:textSize="36sp"
android:textStyle="bold"
android:visibility="@{searchForCompanionViewModel.noResultsView
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent=".8"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/searchField" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
GlobalScope.launch {
accessToken = (activity as MainActivity).accessToken
(activity as MainActivity).petFinderService?
.let { petFinderService ->
EventBus.getDefault().post(IdlingEntity(1))
// 2
val getAnimalsRequest = petFinderService.getAnimals(
accessToken,
location =
searchForCompanionViewModel.companionLocation.value
)
GlobalScope.launch(Dispatchers.Main) {
if (searchForPetResponse.isSuccessful) {
searchForPetResponse.body()?.let {
if (it.animals.size > 0) {
// 3
searchForCompanionViewModel
.noResultsViewVisiblity
.postValue(INVISIBLE)
viewManager = LinearLayoutManager(context)
companionAdapter = CompanionAdapter(it.animals,
searchForCompanionFragment)
petRecyclerView = view?.let {
it.findViewById<RecyclerView>(
R.id.petRecyclerView
).apply {
layoutManager = viewManager
adapter = companionAdapter
}
}
} else {
// 3
searchForCompanionViewModel
.noResultsViewVisiblity
.postValue(VISIBLE)
}
}
} else {
// 3
searchForCompanionViewModel
.noResultsViewVisiblity
.postValue(VISIBLE)
}
}
EventBus.getDefault().post(IdlingEntity(-1))
}
}
}
2. Uses the bound value from the ViewModel to pass the location that a
user is searching for into the web API call.
fun searchForCompanions() {
GlobalScope.launch {
EventBus.getDefault().post(IdlingEntity(1))
// 2
val getAnimalsRequest = petFinderService.getAnimals(
accessToken,
location = companionLocation.value
)
GlobalScope.launch(Dispatchers.Main) {
if (searchForPetResponse.isSuccessful) {
searchForPetResponse.body()?.let {
// 3
animals.postValue(it.animals)
if (it.animals.size > 0) {
// 3
noResultsViewVisiblity.postValue(INVISIBLE)
} else {
// 3
noResultsViewVisiblity.postValue(View.VISIBLE)
}
}
} else {
// 3
noResultsViewVisiblity.postValue(View.VISIBLE)
}
}
EventBus.getDefault().post(IdlingEntity(-1))
}
}
1. Passes the Retrofit service and access token into your ViewModel.
This code:
Now that you made those changes, you can remove searchForCompanions()
in the SearchForCompanionFragment.
In the next chapter, you’ll make use of Koin when you refactor some of
your tests. But since you’re refactoring your code now, you can add Koin
now.
// Koin
implementation 'org.koin:koin-android-viewmodel:1.0.1'
androidTestImplementation 'org.koin:koin-test:1.0.1'
And place it into a companion object. You also need to rename them to
API_KEY and API_SECRET:
// remove these!!
intent.getStringExtra(PETFINDER_KEY)?.let {
apiKey = it
}
Following that, remove the following line since it’s no longer needed:
// 2
private val petFinderService: PetFinderService by inject()
private var token = Token()
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
var mainResponse = chain.proceed(chain.request())
val mainRequest = chain.request()
if ((mainResponse.code() == 401 ||
mainResponse.code() == 403) &&
!mainResponse.request().url().url().toString()
.contains("oauth2/token")) {
// 3
val tokenRequest = petFinderService.getToken(
clientId = MainActivity.API_KEY,
clientSecret = MainActivity.API_SECRET)
val tokenResponse = tokenRequest.execute()
if (tokenResponse.isSuccessful()) {
tokenResponse.body()?.let {
token = it
val builder = mainRequest.newBuilder()
.header("Authorization", "Bearer " +
it.accessToken)
.method(mainRequest.method(), mainRequest.body())
mainResponse = chain.proceed(builder.build())
}
}
}
return mainResponse
}
.addInterceptor(AuthorizationInterceptor(this))
Change it to:
.addInterceptor(AuthorizationInterceptor())
class SearchForCompanionViewModel(
val petFinderService: PetFinderService
): ViewModel() {
Now, remove:
Create a new file in the main project package named KoinModule.kt and
add the following:
const val PETFINDER_URL = "PETFINDER_URL"
Retrofit.Builder()
.baseUrl(get(PETFINDER_URL) as String)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.client(client)
.build().create(PetFinderService::class.java)
}
viewModel { ViewCompanionViewModel() }
viewModel { SearchForCompanionViewModel(get()) }
}
<application
android:name=".CodingCompanionFinder"
android:allowBackup="true"
android:icon="@mipmap/ic_coding_companion"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_coding_companion_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/AppTheme">
.
.
.
This tells the app to use the new Application object when starting the
app.
To the following:
Finally, in the same Fragment, remove the following line from the
onCreateView since you no longer need it:
searchForCompanionViewModel =
ViewModelProviders.of(this)
.get(SearchForCompanionViewModel::class.java)
While your app is working correctly, run the tests. You’ll notice that most
of them are broken.
To fix them, open the FindCompanionInstrumentedTest.kt inside
androidTest and make the test class inherit from KoinTest. It’ll look like
this:
This is creating a function that loads the appModule you defined earlier
and an inline module that replaces urlsModule to reference the URL for
your MockWebServer.
@Before
fun beforeTestsRun() {
testScenario = ActivityScenario.launch(startIntent)
// Insert them here!!
stopKoin()
loadKoinTestModules()
EventBus.getDefault().register(this)
IdlingRegistry.getInstance().register(idlingResource)
}
Since Koin starts as part of the app, this stops that instance of Koin, so
you can inject the test Koin modules, which is done in
loadKoinTestModules().
Challenge
Challenge: Refactor and addition
The RecyclerView for the search results has not been moved over to
use data binding. Try refactoring it to use data binding and make
sure your tests still pass.
Try adding a new feature with an Espresso test and then refactor it.
Key points
Make sure your tests cover everything that you’re changing.
TDD is a journey, but there are a lot of homeless coding companions and
pair-less developers counting on you. So, stay tuned for the next chapter,
where you’ll learn how to refactor your tests to start to go fast.
Chapter 15: Refactoring Your Tests
Sometimes you need to slow down to move fast. In development, that
means taking the time to write and refactor your tests so that you can go
fast with your testing. Right now your app is still fairly small, but the
shelters have big plans for it. There are lots of homeless companions and
pairless developers that need to be matched up! In the last chapter you
started with end-to-end UI tests, added some missing coverage, and then
refactored your code to made it easier to go fast.
As your app gets larger, this will slow down your development velocity
because a number of things happen, including:
Your Espresso tests will take longer and longer for the test suite to
run.
Tests that exercise one part of the app will often be exercising other
parts of the app as well. A change to these other parts can (and will)
break many tests that should not be related to what you are testing.
In this chapter you're going to break down your tests into integration and
unit-level. Along the way you will learn some tricks for mocking things
out, breaking things down, and even sharing tests between Espresso and
Robolectric. A lot of people are counting on you, so let's get started!
This limitation negates that benefit of being able to run the same test in
Espresso and Robolectric (other than the shared syntax). This is a
shortcoming with the current default Android project setup. Luckily,
there is a way to get around this by using a shared source set.
To get started, open the starter project for this chapter or your final
project from the last one. Go to the app ‣ src directory. You will see three
directories there. androidTest, main and test. Delete test, and rename
androidTest to be sharedTest.
Next, open your app level build.gradle and add the following under your
android section:
android {
sourceSets {
String sharedTestDir = 'src/sharedTest/java'
String sharedResources = 'src/sharedTest/assets'
test {
java.srcDir sharedTestDir
resources.srcDirs += sharedResources
}
androidTest {
java.srcDir sharedTestDir
resources.srcDirs += sharedResources
}
}
}
This is creating a new source set that maps both your test and
androidTest to your sharedTest directory. It is also nesting an Android
directive under an Android directive so yours should look like this:
android {
.
.
.
android {
sourceSets {
.
.
.
}
}
.
.
.
}
Note: This may look familiar from the sharedTest set up you did in
Chapter 11, "User Interface."
Run your tests in Espresso (you might need to sync Gradle first) and they
will be green.
Note: If you find some of the tests are failing, check that
MainActivity.accessToken is set to your token you retrieved in
Chapter 13.
Now that you have your tests moved to a sharedTest source set, there are
a few things you need to do in order to get them working with
Robolectric.
First, open your app level build.gradle and add the following to the
dependencies section:
testImplementation 'androidx.test:runner:1.2.0'
testImplementation 'androidx.test.espresso:espresso-core:3.2.0'
testImplementation "androidx.test:rules:1.2.0"
testImplementation "androidx.test.ext:junit:1.1.1"
testImplementation "android.arch.navigation:navigation-testing:1.0.0-al
testImplementation 'com.squareup.okhttp3:mockwebserver:3.12.0'
testImplementation "androidx.test.espresso:espresso-contrib:3.2.0"
testImplementation 'org.koin:koin-test:1.0.1'
testImplementation 'org.robolectric:robolectric:4.3'
This is adding all of the dependencies that you had for your Espresso
tests at the unit level. It is also including the Robolectric dependencies
that you will need. Next, add the following to the top level android
section of the same file:
testOptions {
unitTests.includeAndroidResources = true
unitTests.returnDefaultValues = true
}
Now, go to your app component drop-down at the top of your IDE and
select Edit Configurations.
Select the + button.
Now, under the class select the ellipsis .... The following window will pop
up:
Select FindCompanionInstrumentedTest and press OK. Finally, it will
take you to the previous screen. Press OK on that to continue.
Your new test configuration will be highlighted. Go ahead and run it.
Oh no! Something is not right. If you look at the error messages you will
see the following (you may need to scroll down beyond the first couple of
errors):
@Before
fun beforeTestsRun() {
testScenario = ActivityScenario.launch(startIntent)
That Intent is set up in your companion object:
@BeforeClass
@JvmStatic
fun setup() {
server.setDispatcher(dispatcher)
server.start()
// It is being set right here!
startIntent = Intent(
ApplicationProvider.getApplicationContext(),
MainActivity::class.java)
startIntent.putExtra(MainActivity.PETFINDER_URI,
server.url("").toString())
}
When running Robolectric this doesn't get called before the @Before setup
function. More importantly, this Intent was initially set up to pass in
your mockwebserver URL when running your tests. In the last chapter
you refactored things so that this is not needed anymore, so let's get rid
of it.
To do that, get rid of the last two lines in that function so that it looks
like this:
@BeforeClass
@JvmStatic
fun setup() {
server.setDispatcher(dispatcher)
server.start()
}
@Before
fun beforeTestsRun() {
testScenario = ActivityScenario.launch(startIntent)
To:
@Before
fun beforeTestsRun() {
testScenario =
ActivityScenario.launch(MainActivity::class.java)
Now run your tests again.
Things are looking better but you still have some failing tests (or perhaps
not!).
These are failing with the same error message. At this point, before
reading further, a good exercise is to trace through things to see if you
can figure out what is going wrong here.
If you trace through this you will see that there are two tests that fail
when they try to click on an element with text that contains KEVIN,
which is the last line of the following function:
It would appear that data from your mockWebServer is not being loaded.
The odd thing is that if you look at this test...
@Test
fun searching_for_a_companion_in_30318_returns_two_results() {
onView(withId(R.id.searchForCompanionFragment))
.perform(click())
onView(withId(R.id.searchFieldText))
.perform(typeText("30318"))
onView(withId(R.id.searchButton)).perform(click())
onView(withId(R.id.searchButton))
.check(matches(isDisplayed()))
onView(withText("Joy")).check(matches(isDisplayed()))
onView(withText("Male")).check(matches(isDisplayed()))
onView(withText("Shih Tzu")).check(matches(isDisplayed()))
onView(withText("KEVIN")).check(matches(isDisplayed()))
onView(withText("Female")).check(matches(isDisplayed()))
onView(withText("Domestic Short Hair"))
.check(matches(isDisplayed()))
}
It is able to load up the data and works correctly on some machines but
fails on others. You may experience either of these scenarios. This is
something that can cause a lot of frustration. Some tests are working
correctly, other similar ones that should are not — despite the tests
running correctly on Espresso. The problem has to do with how
Robolectric handles threads. Unlike when you are running tests on an
emulator or device, Robolectric shares a single thread for UI operations
and test code.
import org.robolectric.annotation.LooperMode
@RunWith(AndroidJUnit4::class)
@LooperMode(LooperMode.Mode.PAUSED)
class FindCompanionInstrumentedTest: KoinTest {
Now run your tests again and all of them will pass.
Note: Your can find out more about this PAUSED Robolectric
LooperMode at http://robolectric.org/blog/2019/06/04/paused-
looper/.
Your end-to-end test is currently testing all of this, but the shelters have
a lot of changes that they are going to want to make to this page, along
with the SearchForCompanionFragment and other fragments in your end-to-
end test chain. This will begin to make your end-to-end tests fragile, so
now is a good time to move this to a more focused test.
To get started, open up your app level build.gradle and add the following
to your dependencies:
androidTestImplementation "org.robolectric:annotations:4.3"
// Once https://issuetracker.google.com/127986458 is fixed this can be t
// fragmentscenario testing
debugImplementation 'androidx.fragment:fragment-testing:1.1.0-beta01'
debugImplementation "androidx.test:core:1.2.0"
Note: At the time of this writing there is a known issue with this
module. This means that you need to include it as part of your
implementation. To prevent this from going to production you are
limiting this to a debug build.
@RunWith(AndroidJUnit4::class)
@LooperMode(LooperMode.Mode.PAUSED)
This is telling it to run with AndroidJUnit4 and sets the LooperMode for the
Robolectric runs. Inside of your class add the following:
@Before
fun beforeTestsRun() {
// 1
val animal = Animal(
22,
Contact(
phone = "404-867-5309",
email = "[email protected]",
address = Address(
"",
"",
"Atlanta",
"GA",
"30303",
"USA"
)
),
"5",
"small",
arrayListOf(),
Breeds("shih tzu", "", false, false),
"Spike",
"male",
"A sweet little guy with spikey teeth!"
)
// 2
val bundle = ViewCompanionFragmentArgs(animal).toBundle()
// 3
launchFragmentInContainer<ViewCompanionFragment>(bundle,
R.style.AppTheme)
}
3. Launching your fragment with the bundle that you just created.
Two and three might seem like a bit of magic, so let's break that down.
The SafeArgs that are used to pass arguments to your fragment through
the Jetpack Navigation components are doing some things under the
hood for you (see the previous chapter for some description on SafeArgs).
In your CompanionViewHolder, you have the following setupClickEvent
method:
private fun setupClickEvent(animal: Animal){
view.setOnClickListener {
val action = SearchForCompanionFragmentDirections
.actionSearchForCompanionFragmentToViewCompanion(animal)
view.findNavController().navigate(action)
}
}
// 3
@Suppress("CAST_NEVER_SUCCEEDS")
override fun getArguments(): Bundle {
val result = Bundle()
if (Parcelable::class.java
.isAssignableFrom(Animal::class.java)) {
result.putParcelable("animal",
this.animal as Parcelable)
} else if (Serializable::class.java
.isAssignableFrom(Animal::class.java)) {
result.putSerializable("animal",
this.animal as Serializable)
} else {
throw UnsupportedOperationException(
Animal::class.java.name +
" must implement Parcelable or Serializable or" +
" must be an Enum.")
}
return result
}
}
companion object {
// 1
fun actionSearchForCompanionFragmentToViewCompanion(
animal: Animal
): NavDirections =
ActionSearchForCompanionFragmentToViewCompanion(animal)
}
}
At the time of this writing, Jetpack Navigation does not have testing
hooks for this scenario. Because of that we needed to understand what
this was doing behind the scenes to create this test. While that created a
bit of extra short term work, in the long term it gives you more
understanding about the framework.
Now that you have that out of the way, add the following test to your
ViewCompanionTest class:
@Test
fun check_that_all_values_display_correctly() {
onView(withText("Spike")).check(matches(isDisplayed()))
onView(withText("Atlanta, GA")).check(matches(isDisplayed()))
onView(withText("shih tzu")).check(matches(isDisplayed()))
onView(withText("5")).check(matches(isDisplayed()))
onView(withText("male")).check(matches(isDisplayed()))
onView(withText("small")).check(matches(isDisplayed()))
onView(withText("A sweet little guy with spikey teeth!"))
.check(matches(isDisplayed()))
onView(withText("404-867-5309")).check(matches(isDisplayed()))
onView(withText("[email protected]"))
.check(matches(isDisplayed()))
}
Even though this is a more focused test, it still will be run in Espresso. To
reduce test execution time you are verifying all of the expected display
fields in one test instead of breaking that up. Run the test in Espresso
and it will pass.
Finally, following the instructions at the beginning of this chapter, create
an Android JUnit configuration for your ViewCompanionTest and run it to
execute this test in Robolectric. This will also pass.
Now that you have your ViewCompanionFragment test more focused, let's
refactor your SearchForCompanionFragment tests.
Reviewing from the previous chapter, this fragment does the following:
@RunWith(AndroidJUnit4::class)
@LooperMode(LooperMode.Mode.PAUSED)
class SearchForCompanionTest : KoinTest {
@BeforeClass
@JvmStatic
fun setup() {
server.setDispatcher(dispatcher)
server.start()
}
}
@Subscribe
fun onEvent(idlingEntity: IdlingEntity) {
idlingResource.incrementBy(idlingEntity.incrementValue)
}
This methods are the same as the ones with the same name in your
FindCompanionInstrumentedTest.
Normally, at this point, you might want to consider refactoring this into
a shared component (although bear in mind the Three Strikes Rule,
which you can read about here: https://wiki.c2.com/?
ThreeStrikesAndYouRefactor), but there are some things that may
change so you are going to hold off on that. Following that, add in the
following methods:
@Before
fun beforeTestsRun() {
launchFragmentInContainer<SearchForCompanionFragment>(
themeResId = R.style.AppTheme,
factory = object : FragmentFactory() {
override fun instantiate(
classLoader: ClassLoader,
className: String
): Fragment {
stopKoin()
GlobalScope.async {
val serverUrl = server.url("").toString()
loadKoinTestModules(serverUrl)
}.start()
@After
fun afterTestsRun() {
// eventbus and idling resources unregister.
IdlingRegistry.getInstance().unregister(idlingResource)
EventBus.getDefault().unregister(this)
stopKoin()
}
Beyond this, your code is setting up your IdlingResource before your tests
run and tearing them down afterwards. Now, add the following test:
@Test
fun pressing_the_find_bottom_menu_item_takes_the_user_to_the_find_page()
onView(withId(R.id.searchButton))
.check(matches(isDisplayed()))
onView(withId(R.id.searchFieldText))
.check(matches(isDisplayed()))
onView(withId(R.id.searchFieldText))
.perform(typeText("30318"))
onView(withId(R.id.searchButton)).perform(click())
}
Looking at the error message you will see a message that reads
java.lang.RuntimeException: java.lang.ClassCastException:
androidx.fragment.app.testing.FragmentScenario$EmptyFragmentActivity
cannot be cast to com.raywenderlich.codingcompanionfinder.MainActivity.
Looking at your stack trace, the following line in
SearchForCompanionFragment is your problem:
searchForCompanionViewModel.accessToken = (activity as
MainActivity).accessToken
For now, remove this line. It is setting a stored access token that was
being cached. For the eagle-eyed readers: this will result in extra
requests without tokens being made. Later on there will be an exercise
where you can fix this. Next, open up your SearchForCompanionViewModel
and change the following line close to the top of the class from:
To:
Now use your Edit Configurations... to allow all tests in this class to run
in Robolectric and execute all of its tests. They will also be green.
@Test
fun searching_for_a_companion_in_a_call_returns_an_error_displays_no_re
onView(withId(R.id.searchFieldText)).perform(typeText("dddd"))
onView(withId(R.id.searchButton)).perform(click())
onView(withId(R.id.searchButton))
.check(ViewAssertions.matches(isDisplayed()))
onView(withId(R.id.noResults))
.check(ViewAssertions.matches(
withEffectiveVisibility(Visibility.VISIBLE)))
}
@Test
fun searching_for_a_companion_in_30318_returns_two_results() {
onView(withId(R.id.searchFieldText))
.perform(typeText("30318"))
onView(withId(R.id.searchButton)).perform(click())
onView(withId(R.id.searchButton))
.check(matches(isDisplayed()))
onView(withText("Joy")).check(matches(isDisplayed()))
onView(withText("Male")).check(matches(isDisplayed()))
onView(withText("Shih Tzu")).check(matches(isDisplayed()))
onView(withText("KEVIN")).check(matches(isDisplayed()))
onView(withText("Female")).check(matches(isDisplayed()))
onView(withText("Domestic Short Hair"))
.check(matches(isDisplayed()))
}
Now execute your tests in Robolectric and you might see that there is a
problem.
Note: Like the tests earlier, these tests may not reliably fail, and may
even consistently pass on your machine.
Your test that is supposed to return two results is not. Before reading
further, add some debugging to your application to see what the problem
might be. A hint: look at your IdlingResource to see if it is behaving as
you would expect.
Earlier you learned about the @LooperMode annotation and how setting it
to PAUSED can make your Robolectric tests simulate how threads would
run on an emulator or device. That does help with a lot of tests but there
is a thing you are using in your tests that does not work with Robolectric.
In your case IdlingResource is a problem. At the time of this writing the
following issue talks about this
https://github.com/robolectric/robolectric/issues/4807.
This is where your usage of Koin is going to start to pay some dividends.
If you look at your KoinModule in the main app package you will see the
following:
// 1
val urlsModule = module {
single(name = PETFINDER_URL) {
MainActivity.DEFAULT_PETFINDER_URL
}
}
val appModule = module {
// 2
single<PetFinderService> {
val logger = HttpLoggingInterceptor()
logger.level = HttpLoggingInterceptor.Level.BODY
val client = OkHttpClient.Builder()
.addInterceptor(logger)
.connectTimeout(60L, TimeUnit.SECONDS)
.readTimeout(60L, TimeUnit.SECONDS)
.addInterceptor(AuthorizationInterceptor())
.build()
Retrofit.Builder()
.baseUrl(get(PETFINDER_URL) as String)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.client(client)
.build().create(PetFinderService::class.java)
}
viewModel { ViewCompanionViewModel() }
// 3
viewModel { SearchForCompanionViewModel(get()) }
}
The following items are being injected when you run your test:
1. The URL for the Petfinder service. You override this in your test.
2. Your PetFinderService.
3. Your SearchForCompanionViewModel.
Currently your test has the following:
In order to get away from the network calls you are going to mock out
your PetFinderService. To get started replace that function with the
following:
This gets rid of your PETFINDER_URL Koin Single object and overrides
everything you need from appModule. The important thing here is the
mock of your PetFinderService. There are three parts to this mock:
2. Then you use the Mockito when function to have it look for an event.
In this scenario you are looking for a call to getAnimals on your mock
with any string for the access token, any integer for the limit and a
location string that contains the zipcode 30318.
return responseMock
}
Make sure to import retrofit2.Response, not the OkHttp one. Next, open
CommonTestDataUtil.kt in your main test package and take the private
modifier off the readFile function. Finally, run
searching_for_a_companion_in_30318_returns_two_results test in
Robolectric. It will be green.
Now run the same test using Espresso to make sure that you haven't
broken anything.
Oh no! Something is not right! Looking at the third line of your stack
trace you will see the following:
Mocking final classes with Espresso
Mockito has a limitation when running on an Android device or emulator
that prevents it from being able to mock classes that are final. When the
error above happens, though the message is not very descriptive, it can
mean that you are trying to mock a final class. In the function you
defined above you are mocking the Response class. Trace through to its
definition and you will see the following:
As luck would have it, you can actually get rid of the mock and in the
process make the code a little simpler. To do that, replace your
getMockedResponseWithResults() function with the following:
This returns an actual Response object instead of a mock, and reduces the
size of your function by three lines. Run your test again and it will fail
with the same stack trace.
At this point you are only mocking out PetFinderService, which is an
interface. The problem here is that Koin includes a version of Mockito
which is old, and missing features. To do that, find this line in your app
level build.gradle:
androidTestImplementation 'org.koin:koin-test:1.0.1'
androidTestImplementation("org.koin:koin-test:1.0.1")
{ exclude(group: "org.mockito") }
androidTestImplementation "org.mockito:mockito-android:2.28.2"
This excludes Mockito from koin-test and instead defines it as its own
deepndency. Run your test again and it will be green.
For now you are going to stick with mocking open classes in Espresso. At
some point you may find that you will need to mock classes that are not
open. One option is to make them open. But, if you would prefer to not
have to do that, this post on Medium shows a great alternative:
https://proandroiddev.com/mocking-androidtest-in-kotlin-
51f0a603d500.
If you try running all of your tests in Espresso you still have some that
are broken. Let's fix the mocks for them. To get started, add the following
functions:
Next, add Mockito when clauses that use your new methods in your
loadKoinTestModules function so that it looks like the following:
Now run all of your tests in Espresso and Robolectric and all of them will
pass.
Some scenarios where unit tests might make sense include testing:
Cover basic data marshaling that are covered at other levels of the
pyramid.
The variable definitions that are part of your data class are not good
candidates for tests, but your populateFromAnimal() function could be
tested. To get started, create a new file called
ViewCompanionViewModelTest.kt in your test package. Next, add the
following content to it:
class ViewCompanionViewModelTest {
// 1
val animal = Animal(
22,
Contact(
phone = "404-867-5309",
email = "[email protected]",
address = Address(
"",
"",
"Atlanta",
"GA",
"30303",
"USA"
) ),
"5",
"small",
arrayListOf(),
Breeds("shih tzu", "", false, false),
"Spike",
"male",
"A sweet little guy with spikey teeth!"
)
//2
@Test
fun populateFromAnimal_sets_the_animals_name_to_the_view_model(){
val viewCompanionViewModel = ViewCompanionViewModel()
viewCompanionViewModel.populateFromAnimal(animal)
// 3
assert(viewCompanionViewModel.name.equals("foo"))
}
}
1. An Animal object. This is the same data that you had in your
ViewCompanionTest animal object.
2. A test to make sure that the animals name is set when the user calls
the populateFromAnimal() function.
Now that you have a failing assertion, correct it to check for the name of
your animal:
assert(viewCompanionViewModel.name.equals("Spike"))
You may have noticed that your test here is much more focused with only
one assertion. This is intentional for a number of reasons including:
They run faster and as such do not take as much time to spin up
dependencies.
The focused assertions lead to individual tests that are not as brittle.
1. Write a test function for the next field in your ViewModel with one
assert that should fail.
class SearchForCompanionViewModelTest {
GlobalScope.launch {
EventBus.getDefault().post(IdlingEntity(1))
val getAnimalsRequest = petFinderService.getAnimals(
accessToken,
location = companionLocation.value
)
GlobalScope.launch(Dispatchers.Main) {
if (searchForPetResponse.isSuccessful) {
searchForPetResponse.body()?.let {
animals.postValue(it.animals)
if (it.animals.size > 0) {
noResultsViewVisiblity.postValue(INVISIBLE)
} else {
noResultsViewVisiblity.postValue(View.VISIBLE)
}
}
} else {
noResultsViewVisiblity.postValue(View.VISIBLE)
}
}
EventBus.getDefault().post(IdlingEntity(-1))
}
}
4. A Retrofit call that uses #2 to return data for #3 and either displays
or hides #1.
To start off, do a focused test that enters 30318 as your location and
checks to be sure that two results are returned.
// 1
val server = MockWebServer()
// 3
@Before
fun setup() {
server.setDispatcher(dispatcher)
server.start()
val logger = HttpLoggingInterceptor()
val client = OkHttpClient.Builder()
.addInterceptor(logger)
.connectTimeout(60L, TimeUnit.SECONDS)
.readTimeout(60L, TimeUnit.SECONDS)
.addInterceptor(AuthorizationInterceptor())
.build()
petFinderService = Retrofit.Builder()
.baseUrl(server.url("").toString())
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(CoroutineCallAdapterFactory())
.client(client)
.build().create(PetFinderService::class.java)
}
// 4
@Test
fun call_to_searchForCompanions_gets_results() {
val searchForCompanionViewModel =
SearchForCompanionViewModel(petFinderService)
searchForCompanionViewModel.companionLocation.value = "30318"
searchForCompanionViewModel.searchForCompanions()
Assert.assertEquals(2,
searchForCompanionViewModel.animals.value!!.size)
}
This is not an Espresso test, but it may try to run as one due to your
sharedTest setup, so use the Edit Configuration option to set it up to run
as an Android Junit test. Then try running your test.
This is because your test is trying to access the "main" thread — which
does not exist in the unit test. To fix that you are going to need to add an
InstantTaskExecutorRule. This swaps the default executor for your
ViewModel with one that executes everything synchronously on your
current thread. To add this add the following in your app level
dependencies:
testImplementation "androidx.arch.core:core-testing:2.0.1"
androidTestImplementation "androidx.arch.core:core-testing:2.0.1"
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
Oops! It's still failing, but this time for a different reason! Let's track this
down.
fun searchForCompanions() {
GlobalScope.launch {
.
.
val searchForPetResponse = getAnimalsRequest.await()
.
.
GlobalScope.launch(Dispatchers.Main) {
.
.
.
}
}
}
If you debug through the call you will see that the test exits before your
call completes with your getAnimalsRequest. You are going to need to do
something to allow this to execute your threads and wait for it until
execution is done.
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$c
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coro
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-and
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-tes
This is adding coroutine testing support, which you can read more about
here: https://github.com/Kotlin/kotlinx.coroutines. Next, add the
following at the class level of your test:
This is setting up a thread context, owned by your test thread that will
make all of your tests run under one thread.
Dispatchers.setMain(mainThreadSurrogate)
This tells the system to use this new thread context that you just created.
Now run your tests.
Another problem?! The issue is that the LiveData result is coming back
after you did your assert. To fix that, replace your test with the following:
@Test
fun call_to_searchForCompanions_gets_results() {
val searchForCompanionViewModel =
SearchForCompanionViewModel(petFinderService)
searchForCompanionViewModel.companionLocation.value = "30318"
// 1
val countDownLatch = CountDownLatch(1)
searchForCompanionViewModel.searchForCompanions()
// 2
searchForCompanionViewModel.animals.observeForever {
countDownLatch.countDown()
}
// 3
countDownLatch.await(2, TimeUnit.SECONDS)
Assert.assertEquals(2,
searchForCompanionViewModel.animals.value!!.size)
}
This is adding a CountDownLatch that waits until your result comes back.
There are three parts to using it:
1. Setting up your latch with an initial latch value; in this case it is one.
The number is how many times countDown needs to be called on it
before it continues after await.
Note: CountDownLatches are useful but can make tests slow and
brittle. An easy way to get around them often is to make the
scheduling/threading a dependency of the class you're testing, so
that you can put in "fake" synchronous scheduling within tests.
Since you want to verify that this is a valid test, change the expectation
on your assert to another value, such as 1 and re-run your test.
It fails, which is what we wanted. Now change the value back to 2 and
make it green.
When this ViewModel fetches data it sets values for your view, it also sets
the value of noResultsViewVisibility to INVISIBLE if there are results or
VISIBLE if there are none. Let's add some tests for that. To get started add
the following test:
@Test
fun call_to_searchForCompanions_with_results_sets_the_visibility_of_no_
val searchForCompanionViewModel =
SearchForCompanionViewModel(petFinderService)
searchForCompanionViewModel.companionLocation.value = "30318"
val countDownLatch = CountDownLatch(1)
searchForCompanionViewModel.searchForCompanions()
searchForCompanionViewModel.noResultsViewVisiblity
.observeForever {
countDownLatch.countDown()
}
countDownLatch.await(2, TimeUnit.SECONDS)
Assert.assertEquals(INVISIBLE,
searchForCompanionViewModel.noResultsViewVisiblity.value)
}
noResultsViewVisiblity.postValue(INVISIBLE)
to:
noResultsViewVisiblity.postValue(VISIBLE)
Now run your test and it will fail.
Undo the last change in searchForCompanion so that the line is back to:
noResultsViewVisiblity.postValue(INVISIBLE)
@Test
fun call_to_searchForCompanions_with_no_results_sets_the_visibility_of_
val searchForCompanionViewModel =
SearchForCompanionViewModel(petFinderService)
searchForCompanionViewModel.companionLocation.value = "90210"
val countDownLatch = CountDownLatch(1)
searchForCompanionViewModel.searchForCompanions()
searchForCompanionViewModel.noResultsViewVisiblity
.observeForever {
countDownLatch.countDown()
}
countDownLatch.await(2, TimeUnit.SECONDS)
Assert.assertEquals(INVISIBLE,
searchForCompanionViewModel.noResultsViewVisiblity.value)
}
Because you want to have a failing test first, your assert is currently not
correct. Run the test and it will fail.
Looking at this test and the previous one you did around visibility they
are very similar:
@Test
fun call_to_searchForCompanions_with_results_sets_the_visibility_of_no_
val searchForCompanionViewModel =
SearchForCompanionViewModel(petFinderService)
searchForCompanionViewModel.companionLocation.value = "30318"
val countDownLatch = CountDownLatch(1)
searchForCompanionViewModel.searchForCompanions()
searchForCompanionViewModel.noResultsViewVisiblity
.observeForever {
countDownLatch.countDown()
}
countDownLatch.await(2, TimeUnit.SECONDS)
Assert.assertEquals(INVISIBLE,
searchForCompanionViewModel.noResultsViewVisiblity.value)
}
@Test
fun call_to_searchForCompanions_with_no_results_sets_the_visibility_of_
val searchForCompanionViewModel =
SearchForCompanionViewModel(petFinderService)
searchForCompanionViewModel.companionLocation.value = "90210"
val countDownLatch = CountDownLatch(1)
searchForCompanionViewModel.searchForCompanions()
searchForCompanionViewModel.noResultsViewVisiblity
.observeForever {
countDownLatch.countDown()
}
countDownLatch.await(2, TimeUnit.SECONDS)
Assert.assertEquals(VISIBLE,
searchForCompanionViewModel.noResultsViewVisiblity.value)
}
The only real difference is your companionLocation value and your visibility
assert. Let's refactor this by replacing these two tests with the following:
fun callSearchForCompanionWithALocationAndWaitForVisibilityResult(locat
val searchForCompanionViewModel =
SearchForCompanionViewModel(petFinderService)
searchForCompanionViewModel.companionLocation.value = location
val countDownLatch = CountDownLatch(1)
searchForCompanionViewModel.searchForCompanions()
searchForCompanionViewModel.noResultsViewVisiblity
.observeForever {
countDownLatch.countDown()
}
countDownLatch.await(2, TimeUnit.SECONDS)
return searchForCompanionViewModel
}
@Test
fun call_to_searchForCompanions_with_results_sets_the_visibility_of_no_
val searchForCompanionViewModel = callSearchForCompanionWithALocation
Assert.assertEquals(INVISIBLE,
searchForCompanionViewModel.noResultsViewVisiblity.value)
}
@Test
fun call_to_searchForCompanions_with_no_results_sets_the_visibility_of_
val searchForCompanionViewModel = callSearchForCompanionWithALocation
Assert.assertEquals(VISIBLE,
searchForCompanionViewModel.noResultsViewVisiblity.value)
}
What you are doing here is using a common function for setting up your
call, CountDownLatch, but keeping your assert in the test. Now, technically,
you could have the assert in your common method and just pass in the
expected value to this common method. This is a matter of style. Since
part of the purpose of unit tests is to provide documentation about how
the code works, in the authors' opinion, not having the assert in the
common method makes it a little bit more readable. That said, if you find
it to be more readable by putting the assert in the common method, that
can be valid as well. The key takeaway is that tests are a form of
documentation and the goal is to structure them to make it easier for a
new person looking at the code base to understand it.
Challenge
Challenge: Test and edge cases
If you didn't finish out your test cases for your ViewCompanionViewModel
to test the other data elements, add tests following a red, green,
refactor pattern.
Key points
Source sets help you to run Espresso tests in either Espresso or
Robolectric.
Not all Espresso tests will run in Robolectric, especially if you are
using Idling resources.
As you get your legacy app under test, start to isolate tests around
Fragments and other components.
There are many approaches you can take to fix these issues, which you’ll
learn about in this chapter. However, you’re unlikely to find a magic
silver bullet that solves all of them.
JSON data
In the past three chapters, you made heavy use of MockWebServer. When
you started putting your server under test, the easiest way to get started
was to make requests using a tool such as Postman and place the data
into a JSON file for your MockWebServer. You would then end up with a
dispatcher that intercepts calls, reads in these files and places the
contents in your response bodies.
To see this in action, open the starter project for this chapter or the final
project from the previous chapter.
Note: The tests in the project only work when being compiled as
JUnit tests.
In this example, you’re doing exact matches on the request path and
specifying files based on the request. Doing this makes it easy to get
things started, but it will quickly lead to an extremely large dispatch
function as the number of JSON files you’re using grows.
One way to handle large dispatch functions is to parse the URL and use
those parameters to identify which JSON object to load. For example,
with the Coding Companion Finder app, you might have a dispatch
method that looks like this:
Post requests
Up to this point, your MockWebServer is only dealing with GET requests.
It’s time to look at how to handle POST requests.
The PetFinder API used in your app requires OAuth credentials. POST
requests are used to support this OAuth workflow during certain steps
that — up to this point — you’ve short-circuited with your
MockWebServer. It's time to address this gap in your test coverage.
Since you need to understand how OAuth works to test it, let’s do a quick
review of the OAuth flow as you’re currently using it:
On the righthand side of the window, you’ll need to make some changes.
Unlike the previous chapter, you’ll run all of the tests in the test package,
so select All in package.
For your package choose, com.raywenderlich.codingcompanionfinder.
Finally, there’s a small red error in the bottom part of the window; it
demands that you select a module, so select app.
After you press OK, your new configuration is ready to go. Give it a spin
and execute all of your tests in Robolectric.
Oh, no! Most of the tests are running except for those in
SearchForCompanionViewModelTest. Before you changed your dispatcher,
they were green. If you trace into the stack trace for these, you’ll see the
following error:
fun nonInterceptedDispatch(
request: RecordedRequest
): MockResponse? {
val headers = request.headers
return when (request.path) {
"/animals?limit=20&location=30318" -> {
MockResponse().setResponseCode(200).setBody(
readFile("search_30318.json")
)
}
"/animals?limit=20&location=90210" -> {
MockResponse().setResponseCode(200)
.setBody("{\"animals\": []}")
}
else -> {
MockResponse().setResponseCode(404).setBody("{}")
}
}
}
This code adds a version of your dispatcher that does not end up causing
your AuthorizationInterceptor in your app to be called.
class ViewCompanionViewModelTest {
// 1
val animal = Animal(
22,
Contact(
phone = "404-867-5309",
email = "[email protected]",
address = Address(
"",
"",
"Atlanta",
"GA",
"30303",
"USA"
) ),
"5",
"small",
arrayListOf(),
Breeds("shih tzu", "", false, false),
"Spike",
"male",
"A sweet little guy with spikey teeth!"
)
@Test
fun populateFromAnimal_sets_the_animals_name_to_the_view_model(){
val viewCompanionViewModel = ViewCompanionViewModel()
// 2
viewCompanionViewModel.populateFromAnimal(animal)
// 3
assert(viewCompanionViewModel.name.equals("Spike"))
}
}
Test data is contained within the same class — sometimes the same
functions as your tests — making it easier to see what the values
should be.
Disadvantages include:
object AddressData {
val atlantaAddress = Address(
"",
"",
"Atlanta",
"GA",
"30303",
"USA"
)
}
object BreedsData {
val shihTzu = Breeds("shih tzu", "", false, false)
}
object ContactsData {
val atlantaCodingShelter = Contact(
phone = "404-867-5309",
email = "[email protected]",
address = atlantaAddress
)
}
When you import the required classes, make sure you import the correct
one for Address — the one in your project, not the one from the
framework packages. This creates test objects with the same data you
hard-coded in ViewCompanionViewModelTest.kt.
@Test
fun populateFromAnimal_sets_the_animals_name_to_the_view_model() {
val viewCompanionViewModel = ViewCompanionViewModel()
viewCompanionViewModel
.populateFromAnimal(atlantaShihTzuNamedSpike)
assert(viewCompanionViewModel.name
.equals(atlantaShihTzuNamedSpike.name))
}
}
Test files that are more readable with shorter data set up code.
You have to look in another file to see details about the test data.
Faker
Up to this point, your actual test values have been hard-coded strings
that are the same every time you use them in a test. Over time, you may
run out of ideas for names. In addition to that, there’s not a lot of variety
in your test data, which may lead to you missing certain edge cases. But
don’t worry, there’s a tool to help you address this: Faker. You first saw
this in Chapter 10, "Testing the Network Layer."
Faker has data generators for various kinds of data, such as names and
addresses. To see the full power of it, you need to add it to your app. To
get started, open the app level build.gradle, and add the following to the
dependencies section:
testImplementation 'com.github.javafaker:javafaker:0.18'
androidTestImplementation 'com.github.javafaker:javafaker:0.18'
This code adds another animal object with Faker-generated data for
things like the companion's name and gender. (Yes, we added the Chuck
Norris fact for the description for fun.)
@Test
fun populateFromAnimal_sets_the_animals_description_to_the_view_model()
val viewCompanionViewModel = ViewCompanionViewModel()
System.out.println(fakerAnimal.toString())
viewCompanionViewModel.populateFromAnimal(fakerAnimal)
assertEquals("faker", viewCompanionViewModel.description)
}
This code creates a test that does an assert on the description. It also
prints the contents of your object so that you can see what kind of data
Faker creates.
Animal(
id=798,
contact=Contact(
phone=1-256-143-0873,
[email protected],
address=Address(
address1=09548 Wayne Dale,
address2=Suite 523,
city=Charitybury,
state=West Virginia,
postcode=30725-9938,
country=Northern Mariana Islands
)
),
age=0,
size=Synergistic Wool Bottle,
photos=[],
breeds=Breeds(
primary=Khao Manee,
secondary=Sealyham Terrier,
mixed=false,
unknown=false),
name=Miss Linnea Hills,
gender=female,
description=For Chuck Norris, NP-Hard = O(1).)
Of course, since you’re now using Faker, the information will be different
each time. At the moment, your assertion is not the right value, which is
reflected in the error message.
To fix this incorrect value, change the assert in your test on the last line
to:
assertEquals(fakerAnimal.description,
viewCompanionViewModel.description)
You’re now making sure that your assertion tests using the data
generated by Faker. You want to ensure that viewCompanionViewModel
contains the same description as the fakeAnimal, regardless of what Faker
generated there.
While Faker has test data many scenarios, it doesn’t include all
possibilities.
If you’ve had experience doing server-side TDD, your first thought might
be to:
1. Get your datastore into the state you want it to be.
2. Dump it to a file.
To load test data using code, you could, for example, use Faker with a
series of test objects you load using a helper function before running the
tests.
Key points
There are no magic silver bullets with test data.
JSON data is great for quickly getting started with end-to-end tests
but can become difficult to maintain as your test suites get larger.
Hard-coded data works well when your test suite is small, but it lacks
variety in test data as your test suite grows.
Tests that need to get data stores into a certain state can be
expensive because you need to insert data into the data store
programmatically.
Branches and CI
Many teams end up using a branching strategy
with their source control repository. One that is
commonly used is called Git Flow
https://nvie.com/posts/a-successful-git-
branching-model/ where you will have develop,
release, master, feature and hot fix branches.
CI tools
CI solutions need to do a lot of things including:
Monitoring your source code repository for
changes.
Self hosted CI
A large number of organizations, arguably a
majority of Android teams, have historically
used this approach. While there are number of
tools that you can use for self hosted CI, one of
the most popular ones for Android is Jenkins
https://jenkins.io/. Jenkins installations have
two main components:
1. A Build Server.
Disadvantages include:
Cloud based CI
Cloud based CI solutions move everything that
Jenkins provides to the cloud. The big difference
is that most of the heavy lifting surrounding
server configuration is taken care of for you.
Some of the more popular services include:
CircleCI
Bitrise
Travis CI
Disadvantages include:
CI strategy guidelines
Depending on the size of your project, user
base, code quality needs and size/type of your
test suite your CI strategy will change. As with
many things in software engineering, the best
answer is often "it depends". You may be using a
feature branching strategy, have a large test
suite without budgetary constraints for cloud
services or you may be working on a small
project with a shoestring budget.
Wait time
The most important consideration with the test
suites you run in CI is time. Ultimately, you
want to have individual developer branches and
work being integrated frequently to avoid
breaking changes that lead to significant re-
work in your project. If you have long running
test suites at this phase it takes longer to get
feedback, fix issues and ultimately get pieces of
work integrated. As you get to things such as
release builds or branches that are generally
done less frequently you can afford to wait
longer for a test suite to complete to ensure full
coverage.
Device coverage
If you are using a device farm that has hundreds
of different devices to execute your tests, how
do you determine which ones to execute your
tests on? In an ideal world, the answer would be,
all devices all of the time. Unfortunately, in
reality that would be a very expensive
proposition.
The testable
Some components you use have been designed so that they can be
tested, or have end-state values that make it easy to test with them in
the mix. For example, in Chapter 9, "Testing the Persistence Layer," you
learned about how to test the persistence layer in your application tests.
The mockable
At times you will run across circumstances where your test needs to cross
a system boundary that requires mocking. For example, in Chapter 10,
"Testing the Network Layer," the MockWebServer you are using is mocking
out your okhttp calls that are made to a server. In this case, the mocking
was taken care of for you. In other instances you will need to mock out
system boundaries manually.
Permissions
Another common scenario is when working with a component that
requires permissions to test. As a quick review, there are three types of
Android permissions:
Now, tap on the phone number for the companion, select ALLOW to
allow the permission request and then a call will begin to the shelter.
Open ViewCompanionFragment.kt and look at the
setupPhoneNumberOnClick() function:
// 2
override fun onPermissionRationaleShouldBeShown(
permission: PermissionRequest,
token: PermissionToken
) {/* ... */
token.continuePermissionRequest()
}
}
)
.onSameThread()
.check()
}
}
2. If permission has not been granted it shows a dialog asking for it.
@get:Rule
val grantPermissionRule: GrantPermissionRule =
GrantPermissionRule.grant(
android.Manifest.permission.CALL_PHONE)
This is granting the CALL_PHONE permission for all of the tests in your
class. Next add the following to your app level build.gradle in your
dependencies section:
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.
This adds in support for Espresso Intents, which allows you to assert that
an intent is sent. You can learn more about Espresso Intents at
https://developer.android.com/training/testing/espresso/intents.
@get:Rule
val intentsTestRule = IntentsTestRule(MainActivity::class.java)
This adds a rule to use Espresso Intents in your test. Now add the
following test:
@Test
fun verify_that_tapping_on_phone_number_dials_phone() {
// 1
val intent = Intent()
val result =
Instrumentation.ActivityResult(Activity.RESULT_OK, intent)
intending(
allOf(
hasAction(Intent.ACTION_CALL)
)
).respondWith(result)
// 2
find_and_select_kevin_in_30318()
onView(withText("(706) 236-4537")).perform(click())
// 3
intended(allOf(hasAction(Intent.ACTION_CALL),
hasData("tel:(706) 236-4537")))
}
1. Creates a mock Intent that will be called when the ACTION_CALL intent
is fired in your app.
But, if you try running all of your tests you will have a failure:
If you dig into this, the root cause is that your IntentsTestRule is causing
your MockWebServer to crash. Luckily your ViewCompanionTest does not
depend on MockWebServer, and this test should really be there anyway.
@get:Rule
val grantPermissionRule: GrantPermissionRule =
GrantPermissionRule.grant(
android.Manifest.permission.CALL_PHONE)
@get:Rule
val intentsTestRule = IntentsTestRule(MainActivity::class.java)
@Test
fun verify_that_tapping_on_phone_number_dials_phone() {
// 1
val intent = Intent()
val result =
Instrumentation.ActivityResult(Activity.RESULT_OK, intent)
Intents.intending(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_CALL)
)
).respondWith(result)
// 2
onView(withText("(706) 236-4537"))
.perform(ViewActions.click())
// 3
Intents.intended(
CoreMatchers.allOf(
IntentMatchers.hasAction(Intent.ACTION_CALL),
IntentMatchers.hasData("tel:(706) 236-4537")
)
)
}
This is adding the same test you had before without the call to navigate
to this fragment since you are testing just the fragment in isolation. The
tests in this file also do not depend on MockWebServer. Run all of the tests
in it and they will be green.
Glide
Picasso
Video Players
Network calls
The untestable
There are some components where the best TDD option is to not test it.
Determining that the component is untestable can be tricky. TDD is hard.
On one hand you don't want to give up too soon on testing something.
On the other hand you don't want to spend too much time trying to test
the untestable.
If you dive in to a view generated by it, the elements on the map are not
in a structure where you can verify that a component is in a specific
location because it a basically a canvas view. If you do a Google search
the only solutions you will find for testing this are using a UI automator
to click on a specific position on a pre-determined map. That test will
work fine when you have a specific screen size, but if you are supporting
multiple screen sizes and DPIs, the coordinates you use for one screen
size may not work for another.
Test callbacks made by your map — i.e. when you click on a point on
the map.
Test the data and items being used to create things on the map.
Rely on manual testing for the map functionality.
These will also work well for similiar libraries which are hard to test.
In order to give the system time to apply the setting you will need to
add Thread.sleep() calls which will cause much longer test execution
times, and also might result in unreliable tests.
With enough work you might be able to create reliable, repeatable tests
for these. In many cases it might be better to abstract that code into a
simple library (sometimes called a shim) that is manually tested. In your
unit tests you could then use Dependency Injection to mock this shim
you created to test the code that depends on it.
Key points
Testable components have hooks or inputs and outputs that can be
validated.
After exploring TDD and reading this chapter, you’ll discover that other
techniques and frameworks exist to enhance or complement TDD.
Here’s how it works: The team collaborates with the end users. Based on
that collaboration, the team writes one or more acceptance tests for each
requirement. After that, the development team writes the code needed
for the requirement to pass those acceptance tests.
ATDD focuses on the acceptance criteria agreed upon with the users. If
these tests pass, you can assume the users will be happy with your app.
This process gives you an idea of the feature development progress and
also avoids gold-plating, which means to over-develop features or add
functionality that end users don’t need or want.
With ATDD, you can take each requirement and write it in the user story
format. Once you have that, you can write the acceptance tests for each
one. That’s why, sometimes, this technique is also called Story Test
Driven Development (STDD).
You can write user acceptance tests (UATs), also known as Story Tests, in
a plain document or even a spreadsheet. Because these tests are not
technical, any non-technical person can read and modify them. Once the
team is satisfied, developers can use these tests as input to translate into
test code. For example, in an app that has a login feature:
Or you may have a spreadsheet with inputs and expected output, for
example, in a calculator app:
You can use them to communicate between all of the roles of the
team.
Analysts, developers, testers and the end users have a more active
role. They’re more involved and they all work together in creating
possible scenarios. They understand the stories, the examples and
the acceptance criteria.
Behavior-driven development
Behavior Driven Development (BDD) is an extension of TDD. You might
think of BDD as an enhanced version of TDD.
The main focus of TDD is to write tests first and to think about designing
the features. BDD not only does that, but also uses a natural language to
describe the tests. To begin with, it pays attention to tests method
naming. For example, the name of the methods could start with should,
or use the given/when/then format instead of the traditional naming,
which starts with test. This method naming was selected to focus and
better describe a behavior. The idea is to break down a test scenario into
three parts:
given: Describes the state of the world before you begin the behavior
you’re specifying in this scenario. These are the pre-conditions to
the test.
then: Here you describe the expected changes due to the behavior.
Instead of writing a test class per class of a feature, BDD suggests writing
a class per requirement. It also enforces writing code that uses the same
business language, so there’s a ubiquitous language. This means that
analysts and developers use the same language. For example, if you’re
writing a game where the analyst states that cars are racing, you should
have a class named Car and not a class called Automobile.
BDD doesn’t care about how the app is architected or implemented; its
main focus is on the behavior.
In BDD, you can write the acceptance criteria as follows:
Given [a context]
When [an event happens]
Then [assert something]
Cucumber is a tool that supports BDD using this style for the acceptance
criteria. It reads executable specifications that you provide written in
plain English and it'll validate that your app does what those
specifications state. The specifications consists of multiple examples, or
scenarios.
Examples:
| num1 | num2 | op | result |
| 9 | 8 | + | 17.0 |
| 7 | 6 | – | 1.0 |
| 5 | 4 | x | 20.0 |
| 3 | 2 | / | 1.5 |
| 1 | 0 | / | Infinity |
You specify the name of the feature, give a description and write the
scenario using the given/when/then format, in plain English.
You also have to translate those steps into Java or Kotlin. These steps are
known as step definitions, For example:
@When("I press {operator}")
fun i_press_op(op: Char) {
when (op) {
'+' -> onView(withId(R.id.btn_op_add)).perform(click())
'–' -> onView(withId(R.id.btn_op_subtract)).perform(click())
'x' -> onView(withId(R.id.btn_op_multiply)).perform(click())
'/' -> onView(withId(R.id.btn_op_divide)).perform(click())
'=' -> onView(withId(R.id.btn_op_equals)).perform(click())
}
}
TDD enforces good class design, ensuring that those classes work
correctly in units and collaboration with others. ATDD and BDD
enforce building the appropriate app for the user.
In BDD, you try to bring the business team and the technical team
closer by using a common language that’s understandable by both
parties. Using the given/when/then style, even non-technical people
can write tests that can directly run in automated environments.
No matter which technique you choose or if you take parts of each one,
they're all great practices to explore on your team.
Key points
In TDD, you need to write tests before adding or modifying features.