Pruebas más fáciles a través del minimalismo de Framework y la arquitectura de software

Publicado: 2022-03-10
Resumen rápido ↬ Al igual que con muchos otros temas en el desarrollo de software, las pruebas y el desarrollo basado en pruebas a menudo se vuelven innecesariamente complejos en teoría e implementación al poner demasiado énfasis en aprender una amplia gama de marcos de prueba. En este artículo, revisaremos lo que significa la prueba con una simple analogía, exploraremos conceptos en la arquitectura de software que resultarán directamente en una menor necesidad de marcos de prueba y algunos argumentos sobre por qué podría beneficiarse de una actitud de minimalismo para su proceso de prueba. .

Al igual que muchos otros desarrolladores de Android, mi incursión inicial en las pruebas en la plataforma me llevó a enfrentarme de inmediato con un grado de jerga desmoralizador. Además, los pocos ejemplos que encontré en ese momento (alrededor de 2015) no presentaban casos prácticos de uso que pueden haberme inclinado a pensar que la relación costo-beneficio de aprender una herramienta como Espresso para verificar que TextView.setText( …) funcionaba correctamente, era una inversión razonable.

Para empeorar las cosas, no tenía un conocimiento práctico de la arquitectura de software en teoría o práctica, lo que significaba que incluso si me molestaba en aprender estos marcos, habría estado escribiendo pruebas para aplicaciones monolíticas compuestas de unas pocas clases de god , escritas en código espagueti . El remate es que crear, probar y mantener dichas aplicaciones es un ejercicio de autosabotaje, independientemente de su experiencia en el marco; sin embargo, esta comprensión solo se vuelve clara después de que uno ha construido una aplicación modular , débilmente acoplada y altamente cohesiva .

A partir de aquí llegamos a uno de los puntos principales de discusión en este artículo, que resumiré aquí en un lenguaje sencillo: Entre los principales beneficios de aplicar los principios dorados de la arquitectura de software (no se preocupe, los discutiré con ejemplos simples y lenguaje), es que su código puede volverse más fácil de probar. Existen otros beneficios al aplicar tales principios, pero la relación entre la arquitectura de software y las pruebas es el enfoque de este artículo.

Sin embargo, por el bien de aquellos que deseen entender por qué y cómo probamos nuestro código, primero exploraremos el concepto de prueba por analogía; sin necesidad de memorizar ninguna jerga. Antes de profundizar en el tema principal, también veremos la cuestión de por qué existen tantos marcos de prueba, ya que al examinar esto podemos comenzar a ver sus beneficios, limitaciones y quizás incluso una solución alternativa.

¡Más después del salto! Continúe leyendo a continuación ↓

Pruebas: por qué y cómo

Esta sección no será información nueva para ningún probador experimentado, pero tal vez pueda disfrutar de esta analogía, no obstante. Por supuesto, soy un ingeniero de software, no un ingeniero de cohetes, pero por un momento tomaré prestada una analogía que se relaciona con el diseño y la construcción de objetos tanto en el espacio físico como en el espacio de memoria de una computadora. Resulta que mientras cambia el medio, el proceso es en principio bastante el mismo.

Supongamos por un momento que somos ingenieros de cohetes, y nuestro trabajo es construir el propulsor de cohetes de primera etapa* de un transbordador espacial. Supongamos también que hemos ideado un diseño útil para que la primera etapa comience a construirse y probarse en varias condiciones.

La "primera etapa" se refiere a los propulsores que se disparan cuando se lanza el cohete por primera vez.

Antes de pasar al proceso, me gustaría señalar por qué prefiero esta analogía: no debería tener ninguna dificultad para responder a la pregunta de por qué nos molestamos en probar nuestro diseño antes de ponerlo en situaciones en las que hay vidas humanas en juego. Si bien no intentaré convencerlo de que probar sus aplicaciones antes del lanzamiento podría salvar vidas (aunque es posible según la naturaleza de la aplicación), podría salvar calificaciones, reseñas y su trabajo. En el sentido más amplio, la prueba es la forma en que nos aseguramos de que las piezas individuales, varios componentes y sistemas completos funcionen antes de emplearlos en situaciones en las que es de vital importancia que no fallen.

