Cómo hacer que la aplicación sea más estable con 2 tipos de pruebas unitarias

Hola Habr Mi nombre es Ilya Smirnov, soy desarrollador de Android en FINCH. Quiero mostrarle algunos ejemplos de trabajo con pruebas de Unidad que hemos desarrollado en nuestro equipo.

Se utilizan dos tipos de pruebas unitarias en nuestros proyectos: verificación de conformidad y verificación de llamadas. Detengámonos en cada uno de ellos con más detalle.

Pruebas de cumplimiento


Las pruebas de conformidad verifican si el resultado real de la ejecución de alguna función coincide o no con el resultado esperado. Déjame mostrarte un ejemplo: imagina que hay una aplicación que muestra una lista de noticias para el día:



Los datos sobre las noticias se toman de diferentes fuentes y a la salida de la capa empresarial se transforman en el siguiente modelo:

data class News( val text: String, val date: Long ) 

De acuerdo con la lógica de la aplicación, se requiere un modelo de la siguiente forma para cada elemento de la lista:

 data class NewsViewData( val id: String, val title: String, val description: String, val date: String ) 

La siguiente clase será responsable de convertir un modelo de dominio a un modelo de vista :

 class NewsMapper { fun mapToNewsViewData(news: List<News>): List<NewsViewData> { return mutableListOf<NewsViewData>().apply{ news.forEach { val textSplits = it.text.split("\\.".toRegex()) val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale("ru")) add( NewsViewData( id = it.date.toString(), title = textSplits[0], description = textSplits[1].trim(), date = dateFormat.format(it.date) ) ) } } } } 

Por lo tanto, sabemos que algún objeto

 News( "Super News. Some description and bla bla bla", 1551637424401 ) 

Se convertirá en algún objeto

 NewsViewData( "1551637424401", "Super News", "Some description and bla bla bla", "2019-03-03 21:23" ) 

Los datos de entrada y salida son conocidos, lo que significa que puede escribir una prueba para el método mapToNewsViewData , que verificará el cumplimiento de los datos de salida dependiendo de la entrada.

Para hacer esto, en la carpeta app / src / test / ..., cree la clase NewsMapperTest con el siguiente contenido:

 class NewsMapperTest { private val mapper = NewsMapper() @Test fun mapToNewsViewData() { val inputData = listOf( News("Super News. Some description and bla bla bla", 1551637424401) ) val outputData = mapper.mapToNewsViewData(inputData) Assert.assertEquals(outputData.size, inputData.size) outputData.forEach { Assert.assertEquals(it.id, "1551637424401") Assert.assertEquals(it.title, "Super News") Assert.assertEquals(it.description, "Some description and bla bla bla") Assert.assertEquals(it.date, "2019-03-03 21:23") } } } 

El resultado obtenido se compara con las expectativas utilizando métodos del paquete org.junit.Assert . Si algún valor no cumple con las expectativas, la prueba fallará.

Hay momentos en que el constructor de la clase probada toma algunas dependencias. Puede ser un simple ResourceManager para acceder a los recursos o un Interactor completo para ejecutar la lógica de negocios. Puede crear una instancia de dicha dependencia, pero es mejor hacer un objeto simulado similar. Un objeto simulado proporciona una implementación ficticia de una clase, con la que puede realizar un seguimiento de la llamada de los métodos internos y anular los valores de retorno.

Hay un marco Mockito popular para crear simulacros.
En Kotlin, todas las clases son finales por defecto, por lo que no puede crear objetos simulados en Mockito desde cero. Para evitar esta limitación, se recomienda que agregue la dependencia en línea simulada .

Si usa kotlin dsl al escribir pruebas, puede usar varias bibliotecas, como Mockito-Kotlin .

