With one simple command (har har) we can generate a whole android project using maven!
mvn archetype:generate -DarchetypeArtifactId=android-quickstart \
-DarchetypeGroupId=de.akquinet.android.archetypes \
-DarchetypeVersion=1.0.9 \
-DgroupId=com.detroitlabs.thinks.you.are.cool \
-DartifactId=test-tac-toe \
-Dplatform=10
Now, in the pom.xml, add the following as dependencies.
<dependency>
<groupId>org.robolectric</groupId>
<artifactId>robolectric</artifactId>
<version>2.0-alpha-1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.8.2</version>
<scope>test</scope>
</dependency>
Cool, let's add a test class to test out our default activity.
$ mkdir -p src/test/java/com/detroitlabs/thinks/you/are/cool/
And in vim...
:!mv src/main/java/com/detroitlabs/thinks/you/are/cool/HelloAndroidActivity.java \
src/main/java/com/detroitlabs/thinks/you/are/cool/TestTacToeActivity.java
:split src/main/java/com/detroitlabs/thinks/you/are/cool/TestTacToeActivityTest.java
By the way, we've also changed our application's default Activity, so we should
probably change that in AndroidManifest.xml
:
<activity android:name=".TestTacToeActivity">
And add an id to the TextView
in our main layout so that we can test against it:
android:id="@+id/hello"
Add a test harness.
package com.detroitlabs.thinks.you.are.cool;
import com.detroitlabs.thinks.you.are.cool.TestTacToeActivity;
import com.detroitlabs.thinks.you.are.cool.R;
import android.widget.TextView;
import org.robolectric.RobolectricTestRunner;
import org.junit.runner.RunWith;
import org.junit.Test;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.junit.Assert.assertThat;
@RunWith(RobolectricTestRunner.class)
public class TestTacToeActivityTest {
@Test
public void isThisThingOn() throws Exception {
TestTacToeActivity act = new TestTacToeActivity();
act.onCreate(null);
TextView tv = (TextView) act.findViewById(R.id.hello);
String val = tv.getText().toString();
assertThat(val, equalTo("Hello, test-tac-toe!"));
}
}
Now that we have some passing tests, let's get on with our game.
We'll let's change our test to check if there are actually 9 spaces in our grid view:
@Test public void boardHasNineChildren() throws Exception { TestTacToeActivity act = new TestTacToeActivity(); act.onCreate(null);
GridView board = (GridView) act.findViewById(R.id.board);
int count = board.getChildCount();
assertThat(count, equalTo(9));
}
This causes our tests to fail when we run them again, but tells us our next goal is an adapter to handle our game board:
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 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));
Now, our tests should go green! Let's test that clicking on the squares can do what we need it to.
@Test
public void firstViewClickedGetsAnX() {
GridView board = (GridView) act.findViewById(R.id.board);
board.performItemClick(board, 0, 0);
String item = (String) board.getAdapter().getItem(0);
assertThat(item, equalTo("X"));
TextView tv = (TextView) board.getChildAt(0);
assertThat((String) tv.getText(), equalTo("X"));
}
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);
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) act.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"));
TextView tv = (TextView) board.getChildAt(1);
assertThat((String) tv.getText(), 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) act.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) act.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(act);
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.
# In vim...
:e src/main/java/com/detroitlabs/thinks/you/are/cool/GameOverActivity.class
package com.detroitlabs.thinks.you.are.cool;
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…
# In vim...
:e res/layout/end_of_game.xml
<?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!
Well, we can tell our dear users that the game is over, but our screen end game screen is uninformative. Let's test for something useful being displayed there.
Let's modify our previous test to assure that we're passing along some information about who won by adding a few simple lines:
String winner = shadowIntent.getStringExtra("winner");
assertThat(winner, equalTo("X"));
Now we can confirm that we haven't included information in our intent about who
won. Let's go ahead and add that information to our implementation of
crossWins
:
intent.putExtra("winner", "X");
While this is all swell and good, it doesn't really tell our users anything.
Let's write a new test that passes an Intent
with our information to the new
Activity
:
@Test
public void gameOverScreenDisplaysTheWinner() {
GameOverActivity ga = new GameOverActivity();
Intent intent = new Intent(ga, GameOverActivity.class);
intent.putExtra("winner", "X");
ShadowActivity shadowActivity = shadowOf(ga);
shadowActivity.setIntent(intent);
ga.onCreate(null);
ShadowTextView tv = (ShadowTextView) shadowOf(shadowActivity.findViewById(R.id.winner_text_view));
assertThat((String) tv.getText(), equalTo("X"));
}
Luckily, implementing this is rather simple, we just add the following to
onCreate
in our GameOverActivity
:
TextView winnerTextView = (TextView) findViewById(R.id.winner_text_view);
String winner = getIntent().getStringExtra("winner");
winnerTextView.setText(winner);
Currently, only "X" can win the game, let's add some tests for the other game states. First, let's make O win:
@Test
public void checkForEndGameWhereOWins() {
GridView board = (GridView) act.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, 2, 2);
board.performItemClick(board, 7, 7);
ShadowActivity shadowActivity = shadowOf(act);
Intent startedIntent = shadowActivity.getNextStartedActivity();
ShadowIntent shadowIntent = shadowOf(startedIntent);
assertThat(shadowIntent.getComponent().getClassName(), equalTo(GameOverActivity.class.getName()));
String winner = shadowIntent.getStringExtra("winner");
assertThat(winner, equalTo("O"));
}
Now, we will obviously need to expand our already ugly checkBoard function to check all the possible winning conditions for O as well.
if(checkValues("O", spaces.get(0), spaces.get(1), spaces.get(2))
|| checkValues("O", spaces.get(3), spaces.get(4), spaces.get(5))
|| checkValues("O", spaces.get(6), spaces.get(7), spaces.get(8))
|| checkValues("O", spaces.get(0), spaces.get(3), spaces.get(6))
|| checkValues("O", spaces.get(1), spaces.get(4), spaces.get(7))
|| checkValues("O", spaces.get(2), spaces.get(5), spaces.get(8))
|| checkValues("O", spaces.get(0), spaces.get(4), spaces.get(8))
|| checkValues("O", spaces.get(2), spaces.get(4), spaces.get(6)) ) {
circleWins();
return;
}
And its helper function:
private void circleWins() {
Intent intent = new Intent(TestTacToeActivity.this, GameOverActivity.class);
intent.putExtra("winner", "O");
startActivity(intent);
}
Lastly, even though we've already covered this funcitonality in switching from TestTacToeActivity to GameOverActivity, but let's instantiate a GameOverActivity on its own to demonstrate seeding a mock intent:
@Test
public void gameOverScreenDisplaysTheWinner() {
GameOverActivity ga = new GameOverActivity();
Intent intent = new Intent(ga, GameOverActivity.class);
intent.putExtra("winner", "X");
ShadowActivity shadowActivity = shadowOf(ga);
shadowActivity.setIntent(intent);
ga.onCreate(null);
ShadowTextView tv = (ShadowTextView) shadowOf(shadowActivity.findViewById(R.id.winner_text_view));
assertThat((String) tv.getText(), equalTo("X"));
}