10 September 2015

ลองเขียน Instrumentation Test บน Android Studio

Updated on


        Instrumentation Test หรือ Android Unit Test เป็นส่วนหนึ่งของการทำ Unit Test โดยจะมีการเรียกใช้งาน Resource ต่างๆของแอนดรอยด์ในการเทส ซึ่งจะต่างจาก Local Unit Test (Unit Test แบบธรรมดาๆ) ที่จะเป็นการเทสกับ Plain Java Code เท่านั้น

        ดังนั้นการทำ Instrumentation Test จึงไม่สามารถทำงานได้ทันทีแบบ Local Unit Test แต่ต้องมีการเตรียม Resource ของแอนดรอยด์เพื่อให้สามารถเรียกใช้คำสั่งต่างๆสำหรับแอนดรอยด์ได้

เตรียมโค๊ดพื้นฐาน

        เพิ่ม Dependencies เข้าไปใน build.gradle ดังนี้

androidTestCompile 'com.android.support.test:runner:0.3'
androidTestCompile 'com.android.support.test:rules:0.3'

        และเพิ่ม Runner เข้าไปใน defaultConfig

testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner'

       สรุปคร่าวๆดังนี้

...

android {
    ...

    defaultConfig {
        ...

        testInstrumentationRunner 'android.support.test.runner.AndroidJUnitRunner'
    }
    ...
}

dependencies {
    ...

    androidTestCompile 'com.android.support.test:runner:0.3'
    androidTestCompile 'com.android.support.test:rules:0.3'
}

        สำหรับการทำ Instrumentation Test จะต้องสร้างโฟลเดอร์ androidTest ไว้ใน src ของ Module ที่ต้องการเทสแบบนี้


        ซึ่งผู้ที่หลงเข้ามาอ่านไม่จำเป็นต้องสร้างเอง เพราะ Android Studio จะสร้างไว้ให้โดยอัตโนมัติ รวมไปถึงสร้าง Package ให้พร้อมแล้วด้วย ที่ต้องทำก็แค่สร้างไฟล์จาวาสำหรับเขียนเทส


การเขียน Instrumentation Test 

        การเขียนเทสจะถูกแบ่งออกตามขนาด Resource ของแอนดรอยด์ที่ผู้ที่หลงเข้ามาอ่านต้องการจะเทส ทำให้สามารถทดสอบเฉพาะบางส่วนหรือทั้งหมดก็ได้ แต่อย่าลืมว่าถ้าทดสอบกับ Resource ทั้งหมดก็หมายความว่าต้องใช้เวลานานมากขึ้น เพราะทรัพยากรที่ต้องใช้ในการเทสจะมีขนาดใหญ่กว่าการเทสแค่บางส่วน

        ในการเทสก็จะใช้ AndroidJUnitTestRunner จาก Android Test Support Libraryโดยที่ตัวมันเองนั้นจะใช้ได้ทั้ง JUnit 3 และ JUnit 4 ในการทำงาน (ในบทความจะอิง JUnit4)

        และเวลารันเทสจะต้องใช้อุปกรณ์แอนดรอยด์หรือ Emulator ในการเทสด้วย เพราะว่า Resource ที่ใช้ในการเทสนั้นจะต้องดึงมาจากอุปกรณ์แอนดรอยด์มาใช้เทส ดังนั้นการใช้แต่ละเครื่องในการทำ Instrumentation Test อาจจะได้ผลลัพธ์ที่ไม่เหมือนกัน เพราะ Resource ในเครื่องนั้นต่างกัน

การทำงานของ AndroidJUnitTestRunner

        AndroidJUnitTestRunner เป็น Test Runner สำหรับแอนดรอยด์ โดยจะทำหน้าที่ไล่เทสคำสั่งตามที่เขียนไว้ทั้งหมด โดยที่ Test Runner จะเชื่อมต่อกับ Android System เพื่อส่ง Test Method ทั้งหมดให้ Android System รันทดสอบ (ดังนั้นถ้าแอปพลิเคชันตัวนั้นเปิดอยู่ มันก็จะถูกปิดก่อน) ซึ่งในการรันทดสอบก็จะทำงานตาม Life Cycle ของแอปพลิเคชันนั้นๆได้เลย เช่น Activity ก่อนจะรัน Test Method ก็จะเรียก onCreate ขึ้นมาเตรียมไว้

        นี่จึงเป็นที่มาว่าการรันบนอุปกรณ์แอนดรอยด์ที่ต่างกัน อาจจะทำให้ได้ผลเทสที่ต่างกันก็ได้ ถ้าไปเทสในส่วนที่ไม่เหมือนกันในบางเครื่อง เช่น กล้อง เซ็นเซอร์ ขนาดหน้าจอ เป็นต้น

สิ่งที่ควรรู้เกี่ยวกับ JUnit 4 ก่อนจะเขียนเทส

        เนื่องจาก JUnit4 มีการเปลี่ยนแปลงไปจากเดิมพอสมควร ดังนั้นขออธิบายสิ่งที่ควรจะรู้ในการใช้งานกันซะหน่อย

        • JUnit 4 ไม่รองรับการ Extend ด้วยคลาส TestCase หรือ AndroidTestCase ที่ใช้ใน JUnit 3 แล้ว

        • Test Class จะต้องประกาศ Annotation ไว้ข้างบนคลาสว่า @RunWith(AndroidJUnit4.class)

        • ไม่ควรใช้ JUnit 3 กับ JUnit 4 ร่วมกัน (เลือกซะเถิด)

Annotation สำหรับการเทสใน JUnit 4

        @Test สำหรับกำหนด Method ที่เขียนขึ้นมาเพื่อเทส ซึ่งเดิมทีนั้นจะต้องตั้งชื่อโดยมีคำว่า test เป็น Prefix แต่ว่าในตอนนี้จะใช้ Annotation ตัวนี้แทน แล้วมันจะไปเติมคำว่า test ให้เอง

        @Test(expected= AnyException.class) เหมือนกับ @Test แต่ว่าถ้ามี Exception เกิดขึ้นตรงกับที่กำหนดไว้ใน expected จะถือว่าไม่มีเออเรอร์ (ยอมละเว้นให้) จะได้ไม่ต้องมานั่งเขียน Try-catch ครอบคำสั่งทุกครั้ง แต่กำหนดได้แค่ Exception หนึ่งแบบเท่านั้น

        @Before คล้ายๆกับ setup() ของ JUnit 3 ใช้สำหรับกำหนด Method ที่ต้องการให้ทำงานก่อนจะเริ่ม Test Method แต่ละตัว (เอาไว้เตรียมข้อมูลหรือค่าเริ่มต้น) สามารถกำหนดได้มากกว่าหนึ่ง Method และไม่สามารถเรียงลำดับการทำงานของ Method ที่ประกาศ @Before ได้

        @After คล้ายๆกับ teardown ของ JUnit 3 ใช้สำหรับกำหนด Method ที่ต้องการให้ทำงานหลังจากเสร็จการทำงานของ Test Method แต่ละตัว

        @BeforeClass สำหรับกำหนด Method ที่ต้องการให้ทำการก่อนจะเริ่มเทสในไฟล์นั้นๆ ต่างกับ @Before ตรงที่ @BeforeClass ทำงานก่อนที่ไฟล์คลาสนั้นๆจะเริ่มเทส แต่ @Before จะทำงานก่อนที่ Test Method จะเริ่มทำงานทุกๆตัว

        @AfterClass เหมือนกับ @After แต่ว่าทำงานหลังจากเทสในไฟล์คลาสนั้นๆเสร็จ

        @Rule ใช้สำหรับกำหนดค่าตัวแปรหรือ Instance ต่างๆที่จะเอาไปใช้งานในโค๊ดได้

ตัวอย่างการทำงานของ Annotation ที่ใช้งานหลักๆของ JUnit 4

        สมมติเจ้าของบล็อกสร้างเทสขึ้นมาแบบนี้

import android.support.test.runner.AndroidJUnit4;

import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class SampleTestCode {
    @BeforeClass
    public void openDatabase() {
        ...
    }

    @Before
    public void initContext() {
        ...
    }


    @Before
    public void inflateLayout() {
        ...
    }

    @Test
    public void connectionAvailable() {
        ...
    }

    @Test
    public void accerelometerAvailable() {
        ...
    }

    @After
    public void releaseLayout() {
        ...
    }

    @AfterClass
    public void closeDatabase() {
        ...
    }
}

        เวลารันไฟล์นี้ก็จะได้ลำดับการทำงานออกมาประมาณนี้