Volviendo al aspecto de cómo de esta analogía, presentaré el proceso mediante el cual los ingenieros prueban un diseño en particular: redundancia . La redundancia es simple en principio: cree copias del componente que se probará con la misma especificación de diseño que desea usar en el momento del lanzamiento. Pruebe estas copias en un entorno aislado que controle estrictamente las condiciones previas y las variables. Si bien esto no garantiza que el cohete propulsor funcione correctamente cuando se integre en todo el transbordador, uno puede estar seguro de que si no funciona en un entorno controlado, es muy poco probable que funcione.

Supongamos que de los cientos, o quizás miles de variables contra las que se han probado las copias del diseño del cohete, todo se reduce a la temperatura ambiente en la que se probará el cohete propulsor . Al probar a 35° Celsius, vemos que todo funciona sin errores. Nuevamente, el cohete se prueba aproximadamente a temperatura ambiente sin fallar. La prueba final se realizará a la temperatura más baja registrada para el sitio de lanzamiento, a -5° Celsius. Durante esta prueba final, el cohete se dispara, pero después de un breve período, el cohete se enciende y poco después explota violentamente; pero afortunadamente en un ambiente controlado y seguro.

En este punto, sabemos que los cambios de temperatura parecen estar al menos involucrados en la prueba fallida, lo que nos lleva a considerar qué partes del propulsor del cohete pueden verse afectadas negativamente por las bajas temperaturas. Con el tiempo, se descubre que un componente clave, una junta tórica de goma que sirve para detener el flujo de combustible de un compartimento a otro, se vuelve rígido e ineficaz cuando se expone a temperaturas cercanas o inferiores al punto de congelación.

Es posible que haya notado que su analogía se basa libremente en los trágicos eventos del desastre del transbordador espacial Challenger . Para aquellos que no están familiarizados, la triste verdad (en la medida en que concluyeron las investigaciones) es que hubo muchas pruebas fallidas y advertencias de los ingenieros y, sin embargo, las preocupaciones administrativas y políticas impulsaron el lanzamiento a pesar de todo. En cualquier caso, ya sea que haya memorizado o no el término redundancia , espero que haya comprendido el proceso fundamental para probar partes de cualquier tipo de sistema.

En cuanto al software

Mientras que la analogía anterior explicaba el proceso fundamental para probar cohetes (mientras me tomaba mucha libertad con los detalles más finos), ahora lo resumiré de una manera que probablemente sea más relevante para usted y para mí. Si bien es posible probar el software simplemente iniciando a los dispositivos una vez que esté en algún tipo de estado desplegable, supongo que podemos aplicar primero el principio de redundancia a las partes individuales de la aplicación.

Esto significa que creamos copias de las partes más pequeñas de toda la aplicación (comúnmente denominadas Unidades de software), configuramos un entorno de prueba aislado y vemos cómo se comportan en función de las variables, argumentos, eventos y respuestas que puedan ocurrir. en tiempo de ejecución. La prueba es realmente tan simple como eso en teoría, pero la clave incluso para llegar a este proceso radica en crear aplicaciones que sean factiblemente comprobables. Esto se reduce a dos preocupaciones que veremos en las próximas dos secciones. La primera preocupación tiene que ver con el entorno de prueba , y la segunda preocupación tiene que ver con la forma en que estructuramos las aplicaciones.

¿Por qué necesitamos marcos?

Para probar una pieza de software (en lo sucesivo denominada Unidad , aunque esta definición es deliberadamente una simplificación excesiva), es necesario tener algún tipo de entorno de prueba que le permita interactuar con su software en tiempo de ejecución. Para aquellos que crean aplicaciones que se ejecutarán únicamente en un entorno JVM ( Java Virtual Machine ), todo lo que se requiere para escribir pruebas es un JRE ( Java Runtime Environment ). Tomemos, por ejemplo, esta clase de calculadora muy simple:

 class Calculator { private int add(int a, int b){ return a + b; } private int subtract(int a, int b){ return a - b; } }

