Przeznaczenie
Uruchamianie jednostkowych testów instrumentalnych na urządzeniu lub emulatorze jest kosztowne i powolne. Jednakże w wielu sytuacjach nie sposób uciec od testowania logiki kodu powiązanego z komponentami Androida co sprawia, że testy instrumentalne są stosowane pomimo wynikających kosztów. Alternatywnym sposobem testowania kodu zależnego od Android SDK
może być użycie framework Robolectric
, który umożliwia przeprowadzanie lokalnych testów
jednostkowych w środowisku wykonawyczm JVM
na komponentach Android takich jak m.in. Activity
, Fragment
, Intent
, Service
czy ContentProvider
. W przeciwieństwie do testów instrumentalnych Robolectric wykonuje się piaskownicy systemowej (sandbox
) co pozwala na konfiguracje środowiska Android dla wszystkich testów. Robolectric dostarcza również dublerów
komponentów Android dla których nie potrafi dokonać tłumaczenia do testów jednostkowych (np. sensory hardware, usługi systemowe). Obsługuje inflację widoków, ładowanie zasobów i wiele innych aspektów zaimplementowanych w natywnym kodzie na urządzeniach co pozwala na wykonanie większości czynności tak jak na prawidzwym urządzeniu. Robolectric w łatwy sposób pozwala na zapewnienie własnej implementacji wybranych metod Android SDK dzięki czemu możliwa jest np. symulacja warunków czy zachowań sensorów. Wykorzystanie Robolectric jest bliższe podejściu testowania czarnoskrzynkowego (skupionego na zachowaniu) i nie wyklucza jednoczesnego stosowania innych bibliotek naiwnych implementacji takich jak np. Mockito
.
Przykład
Na podstawie AktywnościMainActivity
zostaną przedstawione możliwości implementacji testów jednostkowych w Robolectric.
class MainActivity : AppCompatActivity() {
companion object {
const val TITLE_KEY = "TITLE"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
buttonAction.setOnClickListener {
val intent = Intent(this, SecondActivity::class.java)
startActivity(intent)
}
textViewTitle.setOnClickListener { textViewTitle.setText(R.string.clicked) }
}
override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
super.onRestoreInstanceState(savedInstanceState)
savedInstanceState?.let {
if(it.containsKey(TITLE_KEY))
textViewTitle.text = it.getString(TITLE_KEY)
}
}
}
Uruchomienie
Aby uruchomić test w Robolectric należy opatrzeć klasę testową adnotacją @RunWith(RobolectricTestRunner.class)
oraz uzyskać instancję żądanej klasy komponentu.
@RunWith(RobolectricTestRunner::class)
class SimpleTest {
lateinit var activity: MainActivity
@Before
fun init() {
//this method provides that activity goes throw all create lifecycle
activity = Robolectric.setupActivity(MainActivity::class.java)
}
@Test
fun checkViewsValue() {
assertEquals("title", activity.textViewTitle.text)
assertEquals("action", activity.buttonAction.text)
}
@Test
fun clickingTextViewChangeText() {
activity.textViewTitle.performClick()
assertEquals("clicked", activity.textViewTitle.text)
}
@Test
fun clickingButtonNavigateToSecondActivity() {
activity.buttonAction.performClick()
val expectedIntent = Intent(activity, SecondActivity::class.java)
val actual = shadowOf(getApplicationContext() as Application).nextStartedActivity
assertNotNull(actual)
assertEquals(expectedIntent.component, actual.component)
}
}
Konfiguracja
Aby zmienić domyślną konfiguracje testów należy oznaczyć klasy lub metody adnotacją @Config
wraz z deklaracją konfiguracji. Metody z adnotacją @Config
nadpisują zachowanie z poziomu klasy. Konfiguracji mogą podlegać m.in. poziom sdk
, plik manifest
, klasy shadows
, ścieżki do zasobów czy kwalifikatory
(np. języka, regionu itp).
@RunWith(RobolectricTestRunner::class)
@Config(minSdk = LOLLIPOP)
class ConfigurationTest {
lateinit var activity: MainActivity
@Before
fun init() {
//this method provides that activity goes throw all create lifecycle
activity = Robolectric.setupActivity(MainActivity::class.java)
}
@Test
fun checkViewsValueOnLollipopAndAbove() {
assertEquals("title", activity.textViewTitle.text)
assertEquals("action", activity.buttonAction.text)
}
@Config(minSdk = KITKAT, maxSdk = LOLLIPOP)
@Test
fun checkViewsValueOnMinKitkatMaxLollipop() {
assertEquals("title", activity.textViewTitle.text)
assertEquals("action", activity.buttonAction.text)
}
@Config(sdk = intArrayOf(O, O_MR1, P))
@Test
fun checkViewsValueOnlyOnOreoAndPie() {
assertEquals("title", activity.textViewTitle.text)
assertEquals("action", activity.buttonAction.text)
}
@Config(qualifiers = "pl")
@Test
fun checkViewsValueInPolish() {
assertEquals("tytuł", activity.textViewTitle.text)
assertEquals("akcja", activity.buttonAction.text)
}
@Config(qualifiers = "xhdpi")
@Test
fun checkViewsValueOnXhdpi() {
assertEquals(32, activity.buttonAction.paddingTop)
}
}
Cykl życia
ActivityController
odpowiada za tworzenie oraz zarządzanie cyklem życia tworzonej Aktywności. Metoda buildActivity
tworzy instancję ActivityController
, która umożliwia wywołanie wybranych metod cyklu życia (create
, start
, resume
, pause
, stop
, destroy
) oraz pobranie instancji Aktywności dla bieżącego stanu. Aby bezpośrednio uzyskać obiekt Aktywności z przebytym pełnym cyklem tworzenia należy wywołać metodę setupActivity
.
@RunWith(RobolectricTestRunner::class)
class LifecycleTest {
@Test
fun checkMainActivityHasWorkingLifecycle() {
val controller = Robolectric.buildActivity(MainActivity::class.java)
val activity = controller.create().get()
assertEquals(Lifecycle.State.CREATED, activity.lifecycle.currentState)
//do more assertion for this state
controller.start()
assertEquals(Lifecycle.State.STARTED, activity.lifecycle.currentState)
//do more assertion for this state
controller.resume()
assertEquals(Lifecycle.State.RESUMED, activity.lifecycle.currentState)
//do more assertion for this state
controller.pause().stop().destroy()
assertEquals(Lifecycle.State.DESTROYED, activity.lifecycle.currentState)
}
@Test
fun checkMainActivityHasWorkingFinishLifecycle() {
//this activity goes throw all create lifecycle
val controller = Robolectric.buildActivity(MainActivity::class.java)
val activity = controller.create().start().resume().visible().get()
//equivalent of val activity = Robolectric.setupActivity(MainActivity::class.java)
//visible methods assures that activity view's is attached
assertFalse(activity.isFinishing)
activity.finish()
assertTrue(activity.isFinishing)
}
@Test
fun checkMainActivityHasWorkingRestoringState() {
val savedInstanceState = Bundle()
savedInstanceState.putString(TITLE_KEY, "value restored")
val activity = Robolectric.buildActivity(MainActivity::class.java)
.create().restoreInstanceState(savedInstanceState).get()
assertEquals("value restored", activity.textViewTitle.text)
}
}
W analogiczny sposób można zarządzać innymi kompontentami Androida i ich cyklem życia poprzez operowanie na dedykowanym kontrolerze (ServiceController
, FragmentController
, ContentProviderController
itp.) oraz wykonanie metod odpowiadających ich cyklowi życia.
@RunWith(RobolectricTestRunner::class)
class ServiceTest {
@Test
fun checkSomeServiceProperlyBindAndUnbind() {
val service = Robolectric.buildService(SomeService::class.java).bind().get()
assertTrue(service.bound)
service.onUnbind(null)
assertFalse(service.bound)
}
}
//this is only dummy implementation
class SomeService : Service() {
var bound = false
override fun onBind(intent: Intent?): IBinder? {
bound = true
return null
}
override fun onUnbind(intent: Intent?): Boolean {
bound = false
return super.onUnbind(intent)
}
//more methods
}
Shadow
Robolectric tworzy środowisko wykonawcze zawierające prawdziwy kod Android SDK co zwiększa realizm testów tak jakby były przeprowadzane na fizycznym urządzeniu. Wszystkie klasy Android są zastąpione tzw. obiektami cienia
(Shadows
). Każdy obiekt cienia może modyfikować lub rozszerzać zachowanie odpowiadającej mu klasy z Android SDK. Aby stworzyć klasę Shadow
należy oznaczyć ją adnotacją @Implements
wraz z nazwą odpowiadającej klasy Android, opcjonalnie rozszerzyć superklasę Shadow oraz dostarczyć publiczny bez argumentowy konstruktor. Metody rozszerzene muszą mieć adnotację @Implementation
(obiekt cienia implementuje metody z tą samą sygnaturą dla odpowiadającej klasy Android niezależnie od modyfikatora dostępu) i przeważnie modyfikator protected
. Metoda directlyOn
pozwala na wywołanie akcji na faktycznym obiekcie.
@Implements(TextView::class)
class CustomShadowTextView : ShadowView() {
@Implementation
fun setEnabled(enable: Boolean) {
directlyOn(realView, View::class.java).setEnabled(enable)
if(enable)
directlyOn(realView, View::class.java).setAlpha(1.0f)
else
directlyOn(realView, View::class.java).setAlpha(0.5f)
}
fun getAlpha() : Float {
return realView.alpha
}
}
Klasa testowa lub metoda wykorzystująca własną implementacje klas Shadows musi być oznaczona adnotacją @Config(shadows=arrayOf(CustomShadowClass::class))
(lub zawierać odpowiedni wpis w pliku ustawień) co umożliwia rozpoznanie i skojarzenie klas Shadow z klasami przykrywanymi. Metoda shadowOf
jest przeznaczona dla dostarczonych przez Robolectric klas Shadow, natomiast extract
dla autorskich klas Shadow.
@RunWith(RobolectricTestRunner::class)
@Config(shadows=arrayOf(CustomShadowTextView::class))
class ShadowTest {
@Test
fun shadowCustomClassImplicit() {
val activity = Robolectric.setupActivity(MainActivity::class.java)
val view = activity.textViewTitle //TextView instance
view.setEnabled(false)
assertEquals(0.5f, view.getAlpha())
}
@Test
fun shadowCustomClassExplicit() {
val activity = Robolectric.setupActivity(MainActivity::class.java)
val view = extract(activity.textViewTitle) as CustomShadowTextView
view.setEnabled(false)
assertEquals(0.5f, view.getAlpha())
}
}