openDatabase();

initContext();
inflateLayout();
connectionAvailable();
releaseLayout();

initContext();
inflateLayout();
accerelometerAvailable();
releaseLayout();

closeDatabase();
     

ประเภทของการเทสสำหรับ JUnit 4

        เจ้าของบล็อกจึงขอแบ่งเป็นประเภทการเทสดังนี้

        • การเทสที่ต้องเรียกใช้ Context
        • การเทสที่ต้องเรียกใช้ Activity
        • การเทสที่ต้องใช้ Application
        • การเทสกับ Service
        • การเทสสำหรับ Handler
        • การใช้งานกับ ActivityInstrumentationTestCase2 

การเทสที่ต้องเรียกใช้ Context

        เนื่องจากมีหลายๆคำสั่งของแอนดรอยด์ที่ต้องกำหนด Context เป็น Parameter ซึ่ง JUnit 4 ก็จะมีคลาส Instrumentation ให้เรียกใช้งาน Context ด้วยคำสั่ง getContext() แบบนี้

import android.content.Context;
import android.support.test.InstrumentationRegistry;
import android.support.test.runner.AndroidJUnit4;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class SampleTestCode {
    Context context;

    @Before
    public void initContext() {
        context = InstrumentationRegistry.getContext();
    }

    @Test
    public void connectionAvailable() {
        boolean isAvailable = NetworkUtil.isOnline(context);
        Assert.assertEquals(true, isAvailable);
    }
}

        สิ่งที่ต้องทำก็คือต้องสร้าง Before Method เพื่อดึง Context จาก InstrumentationRegistry มาเก็บไว้ที่ Global แล้วจึงเอาไปใช้งานตามต้องการ

        จะเอา Context ไป Inflate Layout ก็ได้นะ แต่ทว่าต้องเป็น Context จากคำสั่ง getTargetContext() แทน หรือจะดึง Resource ต่างๆไม่ว่าจะ String Resource, Drawable Resource หรือ Color Resource ก็ทำได้หมด

import android.content.Context;
import android.support.test.InstrumentationRegistry;
import android.support.test.runner.AndroidJUnit4;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.TextView;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class SampleTestCode {
    Context context;

    @Before
    public void initContext() {
        context = InstrumentationRegistry.getTargetContext();
    }

    @Test
    public void helloWorldDescription() {
        LayoutInflater layoutInflater = LayoutInflater.from(context);
        View view = layoutInflater.inflate(R.layout.activity_main, null);
        TextView tvTitleDescription = (TextView) view.findViewById(R.id.tv_title_description);
        String actualDescription = tvTitleDescription.getText().toString();
        String expectedDescription = context.getResources().getString(R.string.hello_world);
        Assert.assertEquals(expectedDescription, actualDescription);
    }
}

        ทั้งนี้ก็เพราะว่า getContext() เป็นการดึง Context ของ Instrumentation มาใช้ แต่ถ้าเป็นคำสั่ง getTargetContext() จะเป็นการดึง Context ของ Application ที่ทำการเทส

        และอย่าลืมว่า Instrumentation Test ไม่ได้เป็นการรัน Activity ให้แสดงขึ้นมาจริงๆ แต่เป็นการรันในระดับโค๊ดเท่านั้น ดังนั้นการ Inflate View หรือ Layout เข้ามาเทสก็ควรเทสแค่พวกค่าต่างๆที่อยู่ใน View หรือ Layout นั้นๆ

การเทสที่ต้องเรียกใช้ Activity

        ถ้าต้องการเทสว่า Activity ของผู้ที่หลงเข้ามาอ่านนั้นทำงานได้ถูกต้องหรือไม่ หรือว่าจะทดสอบคำสั่งบางอย่างที่ต้องโยน Parameter เป็น Activity เข้าไป ก็สามารถเรียกผ่านคลาส ActivityTestRule ได้เลย