En ausencia de marcos, siempre que tengamos una clase de prueba que contenga una función main para ejecutar nuestro código, podemos probarlo. Como recordará, la función main indica el punto de partida de la ejecución de un programa Java simple. En cuanto a lo que estamos probando, simplemente ingresamos algunos datos de prueba en las funciones de la Calculadora y verificamos que está realizando la aritmética básica correctamente:

 public class Main { public static void main(String[] args){ //create a copy of the Unit to be tested Calculator calc = new Calculator(); //create test conditions to verify behaviour int addTest = calc.add(2, 2); int subtractTest = calc.subtract(2, 2); //verify behaviour by assertion if (addTest == 4) System.out.println("addTest has passed."); else System.out.println("addTest has failed."); if (subtractTest == 0) System.out.println("subtractTest has passed."); else System.out.println("subtractTest has failed."); } }

Por supuesto, probar una aplicación de Android es un procedimiento completamente diferente. Aunque hay una función main oculta en lo más profundo de la fuente del archivo ZygoteInit.java (cuyos detalles más finos no son importantes aquí), que se invoca antes de que se inicie una aplicación de Android en la JVM , incluso un desarrollador junior de Android debería sepa que el propio sistema se encarga de llamar a esta función; no el desarrollador . En cambio, los puntos de entrada para las aplicaciones de Android son la clase de Application y cualquier clase de Activity a la que se pueda apuntar el sistema a través del archivo AndroidManifest.xml .

Todo esto es solo una introducción al hecho de que probar Units en una aplicación de Android presenta un mayor nivel de complejidad, estrictamente porque nuestro entorno de prueba ahora debe tener en cuenta la plataforma Android.

Domar el problema del acoplamiento apretado

El acoplamiento estrecho es un término que describe una función, clase o módulo de aplicación que depende de plataformas, marcos, lenguajes y bibliotecas particulares. Es un término relativo, lo que significa que nuestro ejemplo Calculator.java está estrechamente relacionado con el lenguaje de programación Java y la biblioteca estándar, pero ese es el alcance de su acoplamiento. Del mismo modo, el problema de probar clases que están estrechamente vinculadas a la plataforma Android es que debe encontrar una manera de trabajar con la plataforma o en torno a ella.

Para las clases estrechamente acopladas a la plataforma Android, tiene dos opciones. La primera, es simplemente implementar tus clases en un dispositivo Android (físico o virtual). Si bien sugiero que pruebe implementar el código de su aplicación antes de enviarlo a producción, este es un enfoque altamente ineficiente durante las etapas temprana y media del proceso de desarrollo con respecto al tiempo.

Una Unidad , por muy técnica que sea la definición que prefiera, generalmente se considera como una función única en una clase (aunque algunos amplían la definición para incluir funciones de ayuda posteriores que son llamadas internamente por la llamada de función única inicial). De cualquier manera, las Unidades están destinadas a ser pequeñas; construir, compilar e implementar una aplicación completa para probar una sola unidad es perder el punto de prueba de forma aislada por completo .

Otra solución al problema del acoplamiento estrecho es utilizar marcos de prueba para interactuar o simular (simular) las dependencias de la plataforma. Los marcos como Espresso y Robolectric brindan a los desarrolladores medios mucho más efectivos para probar Unidades que el enfoque anterior; el primero es útil para las pruebas que se ejecutan en un dispositivo (conocido como "pruebas instrumentadas" porque aparentemente llamarlas pruebas de dispositivo no era lo suficientemente ambiguo) y el segundo es capaz de burlarse del marco de Android localmente en una JVM.

Antes de proceder a despotricar contra tales marcos en lugar de la alternativa que discutiré en breve, quiero dejar en claro que no quiero decir que nunca debas usar estas opciones. El proceso que utiliza un desarrollador para construir y probar sus aplicaciones debe nacer de una combinación de preferencia personal y un ojo para la eficiencia.

Para aquellos a los que no les gusta construir aplicaciones modulares y débilmente acopladas, no tendrán más remedio que familiarizarse con estos marcos si desean tener un nivel adecuado de cobertura de prueba. Se han creado muchas aplicaciones maravillosas de esta manera, y con frecuencia se me acusa de hacer que mis aplicaciones sean demasiado modulares y abstractas. Ya sea que adopte mi enfoque o decida apoyarse en gran medida en los marcos, lo felicito por dedicar tiempo y esfuerzo para probar sus aplicaciones.

Mantenga sus estructuras al alcance de la mano

Para el preámbulo final de la lección central de este artículo, vale la pena discutir por qué es posible que desee tener una actitud minimalista cuando se trata de usar marcos (y esto se aplica a más que solo probar marcos). El subtítulo de arriba es una paráfrasis del magnánimo maestro de mejores prácticas de software: Robert “Uncle Bob” C. Martin. De las muchas gemas que me ha dado desde que estudié sus obras por primera vez, me llevó varios años de experiencia directa comprender esta.

En la medida en que entiendo de qué se trata esta declaración, el costo de usar marcos está en la inversión de tiempo requerida para aprenderlos y mantenerlos. Algunos de ellos cambian con bastante frecuencia y otros no cambian con la suficiente frecuencia. Las funciones quedan obsoletas, los marcos dejan de mantenerse y cada 6-24 meses llega un nuevo marco para suplantar al anterior. Por lo tanto, si puede encontrar una solución que pueda implementarse como una función de plataforma o idioma (que tiende a durar mucho más), tenderá a ser más resistente a los cambios de los diversos tipos mencionados anteriormente.

En una nota más técnica, los marcos como Espresso y, en menor grado, Robolectric , nunca pueden ejecutarse tan eficientemente como las pruebas simples de JUnit , o incluso la prueba gratuita de framework de antes. Si bien JUnit es de hecho un marco, está estrechamente relacionado con JVM , que tiende a cambiar a un ritmo mucho más lento que la plataforma Android propiamente dicha. Menos marcos casi invariablemente significa código que es más eficiente en términos del tiempo que lleva ejecutar y escribir una o más pruebas.

A partir de esto, probablemente pueda deducir que ahora discutiremos un enfoque que aprovechará algunas técnicas que nos permitirán mantener la plataforma Android a distancia; al mismo tiempo, nos permite una gran cobertura de código, eficiencia de prueba y la oportunidad de seguir usando un marco aquí o allá cuando surja la necesidad.

el arte de la arquitectura

Para usar una analogía tonta, uno podría pensar en los marcos y las plataformas como colegas autoritarios que se harán cargo de su proceso de desarrollo a menos que establezca los límites apropiados con ellos. Los principios dorados de la arquitectura de software pueden brindarle los conceptos generales y las técnicas específicas necesarias para crear y hacer cumplir estos límites. Como veremos en un momento, si alguna vez se ha preguntado cuáles son realmente los beneficios de aplicar los principios de la arquitectura de software en su código, algunos directamente y muchos indirectamente hacen que su código sea más fácil de probar.

Separación de intereses

Separation Of Concerns es, según mi estimación, el concepto más universalmente aplicable y útil en la arquitectura de software en su conjunto (sin querer decir que otros deben ser descuidados). La separación de preocupaciones (SOC) se puede aplicar, o ignorar por completo, en todas las perspectivas de desarrollo de software que conozco. Para resumir brevemente el concepto, veremos SOC cuando se aplica a clases, pero tenga en cuenta que SOC se puede aplicar a funciones a través del uso extensivo de funciones auxiliares , y se puede extrapolar a módulos completos de una aplicación ("módulos" usados ​​en el contexto de Android/Gradle).

Si ha pasado mucho tiempo investigando patrones arquitectónicos de software para aplicaciones GUI, es probable que se haya topado con al menos uno de los siguientes: Modelo-Vista-Controlador (MVC), Modelo-Vista-Presentador (MVP) o Modelo-Vista- Modelo de vista (MVVM). Después de haber creado aplicaciones en todos los estilos, diré por adelantado que no considero que ninguna de ellas sea la mejor opción para todos los proyectos (o incluso para las funciones dentro de un solo proyecto). Irónicamente, el patrón que el equipo de Android presentó hace algunos años como su enfoque recomendado, MVVM, parece ser el menos comprobable en ausencia de marcos de prueba específicos de Android (suponiendo que desee utilizar las clases ViewModel de la plataforma Android, que sin duda soy un fan). de).

En cualquier caso, los detalles de estos patrones son menos importantes que sus generalidades. Todos estos patrones son solo sabores diferentes de SOC que enfatizan una separación fundamental de tres tipos de código a los que me refiero como: datos , interfaz de usuario , lógica .

Entonces, ¿cómo le ayuda exactamente la separación de datos , interfaz de usuario y lógica a probar sus aplicaciones? La respuesta es que al sacar la lógica de las clases que deben lidiar con las dependencias de plataforma/marco en clases que poseen poca o ninguna dependencia de plataforma/marco, las pruebas se vuelven fáciles y el marco es mínimo . Para ser claros, generalmente hablo de clases que deben representar la interfaz de usuario, almacenar datos en una tabla SQL o conectarse a un servidor remoto. Para demostrar cómo funciona esto, veamos una arquitectura simplificada de tres capas de una aplicación hipotética de Android.

La primera clase administrará nuestra interfaz de usuario. Para mantener las cosas simples, he usado una Actividad para este propósito, pero normalmente opto por Fragmentos en lugar de clases de interfaz de usuario. En cualquier caso, ambas clases presentan un acoplamiento estrecho similar a la plataforma Android :

 public class CalculatorUserInterface extends Activity implements CalculatorContract.IUserInterface { private TextView display; private CalculatorContract.IControlLogic controlLogic; private final String INVALID_MESSAGE = "Invalid Expression."; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); controlLogic = new DependencyProvider().provideControlLogic(this); display = findViewById(R.id.textViewDisplay); Button evaluate = findViewById(R.id.buttonEvaluate); evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } }); //..bindings for the rest of the calculator buttons } @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } }

Como puede ver, la actividad tiene dos funciones: primero, dado que es el punto de entrada de una función determinada de una aplicación de Android , actúa como una especie de contenedor para los demás componentes de la función. En términos simples, se puede pensar en un contenedor como una especie de clase raíz a la que los otros componentes están vinculados en última instancia a través de referencias (o campos de miembros privados en este caso). También infla, vincula referencias y agrega oyentes al diseño XML (la interfaz de usuario).

Prueba de lógica de control

En lugar de que la Actividad posea una referencia a una clase concreta en el back-end, hacemos que se comunique con una interfaz de tipo CalculatorContract.IControlLogic. Discutiremos por qué esta es una interfaz en la siguiente sección. Por ahora, solo comprenda que lo que sea que esté al otro lado de esa interfaz se supone que es algo así como un Presentador o un Controlador . Dado que esta clase controlará las interacciones entre la Actividad de front-end y la Calculadora de back-end , he elegido llamarla CalculatorControlLogic :

 public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } } public class CalculatorControlLogic implements CalculatorContract.IControlLogic { private CalculatorContract.IUserInterface ui; private CalculatorContract.IComputationLogic comp; public CalculatorControlLogic(CalculatorContract.IUserInterface ui, CalculatorContract.IComputationLogic comp) { this.ui = ui; this.comp = comp; } @Override public void handleInput(char inputChar) { switch (inputChar){ case '=': evaluateExpression(); break; //...handle other input events } } private void evaluateExpression() { Optional result = comp.computeResult(ui.getDisplay()); if (result.isPresent()) ui.updateDisplay(result.get()); else ui.showError(); } }

Hay muchas cosas sutiles sobre la forma en que está diseñada esta clase que hacen que sea más fácil de probar. En primer lugar, todas sus referencias son de la biblioteca estándar de Java o de las interfaces que se definen dentro de la aplicación. Esto significa que probar esta clase sin marcos es muy sencillo, y podría hacerse localmente en una JVM . Otro consejo pequeño pero útil es que todas las diferentes interacciones de esta clase se pueden llamar a través de una sola función genérica handleInput(...) . Esto proporciona un único punto de entrada para probar cada comportamiento de esta clase.

También tenga en cuenta que en la función evaluateExpression() , devuelvo una clase de tipo Optional<String> desde el back-end. Normalmente usaría lo que los programadores funcionales llaman una mónada cualquiera , o como prefiero llamarla, una envoltura de resultados . Cualquiera que sea el nombre estúpido que use, es un objeto que es capaz de representar múltiples estados diferentes a través de una sola llamada de función. Optional es una construcción más simple que puede representar un valor nulo o algún valor del tipo genérico suministrado. En cualquier caso, dado que al back-end se le puede dar una expresión inválida, queremos darle a la clase ControlLogic algún medio para determinar el resultado de la operación del back-end; teniendo en cuenta tanto el éxito como el fracaso. En este caso, nulo representará un error.

A continuación se muestra una clase de prueba de ejemplo que se ha escrito con JUnit y una clase que en la jerga de prueba se denomina Fake :

 public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } } public class CalculatorControlLogicTest { @Test public void validExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); CalculatorContract.IUserInterface ui = new FakeUserInterface(); CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).displayUpdateCalled); assertTrue(((FakeUserInterface) ui).displayValueFinal.equals("10.0")); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } @Test public void invalidExpressionTest() { CalculatorContract.IComputationLogic comp = new FakeComputationLogic(); ((FakeComputationLogic) comp).returnEmpty = true; CalculatorContract.IUserInterface ui = new FakeUserInterface(); ((FakeUserInterface) ui).displayValueInitial = "+7+7"; CalculatorControlLogic controller = new CalculatorControlLogic(ui, comp); controller.handleInput('='); assertTrue(((FakeUserInterface) ui).showErrorCalled); assertTrue(((FakeComputationLogic) comp).computeResultCalled); } private class FakeUserInterface implements CalculatorContract.IUserInterface{ boolean displayUpdateCalled = false; boolean showErrorCalled = false; String displayValueInitial = "5+5"; String displayValueFinal = ""; @Override public void updateDisplay(String displayText) { displayUpdateCalled = true; displayValueFinal = displayText; } @Override public String getDisplay() { return displayValueInitial; } @Override public void showError() { showErrorCalled = true; } } private class FakeComputationLogic implements CalculatorContract.IComputationLogic{ boolean computeResultCalled = false; boolean returnEmpty = false; @Override public Optional computeResult(String expression) { computeResultCalled = true; if (returnEmpty) return Optional.empty(); else return Optional.of("10.0"); } } }

