Skip to content

Instantly share code, notes, and snippets.

@sleepynate
Last active January 16, 2016 03:33
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save sleepynate/c32505a3fb49292f7967 to your computer and use it in GitHub Desktop.
Save sleepynate/c32505a3fb49292f7967 to your computer and use it in GitHub Desktop.
Test-driving Android in the Studio era

Test-Driving Android and Battery-Powered Sheep (Studio Edition)

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

What's in the box?

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.


Starting our actual application

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 to TestTacToeActivity.java
  • Change the name of the file DeckardActivityRobolectricTest.java to TestTacToeActivityRobolectricTest.java
  • Change the class in TestTacToeActivity.java to be TestTacToeActivity
  • Change the class in TestTacToeActivityRobolectricTest.java to be TestTacToeActivityRobolectricTest
  • Change the activity .DeckardActivity in Android.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.


GAME ON!!

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!


Testing Game Logic

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!


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


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


Winning

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment