This article will introduce Test Driven Development on the Google Android platform. I took me some time to figure it all out and it's not always clear where to find the correct documentation, answers or resources, so I'm writing this hoping it be helpful for someone who is trying to take this approach.
This is an open topic, an we are all doing the first steps and trying to discover some functionality which is still not documented in the Android SDK, and up to now there's no sources to check what's the intention of some API code or tools.
We will be using a fairly simple example that was used in previous articles about similar subjects. That's a Temperature Converter application. Requirements are defined by our Use Case: Convert Temperature.
Using Test Driven Development we will implement this use case, trying to keep it as simple as possible not to get confused with unnecessary details.
Comments, corrections, improvements, critics are gladly welcome.
We are using a different folder to keep our tests in order to permit a clear separation in case the test should be removed at production.
Then
Now we will add our tests, even though our TemperatureConverter class is not clearly defined. Adding the tests will help us define our TemperatureConverter class.
Accordingly with our use case in Temperature Converter we can define 2 tests
to verify our tests' resluts we will use some data obtained from an external converter and we will put these figures in a conversion table. Some temperature measure background can be obtained from http://en.wikipedia.org/wiki/Temperature and an online converter can be found at http://www.onlineconversion.com/temperature.htm. We will add a conversionTable to verify our tests shortly.
Edit the source file and:
And use this code snippet
public void testCelsiusToFahrenheit() {
fail("Not implemented yet");
}
public void testInvalidCelsiusTemperature() {
fail("Not implemented yet");
Also in the TemperatureConversionTest class
just after its definition
Right click on the TDD Android project node and select Run as JUnit Test We will se how, as expected. our tests fail.
Notice that these tests will run outside the Android emulator. Later we will include these tests to be run by the emulator itself.
Now, once our test infrastructure is setup, let's proceed to define our tests.
public void testCelsiusToFahrenheit ()
for (int c: conversionTable.keySet()) {
int f = conversionTable.get(c);
String msg = "" + c + "C -> " + f + "F";
assertEquals(msg, f, TemperatureConverter.celsiusToFahrenheit(c));
}
}
public void invalidCelsiusTemperature () {
try {
int f = TemperatureConverter.celsiusToFahrenheit(-274);
} catch (RuntimeException ex) {
if (ex.getMessage().contains("below absolute zero")) {
return;
}
else {
fail("Undetected temperature below absolute zero: " + ex.getMessage());
}
}
fail("Undetected temperature below absolute zero: no exception generated");
}
We have now the tests for code that still doesn't exist. If everything was fine, you should see a light bulb in the line where TemperatureConverter.celsiusToFahrenheit is invoked and giving you the alternative to
Create Method celsiusToFarenheit(int) in tdd.TemperatureConverter
Our Test Driven Development is starting to appear.
Go to the JUnit tab and Rerun Tests
These tests will fail, because we haven't already implemented the celsiusToFahrenheit conversion method.
We obtain two failures but by very different reasons as before:
So, what's left is to go there an implement it.
Replace this
return 0
by
return (int)Math.round(celsius * 1.8 + 32);
Remember to change parameters name to celsius (from c) if it's not already named.
Running the tests again, and we will see how one test succeed but the other fails with the message
Undetected temperature below absolute zero: no exception generated
That's because we are expecting an exception to be thrown if the temperature is below the absolute zero but our first attempt doesn't do that.
Let's add this condition, but we first need the ABSOLUTE_ZERO_C constant.
TemperatureConverter class editor window.
public static final int ABSOLUTE_ZERO_C = -273;
public static int celsiusToFahrenheit(int celsius) {
if (celsius < ABSOLUTE_ZERO_C) {
throw new RuntimeException("Invalid temperature: " + celsius + " below absolute zero");
}
return (int)Math.round(celsius * 1.8 + 32);
}
Run the tests again and we can verify that in our third attempt both tests passed.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout id="@+id/linear" android:layout_width="fill_parent"
android:layout_height="fill_parent"
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical">
<TextView id="@+id/message"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:padding="6dip"
android:text="Enter the temperature and press Convert">
</TextView>
<TableLayout id="@+id/table" android:layout_width="fill_parent"
android:layout_height="wrap_content"
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:stretchColumns="1">
<TableRow>
<TextView id="@+id/celsius_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="3dip"
android:textStyle="bold"
android:textAlign="end"
android:text="Celsius">
</TextView>
<EditText id="@+id/celsius"
android:padding="3dip"
android:numeric="true"
android:digits="-+0123456789"
android:textAlign="end"
android:singleLine="true"
android:scrollHorizontally="true"
android:nextFocusDown="@+id/convert"
>
</EditText>
</TableRow>
<TableRow>
<TextView id="@+id/fahrenheit_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="3dip"
android:textStyle="bold"
android:textAlign="end"
android:text="Fahrenheit">
</TextView>
<EditText id="@+id/fahrenheit"
android:padding="3dip"
android:textAlign="end"
android:singleLine="true"
android:scrollHorizontally="true">
</EditText>
</TableRow>
</TableLayout>
<Button id="@+id/convert" android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:padding="6dip"
android:text="Convert"
android:textAlign="center"
android:layout_gravity="center_horizontal"
android:nextFocusUp="@+id/celsius"
>
</Button>
</LinearLayout>
There's a lot of documentation and tutorials online in case you need to know more about Android layouts. We now have an empty form.
Change the theme, if you want, in AndroidManifest.xml, adding
android:theme="@android:style/Theme.Dark"
to <application>
We will obtain this when we run as Android Application
Of course this is not functional yet.
provides an instrumentation and some support classes to help writing acceptance tests. It is provided as a jar that gets bundled with your application. Acceptance tests are written in junit, extending a custom base class. Positron can be downloaded from http://code.google.com/p/android-positron/downloads/list.
Positron jar must be added to build path. In this example we are using positron-0.4-alpha, but if by the time you read this there's a newer version you may use it.
In the newly created package com.example.tdd.validation create a new JUnit test case
In the package com.example.tdd create the Positron class extending positron.Positron
Then, create our test suite including OverallTest tests. Other tests can also be added.
@Override
protected TestSuite suite() {
TestSuite suite = new TestSuite();
suite.addTestSuite(OverallTest.class);
return suite;
}
In OverallTest create the testConversion test case
public void testConversion() {
Intent intent = new Intent(getTargetContext(), TemperatureConverterActivity.class);
startActivity(intent.addLaunchFlags(Intent.NEW_TASK_LAUNCH));
TemperatureConverterActivity activity = (TemperatureConverterActivity)activity();
// Is it our application ?
assertEquals(getTargetContext().getString(R.string.app_name), activity().getTitle());
// Do we have focus ?
assertEquals(activity.getCelsiusEditText(), activity.getCurrentFocus());
// Enter a temperature
sendString("123");
// Convert
press(DOWN, CENTER);
// Verify correct conversion 123C -> 253F
assertEquals("253", activity.getFahrenheitEditText().getText().toString());
}
This basically represents what we defines in our Use Case: Convert Temperature:
| Actor Action | System Response |
|---|---|
| 1. The user enters a temperature in Celsius, and then press Convert | 2. The system converts the temperature to Fahrenheit and the result is presented to the user |
| 3. The user wants to enter another temperature and continues from 1 or presses Back to exit. | 4. The process finishes. |
| Alternative Courses | |
| Temperature is below absolute zero | Indicate error |
| Invalid input characters entered | Indicate error |
Finally, add this to tearDown
protected void tearDown() throws Exception {
finishAll();
}
<instrumentation class=".Positron" android:functionalTest="true" android:targetPackage="com.example.tdd" android:label="Temperature Converter Acceptance Tests"/>
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle icicle) {
super.onCreate(icicle);
setContentView(R.layout.main);
// Find text fields.
celsiusEditText = (EditText)findViewById(R.id.celsius);
fahrenheitEditText = (EditText)findViewById(R.id.fahrenheit);
convertButton = (Button)findViewById(R.id.convert);
// disable fahrenheit field
fahrenheitEditText.setEnabled(false);
}
Also add the fields proposed by the IDE
private EditText celsiusEditText;;
private EditText fahrenheitEditText;
private Button convertButton;
and using Source->Generate Getters and Setters... generate the corresponding getters.
I/Positron(1091): .F
I/Positron(1091): Time: 1.332
I/Positron(1091): There was 1 failure:
I/Positron(1091): 1) testConversion(com.example.tdd.validation.OverallTest)junit.framework.ComparisonFailure: expected:<253> but was:<>
I/Positron(1091): at com.example.tdd.validation.OverallTest.testConversion(OverallTest.java:62)
I/Positron(1091): at java.lang.reflect.Method.invokeNative(Native Method)
I/Positron(1091): at positron.harness.InstrumentedTestResult.run(InstrumentedTestResult.java:37)
I/Positron(1091): at junit.extensions.TestDecorator.basicRun(TestDecorator.java:22)
I/Positron(1091): at junit.extensions.TestSetup$1.protect(TestSetup.java:19)
I/Positron(1091): at junit.extensions.TestSetup.run(TestSetup.java:23)
I/Positron(1091): at positron.Positron.onStart(Positron.java:62)
I/Positron(1091): at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1115)
I/Positron(1091): FAILURES!!!
I/Positron(1091): Tests run: 1, Failures: 1, Errors: 0
Again, as expected the test failed because the conversion functionality is not yet implemented.
It's an interface definition for a callback to be invoked when a view, a button in this case, is clicked. We need to implement the onClick abstract method, which in our case will call the convert helper method to carry away the actual conversion.
// Hook up button presses to the appropriate event handler.
convertButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View arg0) {
convert();
}
});
Helper method to get the temperature value entered into the Celsius text field, convert it into Integer and call celsiusToFahrenheit method in TemperatureConverter class.
protected void convert() {
try {
int f = TemperatureConverter.celsiusToFahrenheit(Integer.parseInt(celsiusEditText.getText().toString()));
fahrenheitEditText.setText(String.valueOf(f));
}
catch (Exception ex) {
fahrenheitEditText.setText(ex.toString());
}
}
I/Positron(1334): Time: 1.368
I/Positron(1334): OK (1 test)
I/Positron(1430): Time: 2.408
I/Positron(1430): OK (4 tests)