Como puede ver, no solo se puede ejecutar este conjunto de pruebas muy rápidamente, sino que no llevó mucho tiempo escribirlo. En cualquier caso, ahora veremos algunas cosas más sutiles que hicieron que escribir esta clase de prueba fuera muy fácil.

El poder de la abstracción y la inversión de la dependencia

Hay otros dos conceptos importantes que se han aplicado a CalculatorControlLogic que han hecho que sea trivialmente fácil de probar. En primer lugar, si alguna vez se ha preguntado cuáles son los beneficios de usar interfaces y clases abstractas (denominadas colectivamente abstracciones ) en Java, el código anterior es una demostración directa. Dado que la clase que se probará hace referencia a abstracciones en lugar de clases concretas , pudimos crear dobles de prueba falsos para la interfaz de usuario y el back-end desde nuestra clase de prueba. Siempre que estos dobles de prueba implementen las interfaces adecuadas, a CalculatorControlLogic no podría importarle menos que no sean reales.

En segundo lugar, CalculatorControlLogic recibió sus dependencias a través del constructor (sí, esa es una forma de inyección de dependencia ), en lugar de crear sus propias dependencias. Por lo tanto, no es necesario volver a escribirlo cuando se usa en un entorno de producción o prueba, lo que es una ventaja para la eficiencia.

