上一篇 建立測試案例以及測試 android activity 的模板 建立基本模板以及測試案例之後。本篇開始介紹如何使用 TDD 的方式加入測試。
TDD(測試驅動開發)起源於 XP(極限編程)主張先寫測試再寫代碼的循環,循環的步驟如下:

  1. 用代碼寫需求,必須符合單元測試。(寫測試代碼)
  2. 測試失敗。(因為還未產生產品代碼)。
  3. 編寫代碼,實現需求。(實現產品代碼)
  4. 測試通過。
  5. 重構。

TDD需要為每段產品代碼編寫測試案例,並讓測試案例優先,測試案例定義產品代碼需要做什麼,並保證產品代碼確實符合規定。
首先來建立需求清單。

  1. 被測程式可以取得時間並以數位方式顯示在螢幕上。
  2. 使用者藉由點擊按鈕來顯示時間。
  3. 使用者藉由點擊按鈕來離開程式,離開前必須出現對話框以確定是否真正離開。

被測程式以及測試程式已經在前幾篇建立完成,接下來開始 TDD。
藉由需求清單我們可以考慮螢幕需要兩個按鈕用來啟動顯示時間以及啟動對話框,另外還需要一個 TextView 用來顯示時間。
因此我們先寫測試來建立 UI component,在測試程式的 MainActivityTest 加入以下代碼

public class MainActivityTest extends ActivityInstrumentationTestCase2<MainActivity>
{
    private MainActivity mMainActivity;
    private Button mShowTimeButton;
    private Button mExitButton;
    private TextView mTimeView;
    public MainActivityTest() {
        super("com.example.targettestproject", MainActivity.class);
    }
    protected void setUp() throws Exception
    {
        super.setUp();
        mMainActivity = getActivity();
        initUIComponents();
    }
    private void initUIComponents()
    {
       mShowTimeButton = mMainActivity.findViewById(R.id.showtime_btn);
       mExitButton = mMainActivity.findViewById(R.id.exit_btn);
       mTimeView = mMainActivity.findViewById(R.id.time_view);
    }
    protected void tearDown() throws Exception
    {
        super.tearDown();
    }
    public void testPrecondition()
    {
        assertNotNull(mMainActivity);
        assertNotNull(mExitButton);
        assertNotNull(mShowTimeButton);
        assertNotNull(mTimeView);
    }
}

第5~7行即為被測程式中使用到的 UI component,保存在欄位中方便測試。
第17行在 setUp 呼叫初始化 UI component 的動作。
第35~37行斷言這些 UI component 不可為 null。
到目前為止完成 TDD 的第1個步驟寫測試,但因為被測程式中還不存在這些 UI component,因此現在測試程式還無法執行(測試失敗)。準備進行第3步(編寫代碼,實現需求)我們到測試程式中建立這些 UI component
在 TargetTestProject/src/com/example/targettestproject/MainActivity 加入以下 source code

public class MainActivity extends Activity
{
    private Button mShowTimeButton;
    private Button mExitButton;
    private TextView mTimeView;
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        initUIComponents();
    }
    private void initUIComponents()
    {
       mShowTimeButton = (Button) findViewById(R.id.showtime_btn);
       mExitButton = (Button) findViewById(R.id.exit_btn);
       mTimeView = (TextView) findViewById(R.id.time_view);
    }
}

並開啟 TargetTestProject/res/layout/main.xml 加入以下 source code

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:orientation="vertical" >
    <TextView
        android:id="@+id/time_view"
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        android:layout_gravity="center"
        android:layout_weight="0.6"
        android:gravity="center"
        android:textSize="30sp" />
    <LinearLayout
        android:layout_width="fill_parent"
        android:layout_height="0dp"
        android:layout_weight="0.4"
        android:orientation="horizontal" >
        <Button
            android:id="@+id/showtime_btn"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="0.5"
            android:text="Show Time" />
        <Button
            android:id="@+id/exit_btn"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="0.5"
            android:text="Exit" />
    </LinearLayout>
</LinearLayout>

完成 TDD 第3步編寫代碼實現需求。接著開始 TDD 第4步進行測試。輸入以下指令以執行測試

ant uninstall clean debug install test

輸出為

test:
     [echo] Running tests ...
     [exec]
     [exec] com.example.targettestproject.MainActivityTest:.
     [exec] Test results for InstrumentationTestRunner=.
     [exec] Time: 0.629
     [exec]
     [exec] OK (1 test)
     [exec]
     [exec]
BUILD SUCCESSFUL
Total time: 27 seconds

通過測試!!!
因為加入的 source code 相當簡單,並沒有發現其他程式碼怪味(Code Smell)。
第5步重構可以先忽略。上述過程為完成1次 TDD 循環的過程。
 
