Rather than setting up a project from scratch, we should grab a sample project that already has a build file prepared from the folks who wrote the testing library we'll be using today. Normally, I would advocate learning how to set up a build file as part of the "learning to test-drive" process, but at the time of this writing, the new Android build tools based on Gradle are still a bit of a moving target. Each release sees potentially breaking updates, so rather than trying to keep up as well as the Robolectric team, let's just clone their project:
$ git clone https://github.com/robolectric/deckard-gradle.git
Let's take a look at what came with our application. Aside from the assorted build files and other detritus, the source tree contains the usual main and test folders, with one Activity and one Application in our main tree and two test suites – one using Robolectric for unit-testing on the JVM and the other using espresso for integration testing on Dalvik:
src/
├── main
│ └── java
│ └── com
│ └── example
│ ├── DeckardApplication.java
│ └── robolectric
│ └── DeckardActivity.java
└── test
└── java
└── com
└── example
├── activity
│ └── DeckardActivityRobolectricTest.java
└── espresso
└── activity
└── DeckardEspressoTest.java
First things first, let's check to make sure everything is working as it should. Open DeckardActivityRobolectricTest and add a method that looks liek this:
@Test
public void testsCanFail() {
assertTrue(false);
}
What's this do? Well, it gives us a failing test! Why might we want this you ask? Good question! We want to have a failing test so that we know the tests are actually being run! If we ran these tests and everything passed without issue, we might find ourselves asking: "Did it actually run? Can I trust the test framework to catch my mistakes?" Therefore, we create a failing test so we know we can trust our framework to fail when we mess up!
Now, if we run our tests, we should get a bunch of compilation output, and then near the end, a failure message:
$ ./gradlew test
…
com.example.activity.DeckardActivityRobolectricTest > testsCanFail FAILED
java.lang.AssertionError at DeckardActivityRobolectricTest.java:24
2 tests completed, 1 failed
:testDebug FAILED
FAILURE: Build failed with an exception.
Congratulations, you're a failure! While we're here, let's add a similar test
in DeckardEspressoTest.java
to make sure those are working alright as well.
public void testDoesNotPass() {
assertTrue(false);
}
We'll have to have a device or emulator connected to let the Espresso tests
run. They will actually install an APK and run it on your device, and you'll be
able to see the screen flash on your device. To do this, we use the
connectedIntstrumentTest
gradle task:
./gradlew connectedInstrumentTest
…
com.example.espresso.activity.DeckardEspressoTest > testDoesNotPass[Galaxy S3 - 4.3 - API 18 - 720x1280 - 4.3] FAILED
junit.framework.AssertionFailedError
at com.example.espresso.activity.DeckardEspressoTest.testDoesNotPass(DeckardEspressoTest.java:34)
:connectedInstrumentTest FAILED
FAILURE: Build failed with an exception.
Ok! It looks like thing are in order! You'll probably notice that these instrumentation tests took a lot longer than our unit tests since they have to run on a device or emulator. We therefore, we won't be using them as often.
Today, we'll be writing a tic-tac-toe game! Let's dive in. First of all, let's
rename our Activity to something other than DeckardActivity
– like
TestTacToeActivity
. I'd recommend using the rename functionality in the
"Refactor" menu in Studio/IDEA. This will not only rename the class and its
references in our tests, but should also rename
DeckardActivityRobolectricTest
to TestTacToeActivityRobolectricTest
and
other things that are similarly named, including changing the reference to
DeckardActivity
in our AndroidManifest.xml
. Should you decide not to use
Studio or another IDE capable of performing this kind of refactoring, you'll
need to do the following:
- Change the name of the file
DeckardActivity.java
toTestTacToeActivity.java
- Change the name of the file
DeckardActivityRobolectricTest.java
toTestTacToeActivityRobolectricTest.java
- Change the class in
TestTacToeActivity.java
to beTestTacToeActivity
- Change the class in
TestTacToeActivityRobolectricTest.java
to beTestTacToeActivityRobolectricTest
- Change the activity
.DeckardActivity
inAndroid.xml
to be.TestTacToeActivity
Running the tests again shows that the tests still run appropriately, but with well-named classes.
I think at this point, we can safely remove the failing tests we used to check our setup and get down to business.
Time to write some logic, but remember – we'll be writing our tests first!
@Test
public void boardHasNineChildren() throws Exception {
Activity activity = Robolectric.buildActivity(TestTacToeActivity.class).create().get();
GridView board = (GridView) activity.findViewById(R.id.board);
int count = board.getCount();
assertThat(count, equalTo(9));
}
After doing all the necessary importing, when we run our tests we will see that
this fails because there is no resource with the id board
. This type of test
starts with the same concept as "wishful thinking" from
SICP – we write
classes, methods and identifiers that don't even exist in the API that we'd
like to use, and then run the tests until they guide us to a passing
implementation. In this case, our first step will be to change the layout for
our activity to include a GridView
with the id of "board". Replace the
TextView
in res/layout/deckard.xml
with this:
<GridView android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/board"
android:numColumns="3"/>
Running the tests now will give us a failure as well, but this time, a different one! This one will tell us that our espresso test now points to a view that no longer exists. This is a good defensive strategy! I would certainly like my framework to tell me if I've accidentally deleted a view, especially if it's on a screen that I rarely look at. In this case, the deletion was intentional, so we can proudly do away with the unnecessary espresso test.
Now, when we run our tests, we can see that we are actually failing due to an assertion error, because we have asserted that our view should have 9 children but does not:
com.example.activity.TestTacToeActivityRobolectricTest > boardHasNineChildren FAILED
java.lang.AssertionError at TestTacToeActivityRobolectricTest.java:31
2 tests completed, 1 failed
:testDebug FAILED
How can we get this test passing? If you're familiar with Android, you'll know that this type of collection view will need an adapter, so the way to get this test passing is to simply provide it an adapter pre-filled with 9 elements:
import android.widget.BaseAdapter;
public class GameBoardAdapter extends BaseAdapter {
private Context context;
private ArrayList<String> spaces = new ArrayList<String>();
public GameBoardAdapter(Context context) {
this.context = context;
for(int i = 0; i < 9; i++) {
spaces.add("");
}
}
public int getCount() {
return spaces.size();
}
public void setItem(int position, String item) {
spaces.set(position, item);
}
public String getItem(int position) {
return spaces.get(position);
}
public long getItemId(int position) {
return 0;
}
public View getView(int position, View convertView, ViewGroup parent) {
TextView tv;
if (convertView == null) {
tv = new TextView(context);
tv.setLayoutParams(new GridView.LayoutParams(GridView.LayoutParams.MATCH_PARENT,
GridView.LayoutParams.MATCH_PARENT));
tv.setPadding(8,8,8,8);
}
else {
tv = (TextView) convertView;
}
tv.setText(spaces.get(position));
return tv;
}
}
and add that adapter to our GridView in onCreate():
GridView gv = (GridView) findViewById(R.id.board);
gv.setAdapter(new GameBoardAdapter(this));
If everything is working properly, our first real test should pass!
Now, let's add a new test to make sure we can interact with our game board.
@Test
public void firstViewClickedGetsAnX() {
GridView board = (GridView) activity.findViewById(R.id.board);
board.performItemClick(board, 0, 0);
String item = (String) board.getAdapter().getItem(0);
assertThat(item, equalTo("X"));
}
First of all, to let our activity work as the delegate for our game board, it will need a reference to the adapter we added in the last section, so we should promote our anonymous GameBoardAdapter to a field.
private GameBoardAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.deckard);
GridView gv = (GridView) findViewById(R.id.board);
this.adapter = new GameBoardAdapter(this);
gv.setAdapter(adapter);
}
Naturally, this causes our tests to fail again. We haven't told the board how to handle our interactions. We can add the following function to our activity:
public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
gameBoardAdapter.setItem(i, "X");
gameBoardAdapter.notifyDataSetChanged();
}
We also need to add this line to our onCreate()
method to tie that function to
our view:
gv.setOnItemClickListener(this);
In the same onCreate()
method, we should also probably keep a reference to
our gameBoardAdapter
, since this method will the onItemClick
method will need
access to is
Lastly, we need to tell the compiler that we are implementing the necessary interface:
public class TestTacToeActivity extends Activity implements AdapterView.OnItemClickListener …
And, voilà, our tests pass again, telling us our clicks on the board should work!
Currently, any space touched will be filled with an "X", but that's not a very good way to play tic-tac toe. Let's add a test to prove that it should alternate between Xs and Os:
@Test
public void theNextViewClickedGetsAnO() {
GridView board = (GridView) actvity.findViewById(R.id.board);
board.performItemClick(board, 0, 0);
board.performItemClick(board, 1, 1);
String item = (String) board.getAdapter().getItem(1);
assertThat(item, equalTo("O"));
}
This should be pretty simple right? We modify our OnItemClickListener
implementation to handle swapping:
public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
gameBoardAdapter.setItem(i, mark);
gameBoardAdapter.notifyDataSetChanged();
if(mark == "X") {
mark = "O";
} else {
mark = "X";
}
}
Which will depend on a field in the activity:
private String mark = "X";
That's it! Notice how test-driving the minimal amount of code is really making our logic simpler to suss out.
Before we move on though, it seems we have a bug -- and we can prove what we suspect is a bug without manually trying to recreate it by writing a test to prove our expected behavior.
The problem lies in what would likely be a devious mechanic: clicking a spot on the board a second time could change the mark there from a cross to a circle or vice versa! Let's prove it.
@Test
public void clickingASecondTimeLeavesItAsItWas () {
GridView board = (GridView) activity.findViewById(R.id.board);
board.performItemClick(board, 0, 0);
board.performItemClick(board, 0, 0);
String item = (String) board.getAdapter().getItem(0);
assertThat(item, equalTo("X"));
}
You are so good at debugging! On running our tests again we can clearly see
that our top-left space will gladly swap letters again after it's already been
set. Luckily, the fix is super easy! We just need to add a check at the
beginning of our onItemClick(…)
function:
if(gameBoardAdapter.getItem(i) != "") {
return;
}
Writing a test for our bug instead of simply speculating where it was gave us a clear vision of what needed to happen to solve our problem.
We've come quite a way, and our game is now basically playable, but it would be nice if it did things like show us a screen with who won. Writing a test for this starts to delve a little more in to the internals of how Robolectric does magic for us.
We'll start by writing a test that should provide a "finished" game board, then ask that test if it fires off the right intent!
@Test
public void checkForEndGame() {
GridView board = (GridView) activity.findViewById(R.id.board);
board.performItemClick(board, 0, 0);
board.performItemClick(board, 1, 1);
board.performItemClick(board, 3, 3);
board.performItemClick(board, 4, 4);
board.performItemClick(board, 6, 6);
ShadowActivity shadowActivity = Robolectric.shadowOf(activity);
Intent startedIntent = shadowActivity.getNextStartedActivity();
ShadowIntent shadowIntent = Robolectric.shadowOf(startedIntent);
assertThat(shadowIntent.getComponent().getClassName(), equalTo(GameOverActivity.class.getName()));
}
As it stands, this won't even compile, and we need to add an additional
Activity
class to launch when the game is over.
import android.app.Activity;
import android.os.Bundle;
public class GameOverActivity extends Activity {
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.end_of_game);
}
}
And we'll need to create that layout for it to load…
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:id="@+id/winner_text_view" />
<TextView
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:text="wins the game!"/>
</LinearLayout>
Now we can at least run our tests, but on doing so, we can see that Robolectric
was unable to shadow our Intent
(since we haven't actually added logic to
braodcast such an Intent
), so our test fails.
Let's add logic to check for a finished game after each turn.
First we'll create a function in our activity that asks the gameBoardAdapter
to check its state.
private void checkForFinishedGame() {
gameBoardAdapter.checkBoard();
}
And we can add some functionality to our gameBoardAdapter
to actually do the
checking:
private boolean checkValues(String val, String a, String b, String c) {
return a.equals(val) && b.equals(val) && c.equals(val);
}
public void checkBoard() {
if(checkValues("X", spaces.get(0), spaces.get(1), spaces.get(2))
|| checkValues("X", spaces.get(3), spaces.get(4), spaces.get(5))
|| checkValues("X", spaces.get(6), spaces.get(7), spaces.get(8))
|| checkValues("X", spaces.get(0), spaces.get(3), spaces.get(6))
|| checkValues("X", spaces.get(1), spaces.get(4), spaces.get(7))
|| checkValues("X", spaces.get(2), spaces.get(5), spaces.get(8))
|| checkValues("X", spaces.get(0), spaces.get(4), spaces.get(8))
|| checkValues("X", spaces.get(2), spaces.get(4), spaces.get(6)) ) {
crossWins();
return;
}
}
private void crossWins() {
Intent intent = new Intent(TestTacToeActivity.this, GameOverActivity.class);
startActivity(intent);
}
Despite being totally gross, this causes our tests to go green! Considering we feel guilty for ever even writing code like this (and if you don't, you probably should), this is probably a great place to look to refactor our code when we're done!
Lastly, all we have to do is add a call to checkForFinishedGame(); in our
activity's onItemClick
method, and we're all done!