La inyección de dependencia es una forma de inversión de control , que es un concepto difícil de definir en un lenguaje sencillo. Ya sea que use Inyección de dependencia o un Patrón de localizador de servicios , ambos logran lo que Martin Fowler (mi maestro favorito en estos temas) describe como "el principio de separar la configuración del uso". Esto da como resultado clases que son más fáciles de probar y más fáciles de construir de forma aislada unas de otras.

Prueba de lógica de cálculo

Finalmente, llegamos a la clase ComputationLogic , que se supone que aproxima un dispositivo IO , como un adaptador a un servidor remoto o una base de datos local. Dado que no necesitamos ninguno de esos para una calculadora simple, solo será responsable de encapsular la lógica requerida para validar y evaluar las expresiones que le damos:

 public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } } public class CalculatorComputationLogic implements CalculatorContract.IComputationLogic { private final char ADD = '+'; private final char SUBTRACT = '-'; private final char MULTIPLY = '*'; private final char DIVIDE = '/'; @Override public Optional computeResult(String expression) { if (hasOperator(expression)) return attemptEvaluation(expression); else return Optional.empty(); } private Optional attemptEvaluation(String expression) { String delimiter = getOperator(expression); Binomial b = buildBinomial(expression, delimiter); return evaluateBinomial(b); } private Optional evaluateBinomial(Binomial b) { String result; switch (b.getOperatorChar()) { case ADD: result = Double.toString(b.firstTerm + b.secondTerm); break; case SUBTRACT: result = Double.toString(b.firstTerm - b.secondTerm); break; case MULTIPLY: result = Double.toString(b.firstTerm * b.secondTerm); break; case DIVIDE: result = Double.toString(b.firstTerm / b.secondTerm); break; default: return Optional.empty(); } return Optional.of(result); } private Binomial buildBinomial(String expression, String delimiter) { String[] operands = expression.split(delimiter); return new Binomial( delimiter, Double.parseDouble(operands[0]), Double.parseDouble(operands[1]) ); } private String getOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return "\\" + c; } //default return "+"; } private boolean hasOperator(String expression) { for (char c : expression.toCharArray()) { if (c == ADD || c == SUBTRACT || c == MULTIPLY || c == DIVIDE) return true; } return false; } private class Binomial { String operator; double firstTerm; double secondTerm; Binomial(String operator, double firstTerm, double secondTerm) { this.operator = operator; this.firstTerm = firstTerm; this.secondTerm = secondTerm; } char getOperatorChar(){ return operator.charAt(operator.length() - 1); } } }

