Nuestro recorrido por la aplicación de Android comprobable | Autor: Malvern Sutanto | Proyecto de reclutamiento | Junio de 2021


¿Cómo refactorizamos nuestra antigua base de código de Android para hacerla comprobable?
La aplicación Wantedly para Android se lanzó por primera vez en 2014, hace un tiempo, por lo que nuestra base de código de la aplicación para Android es bastante antigua. Cuando me uní a Wantedly hace 3 años, había muchos problemas con el conjunto de pruebas existente. El número de casos de prueba es pequeño y no se puede generar el informe de cobertura de prueba. Los casos de prueba que tenemos también son muy inestables y difíciles de mantener. Siempre que queremos actualizar o agregar nuevos casos de prueba, necesitamos realizar muchas configuraciones complicadas y es difícil probar los componentes individuales de la aplicación de forma independiente.
Sabemos que si queremos expandir nuestro desarrollo y aumentar la productividad de los ingenieros de Android, necesitamos mejorar esto. En este artículo, quiero compartir nuestro trabajo para hacer que nuestra base de código de Android sea comprobable.
Creo que es muy importante definir qué debemos implementar y lograr. En nuestro caso, creamos un documento que describe los objetivos, beneficios, estrategias y convenciones de las pruebas de redacción. El documento también se utiliza como contenido central de la discusión entre ingenieros y partes interesadas, de modo que todos están en la misma página y también se puede utilizar como referencia para nuevos miembros en el futuro. Intentaré mostrar brevemente cómo es nuestro documento.
la meta: Cree un marco e infraestructura sólidos para crear, probar y publicar nuestras aplicaciones fácilmente.
beneficio: Los conjuntos de pruebas confiables permiten a los ingenieros realizar cambios rápidamente y lanzar aplicaciones con confianza. Proporciona un ciclo de retroalimentación más corto durante el proceso de desarrollo, lo que facilita la depuración de problemas en la aplicación. También nos permite simular de manera consistente casos extremos que son difíciles de replicar. También se puede utilizar un conjunto de pruebas bien documentado como documento para ayudar a compartir conocimientos.
estrategia: Utilice la pirámide de prueba para construir un conjunto de pruebas (discutiremos esto en detalle más adelante). También necesitamos que cada «componente» de la aplicación se pueda probar de forma independiente, pero aún así se puedan probar juntos si es necesario. Ejecutaremos el conjunto de pruebas en cada solicitud de extracción. Cualquier solicitud de extracción a la sucursal principal debe agregar / eliminar casos de prueba, y también nos aseguraremos de que la tasa de cobertura no disminuya.
Convención: Asegúrese de que los casos de prueba sean coherentes en toda la base del código.Usaremos un método de prueba uniforme para nombrar (similar a givenCondition_whenAction_thenResult
) Para garantizar la coherencia y facilitar la redacción y el mantenimiento. La nomenclatura consistente del método de prueba nos permite identificar rápidamente qué caso de prueba falló en CI. El caso de prueba debe ser pequeño y solo probar un aspecto del código.
La mejor estrategia para crear un conjunto de pruebas depende de su equipo y de su base de código. Aquí, describiré nuestro enfoque para introducir una arquitectura comprobable en la base de código heredado.
Pirámide de prueba
La pirámide de pruebas nos permite agrupar las pruebas en depósitos según la cantidad de componentes involucrados, el tiempo de ejecución y el trabajo de mantenimiento de cada grupo. También nos da una idea de cuántos casos de prueba deben escribirse para cada grupo. Idealmente, desea escribir una gran cantidad de casos de prueba que sean fáciles de mantener. Por lo tanto, cuando subes a la pirámide, necesitas un número menor de pruebas. Por lo general, comenzando desde la parte superior, realizará pruebas de extremo a extremo, luego pruebas de integración y finalmente pruebas unitarias en la parte inferior de la pirámide.
Así es como usamos la pirámide de prueba para construir un conjunto de pruebas:
En la parte superior de la pirámide, usamos Firebase Robo Test para realizar pruebas de un extremo a otro.Entonces tenemos nuestro Fragments
‘Prueba de interfaz de usuario de Espresso, ejecutada como prueba de instrumento.Probamos Fragments
versus ViewModel
Como el nuestro Fragments
Estrechamente relacionado con nuestra implementación ViewModel
. También reemplazamos ViewModel
(Como la clase de repositorio) y la prueba se duplica para permitir una prueba más fácil. Finalmente, tenemos el repositorio y las pruebas de la clase de utilidad que usan Robolectric como ejecución de prueba de unidad local.
Nota: Las clases de repositorio que interactúan con bases de datos SQLite deben ejecutarse como pruebas de instrumentación.
Prueba de dobles
Los dobles de prueba son sustitutos de las clases que usamos en producción y su comportamiento se puede controlar durante las pruebas. Usamos pruebas dobles para crear condiciones predecibles para nuestras pruebas. También son muy importantes para permitirnos probar nuestros componentes individualmente. En Wantedly, utilizamos principalmente 2 tipos de pruebas dobles, simulaciones y falsificaciones.
caries en los dientes: Un doble de prueba que puede responder a un conjunto de llamadas a métodos con respuestas definidas. También puede comprobar si el método en la prueba doble se ha llamado o no se ha llamado todavía. Algunas bibliotecas populares que pueden ayudarlo a crear clases simuladas en Android son Mockito y MockK.
interface UserRepository
suspend fun getUser(userId: Int): User
// Create a mock UserRepository with MockK
val mockRepo = mockk<UserRepository>()// Set getUser to return a fake User object every time it's called.
coEvery mockRepo.getUser(any()) answers
User(
id = firstArg(),
firstName = "John",
lastName = "Smith"
)
// Usage
launch
mockRepo.getUser(1) // Will return User(1, "John", "Smith")
Falsificado: Un doble de prueba que usa algún tipo de implementación de «atajo» para comportarse como una clase real. Si usa interfaces para declarar las dependencias de su clase, crear una implementación falsa es muy simple.
interface UserRepository
suspend fun getUser(userId: Int): User
// Create a fake implementation of a UserRepository
class FakeUserRepository : UserRepository
override suspend fun getUser(userId: Int): User
return User(
id = userId,
firstName = "John",
lastName = "Smith"
)
Prueba de escritura
Como mencioné anteriormente, hemos introducido algunas convenciones, como la denominación de métodos de prueba, para garantizar que las pruebas sean coherentes en toda la base del código. Esto nos ayuda a describir el escenario que se está probando y también nos ayuda a identificar rápidamente regresiones en el código. También tratamos nuestro código de prueba como código de producción, lo que significa que los principios SOLID todavía se aplican al código de prueba.
Además, también usamos otras bibliotecas de terceros para ayudarnos a escribir casos de prueba más fácilmente. Por ejemplo, usamos Kakao (un contenedor Kotlin DSL para Espresso) para escribir nuestras pruebas de IU porque encontramos que el DSL es más legible. También escribimos una biblioteca para usar la reflexión para crear objetos falsos que ayuden a generar datos falsos para realizar pruebas.
Dado que la mayoría de los componentes que estamos probando son similares entre sí (Fragments
, ViewModels
, y muchos más). Creamos algunas reglas JUnit para simplificar la configuración de estas pruebas.Por ejemplo, tenemos un FragmentTestRule
Contiene varias otras reglas JUnit, como InstantTaskExecutorRule
, TaskSchedulerRule
, con DataBindingIdlingResourceRule
Para asegurar que el Fragment
La prueba es determinista, no escamosa.
Nuestro enfoque es probar la interfaz de usuario para probar la lógica de la interfaz de usuario, no la apariencia de los elementos de la interfaz de usuario.Compruebe si uno Button
Usar Espresso para mostrar correctamente en un color o tamaño específico es difícil, pero puede verificar fácilmente si el botón tiene la etiqueta correcta o si se puede hacer clic.Si quieres probar una apariencia Button
Te sugiero que eches un vistazo a la prueba de captura de pantalla.
Aislar los componentes de la prueba
La capacidad de probar componentes de forma independiente es importante para garantizar que podamos centrarnos en escribir casos de prueba para cada componente específico.Para lograr esto, usamos la inyección de dependencia, por ejemplo Dagger
Y parámetros de constructor para pasar las dependencias requeridas por cada clase. Esto nos permite reemplazar fácilmente estas dependencias con dobles de prueba durante la prueba. Considere el siguiente ejemplo:
interface UserApi
suspend fun getUser(userId: Int): User
interface UserRepository
suspend fun getUser(userId: Int): Flow<User>
class UserRepositoryImpl(
val userApi: UserApi,
val userDb: UserDB
) : UserRepositoryoverride fun getUser(userId: Int): Flow<User>
class UserViewModel(
// Fetches user data from UserAPI and store it
// in the local DB if necessary.
...
private val userId: Int,
private val userRepository: UserRepository
) : ViewModel() private val _user: MutableLiveData<User> = MutableLiveData()
val user: LiveData<User> = _userinit
class UserFragment : Fragment()
viewModelScope.launch
// Observe changes from UserRepository.
userRepository.getUser(userId).collect user ->
_user.value = user// Assume that UserViewModel is injected through Dagger.
@Inject
lateinit var viewModel: UserViewModeloverride fun onViewCreated(view: View, savedInstanceState: Bundle?)
// Observe state changes from LiveData.
super.onViewCreated(view, savedInstanceState)
...
viewModel.user.observe ...someButton.setOnClickListener
// Use Navigation Component to handle navigation events.
findNavController().navigate(R.id.to_other_destination)
Al probar UserRepository
, Podemos reemplazar la ejecución UserApi
como esto:
@RunWith(AndroidJUnit4::class)
class UserRepositoryTest
@JvmField
@Rule
val repositoryTestRule = RepositoryTestRule()@MockK
lateinit var userApi: UserApi
@Before
fun setUp()
MockKAnnotations.init(this)
@Test
fun givenUserApiReturnsUser_ThenEmitUser() = runBlockingTest
// Set up mocked UserApi to return a fake user.
coEvery userApi.getUser(any()) answers
User(
id = firstArg(),
firstName = "John",
lastName = "Smith"
)
// Replace the dependencies of UserRepository with mocks.
val userRepository = UserRepositoryImpl(
userApi,
repositoryTestRule.db
)
// Assert that repository emits the fake user.
val user = userRepository.getUser().single()
assertEquals("John", user.firstName)
assertEquals("Smith", user.lastName)
...
Al probar UserViewModel
, Solo necesitamos reemplazar UserRepository
llevado a cabo:
@RunWith(AndroidJUnit4::class)
class UserViewModelTest
@JvmField
@Rule
val viewModelTestRule = ViewModelTestRule()@MockK
@Before
lateinit var userRepository: UserRepository
fun setUp()
MockKAnnotations.init(this)@Test
fun givenUserRepostiroyEmitsUser_ThenUserLiveDataIsUpdated()// Set user repository to emit a fake user.
coEvery userRepository.getUser(any()) answers
flowOf(
User(
id = firstArg(),
firstName = "John",
lastName = "Smith"
)
)// Replace the dependencies of UserViewModel with mocks.
val viewModel = UserViewModel(1, userRepository)// Assert that user LiveData emits the fake user.
val user = viewModel.user.getOrAwaitValue()
assertEquals("John", user.firstName)
assertEquals("Smith", user.lastName)
Puedes encontrar
getOrAwaitValue
La extensión está aquí.
Como mencioné antes, generalmente probamos nuestro Fragment
con ViewModel
Juntos, así es como probamos UserFragment
:
@RunWith(AndroidJUnit4::class)
class UserFragmentTest
@JvmField
@Rule
val fragmentTestRule = FragmentTestRule()@MockK
lateinit var navController: NavController
@MockK
lateinit var userRepository: UserRepository
@Before
fun setUp()
MockKAnnotations.init(this)
// Launch the fragment using FragmentScenario.
private fun launchFragment(): FragmentScenario<UserFragment>
return launchFragmentInContainer
// Create the Fragment manually and replace any
// dependencies as needed.
UserFragment().also userFragment ->
userFragment.viewModel = UserViewModel(1, userRepository)
fragmentTestRule.dataBindingIdlingResourceRule
.setFragment(userFragment)
.onFragment userFragment ->
// Replace the NavController with a mocked NavController
// to test UserFragment in isolation.
Navigation.setViewNavController(
it.requireView(),
navController
)
@Test
fun givenUser_ThenDisplayUserDetails()
// Set fake user similar to ViewModel.
coEvery userRepository.getUser(any()) answers
flowOf(
User(
id = firstArg(),
firstName = "John",
lastName = "Smith"
)
)
launchFragment()
// Assert views with Kakao.
onScreen<UserScreen>
firstNameTextView.hasText("John")
lastNameTextView.hasText("Smith")
@Test
fun whenClickSomeButton_ThenNavigateToOtherDetailsFragment()
launchFragment()
// Trigger UI interactions with Kakao.
onScreen<UserScreen>
someButton.click()
// Verify navigation events using the mocked NavController.
verify navController.navigate(R.id.to_other_destination)
También puedes usar
TestNavHostController
Como tu sustitutoNavController
.
Esta configuración también nos permite combinar componentes fácilmente durante las pruebas de integración.Por ejemplo, podemos usar Dagger para reemplazar solo UserApi
Utilice un cliente de API simulado durante las pruebas y mantenga las dependencias restantes coherentes con la implementación real.
Solicitudes de extracción y cobertura
Ejecutamos nuestro conjunto de pruebas para cada solicitud de extracción en el servidor de CI, lo que nos permite medir la cobertura de código de cada rama. Sin embargo, no usamos la cobertura para orientar cuántos casos de prueba debemos escribir. Creemos que debemos centrarnos en la ruta más crítica en la base del código, no en el número de casos de prueba.
Dado que estamos tratando con código heredado, debemos introducir gradualmente conjuntos de pruebas. Es imposible refactorizar toda la aplicación a la vez, porque con este método no podremos desarrollar ninguna característica en paralelo. Si no hay un desarrollo paralelo, no podremos proporcionar a los usuarios actualizaciones o correcciones. Por lo tanto, decidimos escribir casos de prueba para los componentes en los que estamos trabajando o refactorizando actualmente, y usar la cobertura de código para rastrear nuestro progreso general.
El informe de cobertura de código nos permite averiguar fácilmente qué componentes se han probado o no. Además, también introdujimos un umbral de cobertura mínimo y lo aumentamos gradualmente para garantizar que no agreguemos más código no probado a la base del código.
El viaje hacia una aplicación comprobable no ha sido fácil. Los siguientes son algunos de los problemas que encontramos durante el proceso de refactorización y cómo los abordamos.
- La implementación real no coincide con el documento de prueba. Cuando creamos nuestro documento de prueba por primera vez, había muchas incógnitas. Lo que finalmente logramos es completamente diferente de lo que queríamos originalmente. Por lo tanto, debemos probar el documento de forma iterativa. Necesitamos adaptarnos a los cambios tecnológicos y ajustar nuestros métodos en consecuencia. Por ejemplo, queremos usar la sintaxis de pepinillo en Spek para ayudarnos a construir suites de prueba. Sin embargo, Spek no admite actualmente las pruebas de instrumentación para Android. Dado que queríamos mantener la coherencia entre las pruebas de unidades locales y las pruebas de instrumentos (como queríamos el proyecto Nitrogen), decidimos no utilizar Spek.
- Se necesita mucho tiempo para escribir y mantener conjuntos de pruebas automatizados. La implementación de los casos de prueba correctos requerirá inevitablemente más tiempo. Nosotros, como ingenieros, necesitamos comunicarnos con las partes interesadas y gestionar sus expectativas. Sin embargo, creo que escribir pruebas automatizadas es una buena inversión a largo plazo, porque la mayoría de las pruebas manuales son muy repetitivas y pueden automatizarse. Invertir en suites de prueba automatizadas también nos permite cambiar el código más rápido.
- A medida que crece el conjunto de pruebas, el tiempo de ejecución aumentará. El número y los tipos de pruebas en el conjunto de pruebas afectarán seriamente el tiempo de ejecución de la canalización de pruebas. Si hay muchas pruebas de IU, el tiempo de ejecución puede ser muy largo y ralentizará la velocidad de desarrollo. Para nosotros, tenemos alrededor de 1000 pruebas unitarias y 300 UI / pruebas de integración. Se necesitan aproximadamente 5 minutos de pruebas unitarias y 15 minutos de pruebas de IU para ejecutar nuestro conjunto de pruebas en nuestro servidor CI. Incluso después de que presentamos Flank para dividir nuestras pruebas, lleva mucho tiempo realizar pruebas de IU. Si tiene un conjunto de pruebas más grande, es posible que desee consultar el método de Dropbox, que ejecuta de forma selectiva el conjunto de pruebas para detectar el código que ha cambiado o que puede verse afectado por el cambio.
- Usar Jacoco para generar cobertura de código puede ser difícil. Aunque el complemento de Android Gradle es compatible con Jacoco de fábrica. Puede ser muy difícil de configurar, especialmente al actualizar la versión de AGP. Según nuestra experiencia, cuando se actualiza AGP, Jacoco a menudo arroja errores de compilación o muestra informes de cobertura incorrectos. Como puede ver en nuestro gráfico de cobertura de sucursales, cuando actualizamos AGP a 4.2, nuestra cobertura se redujo significativamente debido a este problema. Hay otros temas que necesitan atención, como este, este y este.
Aunque hemos pasado mucho tiempo viendo los beneficios y los resultados de nuestros esfuerzos para lograr aplicaciones probables, creo que la configuración de prueba de nuestras aplicaciones de Android ha mejorado significativamente en los últimos dos años. Por supuesto, no podemos quedarnos ahí, porque todavía hay mucho margen de mejora. La creación de una aplicación comprobable requiere el esfuerzo concertado de todos. Antes de emprender el viaje de implementación de una arquitectura comprobable, es muy importante obtener el apoyo de los miembros del equipo y las partes interesadas. Escribir pruebas automatizadas debe ser una parte integral de su proceso de desarrollo y no debe ralentizar el desarrollo.
Los conjuntos de pruebas automatizados pueden ayudarlo a identificar rápidamente las regresiones durante el proceso de desarrollo. Sin embargo, no creo que pueda confiar en las suites de prueba automatizadas al 100%, porque los procesos de control de calidad manuales pueden encontrar más fácilmente problemas como problemas de animación, problemas de interfaz de usuario o de diseño, uso incorrecto de dobles de prueba o casos extremos no manejados. Por lo tanto, sigue siendo importante realizar pruebas manuales durante su ciclo de desarrollo.