接著把剩下的需求轉換成測試案例,從第1項需求開始
“被測程式可以取得時間並以數位方式顯示在螢幕上。”
把 “取得時間” 轉換為 source code,在測試程式實作該測試案例。
MainActivityTest 加入以下 source code  (TDD 第1步和第2步)

    ...
    public void testGetTime()
    {
        String except = mMainActivity.getTime();
        assertNotNull(except);
    }

接著在 MainActivity 加入相對應的實作(TDD第3步)

    ...
    public String getTime()
    {
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd kk:mm:ss", Locale.TAIWAN);
        return dateFormat.format(new Date());
    }

進行測試,輸入

ant uninstall clean debug install test

輸出如下

test:
[echo] Running tests ...
[exec]
[exec] com.example.targettestproject.MainActivityTest:..
[exec] Test results for InstrumentationTestRunner=..
[exec] Time: 1.257
[exec]
[exec] OK (2 tests)
[exec]
[exec]
BUILD SUCCESSFUL
Total time: 29 seconds

通過測試!!!(TDD第4步)
針對第1項需求的後半段 “以數位方式顯示在螢幕上” 代表 mTimeView 欄位必須能設定數值且數值必須正確才行。
TDD 第1步,加入測試案例。MainActivityTest 加入以下代碼

    ...
    public void testSetTimeToTimeView()
    {
        String time = mMainActivity.getTime();
        mTimeView.setText(time);
        assertEquals(time, mTimeView.getText());
    }

這一次不需要實作相對應的實作,因為 mTimeView.setText() 函式已經存在於android 本身。
執行測試,輸入

ant uninstall clean debug install test

測試失敗!!!輸出訊息如下

test:
     [echo] Running tests ...
     [exec]
     [exec] com.example.targettestproject.MainActivityTest:..
     [exec] Error in testSetTimeToTimeView:
     [exec] android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
     [exec]     at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:4925)
     [exec]     at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:950)
     [exec]     at android.view.View.requestLayout(View.java:15461)
     [exec]     at android.view.View.requestLayout(View.java:15461)
     [exec]     at android.view.View.requestLayout(View.java:15461)
     [exec]     at android.view.View.requestLayout(View.java:15461)
     [exec]     at android.view.View.requestLayout(View.java:15461)
     [exec]     at android.widget.TextView.checkForRelayout(TextView.java:6645)
     [exec]     at android.widget.TextView.setText(TextView.java:3733)
     [exec]     at android.widget.TextView.setText(TextView.java:3591)
     [exec]     at android.widget.TextView.setText(TextView.java:3566)
     [exec]     at com.example.targettestproject.MainActivityTest.testSetTimeToTimeView(MainActivityTest.java:67)
     [exec]     at java.lang.reflect.Method.invokeNative(Native Method)
     [exec]     at android.test.InstrumentationTestCase.runMethod(InstrumentationTestCase.java:214)
     [exec]     at android.test.InstrumentationTestCase.runTest(InstrumentationTestCase.java:199)
     [exec]     at android.test.ActivityInstrumentationTestCase2.runTest(ActivityInstrumentationTestCase2.java:192)
     [exec]     at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:192)
     [exec]     at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:177)
     [exec]     at android.test.InstrumentationTestRunner.onStart(InstrumentationTestRunner.java:555)
     [exec]     at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1619)
     [exec]
     [exec] Test results for InstrumentationTestRunner=...E
     [exec] Time: 1.909
     [exec]
     [exec] FAILURES!!!
     [exec] Tests run: 3,  Failures: 0,  Errors: 1
     [exec]
     [exec]
BUILD SUCCESSFUL
Total time: 31 seconds

第6行說明失敗原因,只能在 UI thread 改變 UI component 的值才行,在其他 thread 改變都會失敗。
有不少方法可以修正該錯誤,我們採取最間單的方式。在該測試案例上加上 @UiThreadTest,該註解說明其內的動作全都會執行在 UI thread 上。加上該註解

    @UiThreadTest
    public void testSetTimeToTimeView()
    {
        String time = mMainActivity.getTime();
        mTimeView.setText(time);
        assertEquals(time, mTimeView.getText());
    }

再次執行測試,輸入

ant uninstall clean debug install test

測試結果輸出

test:
[echo] Running tests ...
[exec]
[exec] com.example.targettestproject.MainActivityTest:...
[exec] Test results for InstrumentationTestRunner=...
[exec] Time: 2.156
[exec]
[exec] OK (3 tests)
[exec]
[exec]
BUILD SUCCESSFUL
Total time: 30 seconds