No hay mucho que decir sobre esta clase, ya que normalmente habría un acoplamiento estrecho con una biblioteca de back-end en particular que presentaría problemas similares a los de una clase estrechamente acoplada a Android. En un momento discutiremos qué hacer con tales clases, pero esta es tan fácil de probar que también podemos intentarlo:

 public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } } public class CalculatorComputationLogicTest { private CalculatorComputationLogic comp = new CalculatorComputationLogic(); @Test public void additionTest() { String EXPRESSION = "5+5"; String ANSWER = "10.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void subtractTest() { String EXPRESSION = "5-5"; String ANSWER = "0.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void multiplyTest() { String EXPRESSION = "5*5"; String ANSWER = "25.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void divideTest() { String EXPRESSION = "5/5"; String ANSWER = "1.0"; Optional result = comp.computeResult(EXPRESSION); assertTrue(result.isPresent()); assertEquals(result.get(), ANSWER); } @Test public void invalidTest() { String EXPRESSION = "Potato"; Optional result = comp.computeResult(EXPRESSION); assertTrue(!result.isPresent()); } }

Las clases más fáciles de probar son aquellas a las que simplemente se les da algún valor u objeto, y se espera que devuelvan un resultado sin la necesidad de llamar a algunas dependencias externas. En cualquier caso, llega un punto en el que no importa cuánta magia de arquitectura de software aplique, aún tendrá que preocuparse por las clases que no se pueden desacoplar de las plataformas y los marcos. Afortunadamente, todavía hay una forma en que podemos emplear la arquitectura de software para: En el peor de los casos, hacer que estas clases sean más fáciles de probar y, en el mejor de los casos, tan trivialmente simples que las pruebas se pueden realizar de un vistazo .

Objetos humildes y puntos de vista pasivos

Los dos nombres anteriores se refieren a un patrón en el que un objeto que debe comunicarse con dependencias de bajo nivel se simplifica tanto que podría decirse que no necesita ser probado. Conocí este patrón por primera vez a través del blog de Martin Fowler sobre variaciones de Model-View-Presenter. Más tarde, a través de los trabajos de Robert C. Martin, se me presentó la idea de tratar ciertas clases como Humble Objects , lo que implica que este patrón no tiene por qué limitarse a las clases de interfaz de usuario (aunque no quiero decir que Fowler nunca implica tal limitación).

Cualquiera que sea el nombre que elija para este patrón, es deliciosamente simple de entender y, en cierto sentido, creo que en realidad es solo el resultado de aplicar rigurosamente SOC a sus clases. Si bien este patrón se aplica también a las clases de back-end, usaremos nuestra clase de interfaz de usuario para demostrar este principio en acción. La separación es muy simple: las clases que interactúan con las dependencias de la plataforma y el marco, no piensan por sí mismas (de ahí los apodos Humble y Pasivo ). Cuando ocurre un evento, lo único que hacen es reenviar los detalles de este evento a cualquier clase lógica que esté escuchando:

 //from CalculatorActivity's onCreate() function: evaluate.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { controlLogic.handleInput('='); } });

La clase lógica, que debería ser trivialmente fácil de probar, es responsable de controlar la interfaz de usuario de manera muy detallada. En lugar de llamar a una sola función genérica updateUserInterface(...) en la clase user interface y dejar que haga el trabajo de una actualización masiva, la user interface (u otra clase similar) poseerá funciones pequeñas y específicas que deberían ser fáciles de nombrar e implementar:

 //Interface functions of CalculatorActivity: @Override public void updateDisplay(String displayText) { display.setText(displayText); } @Override public String getDisplay() { return display.getText().toString(); } @Override public void showError() { Toast.makeText(this, INVALID_MESSAGE, Toast.LENGTH_LONG).show(); } //…

En principio, estos dos ejemplos deberían brindarle lo suficiente para comprender cómo implementar este patrón. El objeto que posee la lógica está vagamente acoplado, y el objeto que está fuertemente acoplado a dependencias molestas se vuelve casi desprovisto de lógica.

Now, at the start of this subsection, I made the statement that these classes become arguably unnecessary to test, and it is important we look at both sides of this argument. In an absolute sense, it is impossible to achieve 100% test coverage by employing this pattern, unless you still write tests for such humble / passive classes. It is also worth noting that my decision to use a Calculator as an example App, means that I cannot escape having a gigantic mass of findViewById(...) calls present in the Activity. Giant masses of repetitive code are a common cause of typing errors, and in the absence of some Android UI testing frameworks, my only recourse for testing would be via deploying the feature to a device and manually testing each interaction. Ay.

It is at this point that I will humbly say that I do not know if 100% code coverage is absolutely necessary. I do not know many developers who strive for absolute test coverage in production code, and I have never done so myself. One day I might, but I will reserve my opinions on this matter until I have the reference experiences to back them up. In any case, I would argue that applying this pattern will still ultimately make it simpler and easier to test tightly coupled classes; if for no reason other than they become simpler to write.

Another objection to this approach, was raised by a fellow programmer when I described this approach in another context. The objection was that the logic class (whether it be a Controller , Presenter , or even a ViewModel depending on how you use it), becomes a God class.

While I do not agree with that sentiment, I do agree that the end result of applying this pattern is that your Logic classes become larger than if you left more decisions up to your user interface class.

This has never been an issue for me as I treat each feature of my applications as self-contained components, as opposed to having one giant controller for managing multiple user interface screens. In any case, I think this argument holds reasonably true if you fail to apply SOC to your front end or back end components. Therefore, my advice is to apply SOC to your front end and back end components quite rigorously.

Further Considerations

After all of this discussion on applying the principles of software architecture to reduce the necessity of using a wide-array of testing frameworks, improve the testability of classes in general, and a pattern which allows classes to be tested indirectly (at least to some degree), I am not actually here to tell you to stop using your preferred frameworks.

For those curious, I often use a library to generate mock classes for my Unit tests (for Java I prefer Mockito , but these days I mostly write Kotlin and prefer Mockk in that language), and JUnit is a framework which I use quite invariably. Since all of these options are coupled to languages as opposed to the Android platform, I can use them quite interchangeably across mobile and web application development. From time to time (if project requirements demand it), I will even use tools like Robolectric , MockWebServer , and in my five years of studying Android, I did begrudgingly use Espresso once.

My hope is that in reading this article, anyone who has experienced a similar degree of aversion to testing due to paralysis by jargon analysis , will come to see that getting started with testing really can be simple and framework minimal .

Lectura adicional en SmashingMag:

  • Sliding In And Out Of Vue.js
  • Designing And Building A Progressive Web Application Without A Framework
  • CSS Frameworks o CSS Grid: ¿Qué debo usar para mi proyecto?
  • Uso de Flutter de Google para un desarrollo móvil verdaderamente multiplataforma