import android.support.test.rule.ActivityTestRule;
import android.support.test.runner.AndroidJUnit4;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class SampleTestCode {
    MainActivity activity;

    @Rule
    public final ActivityTestRule<MainActivity> activityTestRule = new ActivityTestRule<>(MainActivity.class);

    @Before
    public void initActivity() {
        activity = activityTestRule.getActivity();
    }

    @Test
    public void checkCurrentTarget() {
        int currentTarget = activity.getCurrentTarget();
        Assert.assertEquals(0, currentTarget);
    }
}

        โดยจะต้องใส่ Annotation ไว้ว่า @Rule ด้วย และสำหรับคลาส Activity ที่ว่าก็สามารถเป็นคลาส Activity ตัวไหนก็ได้ ในแอปพลิเคชันของผู้ที่หลงเข้ามาอ่าน

การเทสที่ต้องใช้ Application

        การเทสแบบนี้จะคล้ายกับ Activity เพราะว่าผู้ที่หลงเข้ามาอ่านจะต้องดึง Application Class จาก Activity Class เพื่อเอาไปใช้งานนั่นเอง

import android.support.test.rule.ActivityTestRule;
import android.support.test.runner.AndroidJUnit4;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class SampleTestCode {
    Application application;

    @Rule
    public final ActivityTestRule<MainActivity> activityTestRule = new ActivityTestRule<>(MainActivity.class);

    @Before
    public void initActivity() {
        Activity activity = activityTestRule.getActivity();
        application = activity.getApplication();
    }

    @Test
    public void testSomething() {
        ...
    }
}

การเทสกับ Service

        ถ้าอยากจะเทสกับ Service ที่เขียนไว้ก็จะมี ServiceTestRule ไว้ให้ใช้งานเพื่อเรียก Service ที่ต้องการมาเทสได้เลย

import android.content.Intent;
import android.os.IBinder;
import android.support.test.InstrumentationRegistry;
import android.support.test.rule.ServiceTestRule;
import android.support.test.runner.AndroidJUnit4;

import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class SampleTestCode {

    @Rule
    public final ServiceTestRule mServiceRule = new ServiceTestRule();

    @Test
    public void testWithStartedService() {
        mServiceRule.startService(new Intent(InstrumentationRegistry.getTargetContext(), MyService.class));
    }

    @Test
    public void testWithBoundService() {
        IBinder binder = mServiceRule.bindService(new Intent(InstrumentationRegistry.getTargetContext(), MyService.class));
        MyService service = ((MyService.LocalBinder) binder).getService();
    }
}

การเทสสำหรับ Handler

        การเรียกใช้คำสั่ง Handler เพื่อเทสบางอย่างบน AndroidJUnitTestRunner จะไม่สามารถทำงานได้ เพราะว่าการเทสนั้นไม่ได้อยู่บน Main Thread ดังนั้นถ้าจะเรียกใช้ Handler ก็จะต้องสั่งให้ทำงานบน Main Thread อีกที

import android.app.Instrumentation;
import android.support.test.InstrumentationRegistry;
import android.support.test.runner.AndroidJUnit4;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;


@RunWith(AndroidJUnit4.class)
public class SampleTestCode {
    Instrumentation instrumentation;

    @Before
    public void initActivity() {
        instrumentation = InstrumentationRegistry.getInstrumentation();
    }

    @Test
    public void checkCurrentTarget() {
        instrumentation.runOnMainSync(new Runnable() {
            @Override
            public void run() {
                // เรียกคำสั่งของ Handler ที่นี่
            }
        });

    }
}

        โดยดึง Instrumentation Instance มาเก็บไว้ที่ Global แล้วเอาไปใช้เรียก Main Thread อีกทีหนึ่ง

        แต่ถ้าอยากให้ชีวิตง่ายกว่านั้น เจ้าของบล็อกแนะนำให้ใส่ @UiThreadTest ไว้ข้างบน Method ที่ต้องการดีกว่าครับ

import android.app.Instrumentation;
import android.support.test.annotation.UiThreadTest;
import android.support.test.InstrumentationRegistry;
import android.support.test.runner.AndroidJUnit4;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;


@RunWith(AndroidJUnit4.class)
public class SampleTestCode {
    Instrumentation instrumentation;

    @Before
    public void initActivity() {
        instrumentation = InstrumentationRegistry.getInstrumentation();
    }

    @UiThreadTest
    @Test
    public void checkCurrentTarget() {
        // เรียกคำสั่งของ Handler ที่นี่
    }
}

        เท่านี้ก็ไม่ต้องมานั่งเรียก Main Thread ด้วย Instrumentation ให้น่าเบื่ออีกค่อไป