測試成功!!!
接著 TDD 第2項需求
“使用者藉由點擊按鈕來顯示時間”
在MainActivityTest加入以下測試案例

    ...
    public void testShowTimeByClickButton()
    {
        TouchUtils.clickView(this, mShowTimeButton);
        String exceptTime = mMainActivity.getTime();
        assertEquals(exceptTime, mTimeView.getText().toString());
    }

這次的測試案例不會出現失敗,因為需要的函式實作在之前的步驟已經完成。
進行測試。

ant uninstall clean debug install test

測試失敗!!!,輸出如下

test:
     [echo] Running tests ...
     [exec]
     [exec] com.example.targettestproject.MainActivityTest:...
     [exec] Failure in testShowTimeByClickButton:
     [exec] junit.framework.ComparisonFailure: expected:<[2017/02/20 10:06:30]> but was:<[]>
     [exec]     at com.example.targettestproject.MainActivityTest.testShowTimeByClickButton(MainActivityTest.java:80)
     [exec]     at java.lang.reflect.Method.invokeNative(Native Method)
     [exec]     at android.test.InstrumentationTestCase.runMethod(InstrumentationTestCase.java:214)
     [exec]     at android.test.InstrumentationTestCase.runTest(InstrumentationTestCase.java:199)
     [exec]     at android.test.ActivityInstrumentationTestCase2.runTest(ActivityInstrumentationTestCase2.java:192)
     [exec]     at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:192)
     [exec]     at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:177)
     [exec]     at android.test.InstrumentationTestRunner.onStart(InstrumentationTestRunner.java:555)
     [exec]     at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1619)
     [exec]
     [exec] Test results for InstrumentationTestRunner=....F
     [exec] Time: 4.306
     [exec]
     [exec] FAILURES!!!
     [exec] Tests run: 4,  Failures: 1,  Errors: 0
     [exec]
     [exec]
BUILD SUCCESSFUL
Total time: 44 seconds

從第6行可以看到預期的值為 2017/02/20 10:06:30 ,但是實際的值為空白。
雖然相關的函式實作已經完成,但將其連結的邏輯還未完成。
在目前的動作邏輯中,點擊按鈕並不會顯示時間。
因此我們回到被測專案(MainActivity)中加入以下代碼

public class MainActivity extends Activity implements OnClickListener
{
    private Button mShowTimeButton;
    private Button mExitButton;
    private TextView mTimeView;
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        initUIComponents();
    }
    private void initUIComponents()
    {
       mShowTimeButton = (Button) findViewById(R.id.showtime_btn);
       mShowTimeButton.setOnClickListener(this);
       mExitButton = (Button) findViewById(R.id.exit_btn);
       mTimeView = (TextView) findViewById(R.id.time_view);
    }
    public String getTime()
    {
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd kk:mm:ss", Locale.TAIWAN);
        return dateFormat.format(new Date());
    }
    @Override
    public void onClick(View view)
    {
       int uiId = view.getId();
       switch(uiId){
           case R.id.showtime_btn:
               mTimeView.setText(getTime());
           break;
       }
    }
}

第1行讓Activity 實作 OnClickListener 介面
第19行讓 mShowTimeButton 加入onClickListener 監聽器
第31~39行實作 onClick 函式內容即為將點擊按鈕連結到顯示時間
將動作的連結邏輯實作完成後,再來執行測試。

ant uninstall clean debug install test

測試通過!!!,輸出如下

test:
     [echo] Running tests ...
     [exec]
     [exec] com.example.targettestproject.MainActivityTest:....
     [exec] Test results for InstrumentationTestRunner=....
     [exec] Time: 1.931
     [exec]
     [exec] OK (4 tests)
     [exec]
     [exec]
BUILD SUCCESSFUL
Total time: 29 seconds

最後我們增加最後一項需求的測試案例。
“使用者藉由點擊按鈕出現對話框以確定是否真正離開程式。”
在這裡我們加入 Robotium 來更方便的寫測試,Robotium 是一個專門測試 android ui 的 framework,詳細介紹請到官網
導入的步驟其實就是將 Robotium 的 jar 檔加到測試專案。
首先在測試專案的根目錄建立 libs 資料夾,該資料夾中的 jar 檔都會被自動的連結到該專案中。到官網下載jar檔,目前(2017/02/20)最新的版本為 Robotium-5.6.3.jar
接著在 MainActivityTest 加入以下代碼

public class MainActivityTest extends ActivityInstrumentationTestCase2<MainActivity>
{
    private static final String DEBUG = MainActivityTest.class.getSimpleName();
    private Solo mSolo;
    protected void setUp() throws Exception
    {
        super.setUp();
        mSolo = new Solo(getInstrumentation(), getActivity());
        mMainActivity = getActivity();
        ...
    }
    protected void tearDown() throws Exception
    {
        mSolo.finishOpenedActivities();
        super.tearDown();
    }
    public void testShowExitDialogByClickExitButton()
    {
        mSolo.clickOnButton("Exit");
        assertTrue(mSolo.waitForDialogToOpen());
    }
    public void testNoButtonActionOnExitDialog()
    {
        mSolo.clickOnButton("Exit");
        boolean isExitDialogOpen = mSolo.waitForDialogToOpen();
        if (isExitDialogOpen) {
            mSolo.clickOnButton("no");
            assertTrue(mSolo.waitForDialogToClose());
        }
    }
    public void testYesButtonActionOnExitDialog()
    {
        mSolo.clickOnButton("Exit");
        boolean isExitDialogOpen = mSolo.waitForDialogToOpen();
        if (isExitDialogOpen) {
            mSolo.clickOnButton("yes");
            mSolo.sleep(5000);
            assertFalse(mSolo.searchButton("Show Time"));
        }
    }
}

第5行的 Solo 為 Robotium 最常使用的類別,我們需要一個欄位(mSolo)來保存它。
第10行測試前初始化 mSolo。
第17行測試後銷毀 mSolo。
第23行就是使用 Robotium 的方便之處,Robotium 會去目前頁面中搜尋符合 clickOnButton 的參數值(Exit),找到 button 的話就會去點擊它。
第24行 waitForDialogToOpen 代表會在預設的時間(20sec)內等待 dialog 顯示,如果20秒之內有 dialog 顯示就會回傳 true,反之則回傳 false。
第27行測試當點擊 exit 對話框的 no button 動作是否如預期。我們預期點擊 no button 之後,exit 對話框會關閉,回到主畫面。
第37行測試當點擊 exit 對話框上的 yes button 的動作是否如預期。我們預期點擊 yes button 之後,exit 對話框會關閉回到主畫面,MainActivity 會 finish,因此等待5秒再去尋找 Show Time 按鈕是不存在的。
寫完以上的測試代碼之後,開始寫產品代碼。
在 MainActivity 加入以下代碼

public class MainActivity extends Activity implements OnClickListener
{
    private Button mShowTimeButton;
    private Button mExitButton;
    private TextView mTimeView;
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        initUIComponents();
    }
    private void initUIComponents()
    {
        mShowTimeButton = (Button) findViewById(R.id.showtime_btn);
        mShowTimeButton.setOnClickListener(this);
        mExitButton = (Button) findViewById(R.id.exit_btn);
        mExitButton.setOnClickListener(this);
        mTimeView = (TextView) findViewById(R.id.time_view);
    }
    public String getTime()
    {
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd kk:mm:ss", Locale.TAIWAN);
        return dateFormat.format(new Date());
    }
    @Override
    public void onClick(View view)
    {
        int uiId = view.getId();
        switch (uiId) {
            case R.id.showtime_btn:
                mTimeView.setText(getTime());
                break;
            case R.id.exit_btn:
                showExitDialog();
                break;
        }
    }
    private void showExitDialog()
    {
        final AlertDialog exitDialog = new AlertDialog.Builder(this).create();
        exitDialog.setMessage("Exit app?");
        exitDialog.setButton(DialogInterface.BUTTON_POSITIVE, "yes", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which)
            {
               finish();
            }
        });
        exitDialog.setButton(DialogInterface.BUTTON_NEGATIVE, "no", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which)
            {
              exitDialog.dismiss();
            }
        });
        exitDialog.show();
    }
}

第21行讓 mExitButton 設定 listener,對象為 MainActivity。
第39~41行建立點擊 mExitButton 動作,當點擊 mExitButton 之後會呼叫showExitDialog()
第45行即為 showExitDialog 函式本身,函式會建立含有2個 button (yes, no)的 dialog,並對這2個 button 建立相對應的動作。
完成產品代碼之後來執行測試,輸入

ant uninstall clean debug install test

測試通過!!!,輸出為

test:
     [echo] Running tests ...
     [exec]
     [exec] com.example.targettestproject.MainActivityTest:.......
     [exec] Test results for InstrumentationTestRunner=.......
     [exec] Time: 30.024
     [exec]
     [exec] OK (7 tests)
     [exec]
     [exec]
BUILD SUCCESSFUL
Total time: 1 minute 0 seconds

以上即為使用 TDD 來建立 android test 的過程,比較需要注意的是如果需求有對 UI Component 設定位置標準,如對齊,置中等等,就必須加入這些測試案例。
另一點為可以強化對斷言(assert)的條件,如以下 testGetTime 的測試案例

    public void testGetTime()
    {
        String except = mMainActivity.getTime();
        assertNotNull(except);
    }

我們僅對 getTime 測試不可為 null,事實上還可以測試它的格式是否符合標準(yyyy/MM/dd kk:mm:ss),測試是否位於合理範圍值等等。