Supongamos que NewsMapper toma la forma de una dependencia de un determinado NewsRepo , que registra información sobre el usuario que ve una noticia en particular. Entonces tiene sentido burlarse de NewsRepo y verificar los valores de retorno del método mapToNewsViewData dependiendo del resultado de isNewsRead .

 class NewsMapperTest { private val newsRepo: NewsRepo = mock() private val mapper = NewsMapper(newsRepo) … @Test fun mapToNewsViewData_Read() { whenever(newsRepo.isNewsRead(anyLong())).doReturn(true) ... } @Test fun mapToNewsViewData_UnRead() { whenever(newsRepo.isNewsRead(anyLong())).doReturn(false) ... } … } 

Por lo tanto, el objeto simulado le permite simular varias opciones de valores de retorno para probar varios casos de prueba.

Además de los ejemplos anteriores, las pruebas de conformidad incluyen varios validadores de datos. Por ejemplo, un método que verifica la contraseña ingresada por la presencia de caracteres especiales y la longitud mínima.

Prueba de llamada


La prueba de una llamada verifica si el método de una clase llama a los métodos necesarios de otra clase o no. Muy a menudo, tales pruebas se aplican a Presenter , que envía Ver comandos específicos para cambiar de estado. Volver al ejemplo de la lista de noticias:

 class MainPresenter( private val view: MainView, private val interactor: NewsInteractor, private val mapper: NewsMapper ) { var scope = CoroutineScope(Dispatchers.Main) fun onCreated() { view.setLoading(true) scope.launch { val news = interactor.getNews() val newsData = mapper.mapToNewsViewData(news) view.setLoading(false) view.setNewsItems(newsData) } } … } 

Lo más importante aquí es el hecho de invocar métodos de Interactor y View . La prueba se verá así:

 class MainPresenterTest { private val view: MainView = mock() private val mapper: NewsMapper = mock() private val interactor: NewsInteractor = mock() private val presenter = MainPresenter(view, interactor, mapper).apply { scope = CoroutineScope(Dispatchers.Unconfined) } @Test fun onCreated() = runBlocking { whenever(interactor.getNews()).doReturn(emptyList()) whenever(mapper.mapToNewsViewData(emptyList())).doReturn(emptyList()) presenter.onCreated() verify(view, times(1)).setLoading(true) verify(interactor).getNews() verify(mapper).mapToNewsViewData(emptyList()) verify(view).setLoading(false) verify(view).setNewsItems(emptyList()) } } 

Se pueden requerir diferentes soluciones para excluir las dependencias de la plataforma de las pruebas, como todo depende de la tecnología para trabajar con subprocesos múltiples. El ejemplo anterior usa Corrutinas de Kotlin con un alcance anulado para ejecutar pruebas, como utilizado en Dispatchers. El código del programa principal se refiere al hilo de la interfaz de usuario de Android, que es inaceptable en este tipo de pruebas. El uso de RxJava requerirá otras soluciones, por ejemplo, crear una TestRule que cambie el flujo de ejecución del código.

Para verificar que se ha llamado a un método, se utiliza el método de verificación, que puede tomar métodos que indican el número de llamadas al método que se está probando como argumentos adicionales.

*****


Las opciones de prueba consideradas pueden cubrir un porcentaje bastante grande del código, haciendo que la aplicación sea más estable y predecible. El código cubierto por pruebas es más fácil de mantener, más fácil de escalar, porque Existe cierta confianza en que al agregar nuevas funciones, nada se romperá. Y, por supuesto, dicho código es más fácil de refactorizar.

La clase más fácil de probar no contiene dependencias de plataforma, porque al trabajar con él, no necesita soluciones de terceros para crear objetos simulados de plataforma. Por lo tanto, nuestros proyectos utilizan una arquitectura que minimiza el uso de dependencias de la plataforma en la capa bajo prueba.

Un buen código debe ser comprobable. La complejidad o la incapacidad para escribir pruebas unitarias generalmente muestran que algo está mal con el código que se está probando, y es hora de pensar en la refactorización.

El código fuente para el ejemplo está disponible en GitHub .

Source: https://habr.com/ru/post/447588/


All Articles