การใช้งานกับ ActivityInstrumentationTestCase2

        ActivityInstrumentationTestCase2 เป็น Testing API ตัวเก่า ที่เอาไว้เทสกับ Activity ที่ต้องการ ซึ่ง JUnit 4 ก็ได้ทำ Backward Compabiity ไว้ ดังนั้นจึงสามารถเขียนเทสด้วยคลาสนี้บน JUnit 4 ได้ โดยมีเงื่อนไขว่า

         • ประกาศ @RunWith(AndroidJUnit4.class) ไว้ข้างบน
         • Method setup() ให้ใส่ @Before ไว้ข้างบนด้วย
         • Inject คลาส Instrumentation เข้าไปในคลาสนั้นๆโดยใช้ InstrumentationRegistry
         • Test Method ทุกตัวให้ใส่ @Test ไว้ข้างบน
         • Method tearDown() ให้ใส่ @After ไว้ข้างบนด้วย

        จะได้ออกมาแบบนี้

import android.support.test.InstrumentationRegistry;
import android.support.test.runner.AndroidJUnit4;
import android.test.ActivityInstrumentationTestCase2;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class MyJunit4ActivityInstrumentationTest extends ActivityInstrumentationTestCase2<MainActivity> {

    private MainActivity mActivity;

    public MyJunit4ActivityInstrumentationTest() {
        super(MainActivity.class);
    }

    @Before
    public void setUp() throws Exception {
        super.setUp();
        injectInstrumentation(InstrumentationRegistry.getInstrumentation());
        mActivity = getActivity();
    }

    @Test
    public void checkPreconditions() {
        ...
    }

    @After
    public void tearDown() throws Exception {
        super.tearDown();
    }
}

กำหนดความเหมาะสมของการเทสนั้นๆด้วย Test Size

        บ่อยครั้งในการเขียนเทสจะมีการแบ่งว่าอันไหนเทสมากน้อยแค่ไหน ซึ่งสิ่งที่ทำให้มีปัญหาบ่อยๆคือชื่อเรียกที่ทำให้เข้าใจผิดกันได้ง่าย Integration Test, Functional Test, System Test, Unit Test บลาๆ

        ทางทีมแอนดรอยด์จึงนิยามขนาดของการเทสขึ้นมา 3 ระดับเพื่อให้ทีมพัฒนาสามารถจำได้ง่ายขึ้น โดยแบ่งเป็น @SmallTest, @MediumTest และ @LargeTest

        @SmallTest สำหรับการเทสที่เทสบ่อยที่สุดและใช้เวลาเทสน้อยกว่า 100ms ลงไป ซึ่งเป็นการเทสที่ไม่ต้องเข้าถึง Resource ใหญ่ๆของแอนดรอยด์ อย่างเช่น Network, Database หรือ File System เป็นต้น ให้กำหนดเป็น Test Size ตัวนี้

        @Medium สำหรับการเทสบ่อยแต่ไม่มากเท่า @SmallTest โดยใช้เวลาเทสน้อยกว่า 2 วินาทีลงไป ซึ่งเป็นการเทสโดยเข้าถึง Resource บางอย่าง อย่าง ContentProvider หรือ Network ในระดับ Localhost ให้กำหนดเป็น Test Size ตัวนี้

        @LargeTest สำหรับการเทสหนักๆที่ใช้เวลานานมาก (2 วินาทีขึ้นไป) ซึ่งเป็นการเทสแบบเต็มรูปแบบ ที่ไม่ได้เทสบ่อยมากนัก (เพราะมันจะเสียเวลามาก) ให้กำหนดเป็น Test Size ตัวนี้

        หรือดูตารางนี้ประกอบความเข้าใจก็ได้ครับ



ขอให้สนุกกับการเทสนะจ๊ะ


แหล่งข้อมูลอ้างอิง

        • Developing Android unit and instrumentation tests - Tutorial [Vogella]
        • Android Test Kit - Wiki
        • Effective Unit Test สำหรับ Android application [Somkiat.cc]
        • Testing Your Android Activity [Android Developer]
        • Test Sizes [Google Testing Blog]
        • Testing Support Library [Android Developer]
        • Test Sizes [Android Developers on Google+]