Skip to content

Instantly share code, notes, and snippets.

@sleepynate
Last active December 15, 2015 08:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sleepynate/cdcad235427db3a39c2c to your computer and use it in GitHub Desktop.
Save sleepynate/cdcad235427db3a39c2c to your computer and use it in GitHub Desktop.
Test-Driving Android and Battery-Powered Sheep

Test-Driving Android and Battery-Powered Sheep

Scaffold project

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

Adding Robolectric

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>

Initial test setup

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!"));
    }

}

Testing for a game setup

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));

Testing in-game functionality

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!


Taking turns

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.


Devious mechanics

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.


Winning (with or without Tiger Blood)

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!


But who actually won?

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);

Rounding out the game state tests

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"));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment