Teste facilitado por meio do minimalismo de framework e arquitetura de software

Publicados: 2022-03-10
Resumo rápido ↬ Assim como em muitos outros tópicos no desenvolvimento de software, testes e desenvolvimento orientado a testes geralmente são desnecessariamente complexos em teoria e implementação, colocando muita ênfase no aprendizado de uma ampla gama de estruturas de teste. Neste artigo, vamos revisitar o significado de teste por uma analogia simples, explorar conceitos em arquitetura de software que resultarão diretamente em uma necessidade reduzida de frameworks de teste e alguns argumentos sobre por que você pode se beneficiar de uma atitude de minimalismo para seu processo de teste .

Como muitos outros desenvolvedores Android, minha incursão inicial em testes na plataforma me levou a ser imediatamente confrontado com um grau desmoralizante de jargão. Além disso, os poucos exemplos que encontrei na época (por volta de 2015) não apresentavam casos de uso práticos que podem ter me inclinado a pensar que a relação custo-benefício de aprender uma ferramenta como o Espresso para verificar se um TextView.setText( …) estava funcionando corretamente, era um investimento razoável.

Para piorar ainda mais as coisas, eu não tinha uma compreensão prática da arquitetura de software na teoria ou na prática, o que significava que, mesmo que eu me preocupasse em aprender esses frameworks, eu estaria escrevendo testes para aplicativos monolíticos compostos por algumas classes god , escritas em código de espaguete . A conclusão é que construir, testar e manter tais aplicativos é um exercício de auto-sabotagem, independentemente de sua experiência em framework; no entanto, essa percepção só se torna clara após a construção de um aplicativo modular , fracamente acoplado e altamente coeso .

A partir daqui chegamos a um dos principais pontos de discussão deste artigo, que vou resumir em linguagem simples aqui: Entre os principais benefícios da aplicação dos princípios dourados da arquitetura de software (não se preocupe, vou discuti-los com exemplos simples e language), é que seu código pode se tornar mais fácil de testar. Existem outros benefícios em aplicar tais princípios, mas a relação entre arquitetura de software e teste é o foco deste artigo.

No entanto, para aqueles que desejam entender por que e como testamos nosso código, vamos primeiro explorar o conceito de teste por analogia; sem exigir que você memorize nenhum jargão. Antes de nos aprofundarmos no tópico principal, também examinaremos a questão de por que existem tantos frameworks de teste, pois ao examinar isso podemos começar a ver seus benefícios, limitações e talvez até uma solução alternativa.

Mais depois do salto! Continue lendo abaixo ↓

Testes: por que e como

Esta seção não será uma informação nova para qualquer testador experiente, mas talvez você goste dessa analogia mesmo assim. Claro que sou um engenheiro de software, não um engenheiro de foguetes, mas por um momento vou tomar emprestada uma analogia que diz respeito ao projeto e construção de objetos tanto no espaço físico quanto no espaço de memória de um computador. Acontece que enquanto o meio muda, o processo é, em princípio, o mesmo.

Suponhamos por um momento que somos engenheiros de foguetes e nosso trabalho é construir o primeiro estágio* do foguete de um ônibus espacial. Suponha também que tenhamos um projeto útil para o primeiro estágio para começar a construir e testar em várias condições.

“Primeiro estágio” refere-se a propulsores que são disparados quando o foguete é lançado pela primeira vez

Antes de entrarmos no processo, gostaria de salientar por que prefiro esta analogia: você não deve ter nenhuma dificuldade em responder à pergunta de por que estamos nos preocupando em testar nosso design antes de colocá-lo em situações em que vidas humanas estão em jogo. Embora eu não tente convencê-lo de que testar seus aplicativos antes do lançamento pode salvar vidas (embora seja possível, dependendo da natureza do aplicativo), isso pode salvar classificações, avaliações e seu trabalho. No sentido mais amplo, o teste é a maneira pela qual garantimos que partes únicas, vários componentes e sistemas inteiros funcionem antes de empregá-los em situações em que é extremamente importante que eles não falhem.

Voltando ao aspecto como dessa analogia, apresentarei o processo pelo qual os engenheiros testam um projeto específico: redundância . A redundância é simples em princípio: crie cópias do componente a ser testado com a mesma especificação de design que você deseja usar no momento do lançamento. Teste essas cópias em um ambiente isolado que controla estritamente as pré-condições e variáveis. Embora isso não garanta que o propulsor do foguete funcione corretamente quando integrado em todo o ônibus espacial, pode-se ter certeza de que, se não funcionar em um ambiente controlado, será muito improvável que funcione.

Suponha que das centenas, ou talvez milhares de variáveis ​​com as quais as cópias do projeto do foguete foram testadas, se reduza à temperatura ambiente em que o foguete será disparado. Ao testar a 35° Celsius, vemos que tudo funciona sem erros. Novamente, o foguete é testado aproximadamente à temperatura ambiente sem falhas. O teste final será na temperatura mais baixa registrada para o local de lançamento, a -5° Celsius. Durante este teste final, o foguete dispara, mas após um curto período, o foguete explode e logo em seguida explode violentamente; mas felizmente em um ambiente controlado e seguro.

Neste ponto, sabemos que as mudanças de temperatura parecem estar pelo menos envolvidas no teste falho, o que nos leva a considerar quais partes do propulsor do foguete podem ser afetadas adversamente por temperaturas frias. Com o tempo, descobre-se que um componente chave, um anel de borracha que serve para estancar o fluxo de combustível de um compartimento para outro, torna-se rígido e ineficaz quando exposto a temperaturas próximas ou abaixo de zero.

É possível que você tenha notado que sua analogia é vagamente baseada nos trágicos eventos do desastre do ônibus espacial Challenger . Para aqueles que não estão familiarizados, a triste verdade (na medida em que as investigações concluíram) é que houve muitos testes e avisos fracassados ​​dos engenheiros, e ainda assim as preocupações administrativas e políticas estimularam o lançamento a prosseguir independentemente. De qualquer forma, tenha você memorizado ou não o termo redundância , minha esperança é que você tenha entendido o processo fundamental para testar partes de qualquer tipo de sistema.

Sobre Software

Considerando que a analogia anterior explicava o processo fundamental para testar foguetes (tomando bastante liberdade com os detalhes mais sutis), agora vou resumir de uma maneira que provavelmente é mais relevante para você e para mim. Embora seja possível testar software apenas lançando para os dispositivos, uma vez que esteja em qualquer tipo de estado implantável, suponho que podemos aplicar o princípio da redundância às partes individuais do aplicativo primeiro.

Isso significa que criamos cópias das partes menores de todo o aplicativo (comumente chamadas de Unidades de software), configuramos um ambiente de teste isolado e vemos como eles se comportam com base em quaisquer variáveis, argumentos, eventos e respostas que possam ocorrer em tempo de execução. O teste é realmente tão simples quanto isso em teoria, mas a chave para chegar a esse processo está na criação de aplicativos que sejam testáveis ​​de maneira viável. Isso se resume a duas preocupações que veremos nas próximas duas seções. A primeira preocupação tem a ver com o ambiente de teste , e a segunda tem a ver com a forma como estruturamos as aplicações.

Por que precisamos de frameworks?

Para testar um software (doravante referido como Unit , embora essa definição seja deliberadamente uma simplificação excessiva), é necessário ter algum tipo de ambiente de teste que permita interagir com seu software em tempo de execução. Para que os aplicativos de construção sejam executados puramente em um ambiente JVM ( Java Virtual Machine ), tudo o que é necessário para escrever testes é um JRE ( Java Runtime Environment ). Tomemos por exemplo esta classe de calculadora muito simples:

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

Na ausência de qualquer framework, desde que tenhamos uma classe de teste que contenha uma função main para realmente executar nosso código, podemos testá-lo. Como você deve se lembrar, a função main denota o ponto inicial de execução de um programa Java simples. Quanto ao que estamos testando, simplesmente alimentamos alguns dados de teste nas funções da Calculadora e verificamos se ela está executando a aritmética básica corretamente:

 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."); } }

Testar um aplicativo Android é, obviamente, um procedimento completamente diferente. Embora haja uma função main enterrada na fonte do arquivo ZygoteInit.java (cujos detalhes não são importantes aqui), que é invocada antes de um aplicativo Android ser lançado na JVM , mesmo um desenvolvedor Android júnior deve saiba que o próprio sistema é responsável por chamar essa função; não o desenvolvedor . Em vez disso, os pontos de entrada para aplicativos Android são a classe Application e quaisquer classes Activity para as quais o sistema pode ser apontado por meio do arquivo AndroidManifest.xml .

Tudo isso é apenas uma pista para o fato de que testar Units em um aplicativo Android apresenta um nível maior de complexidade, estritamente porque nosso ambiente de teste agora deve levar em conta a plataforma Android.

Domar o problema do acoplamento apertado

Acoplamento forte é um termo que descreve uma função, classe ou módulo de aplicativo que depende de plataformas, estruturas, linguagens e bibliotecas específicas. É um termo relativo, o que significa que nosso exemplo Calculator.java está fortemente acoplado à linguagem de programação Java e à biblioteca padrão, mas essa é a extensão de seu acoplamento. Na mesma linha, o problema de testar classes fortemente acopladas à plataforma Android é que você deve encontrar uma maneira de trabalhar com ou em torno da plataforma.

Para aulas fortemente acopladas à plataforma Android, você tem duas opções. A primeira é simplesmente implantar suas classes em um dispositivo Android (físico ou virtual). Embora eu sugira que você teste a implantação do código do aplicativo antes de enviá-lo para produção, essa é uma abordagem altamente ineficiente durante os estágios iniciais e intermediários do processo de desenvolvimento em relação ao tempo.

Uma Unit , por mais técnica que você prefira, é geralmente considerada como uma única função em uma classe (embora alguns expandam a definição para incluir funções auxiliares subsequentes que são chamadas internamente pela chamada de função única inicial). De qualquer forma, as Unidades devem ser pequenas; construir, compilar e implantar um aplicativo inteiro para testar uma única Unidade é perder completamente o objetivo de testar isoladamente .

Outra solução para o problema do acoplamento rígido é usar estruturas de teste para interagir ou simular (simular) dependências de plataforma. Estruturas como Espresso e Robolectric fornecem aos desenvolvedores meios muito mais eficazes para testar Unidades do que a abordagem anterior; o primeiro sendo útil para testes executados em um dispositivo (conhecidos como “testes instrumentados” porque aparentemente chamá-los de testes de dispositivo não era ambíguo o suficiente) e o último sendo capaz de zombar do framework Android localmente em uma JVM.

Antes de começar a criticar essas estruturas em vez da alternativa que discutirei em breve, quero deixar claro que não quero dizer que você nunca deve usar essas opções. O processo que um desenvolvedor usa para construir e testar seus aplicativos deve nascer de uma combinação de preferência pessoal e atenção à eficiência.

Para aqueles que não gostam de construir aplicativos modulares e fracamente acoplados, você não terá escolha a não ser se familiarizar com essas estruturas se desejar ter um nível adequado de cobertura de teste. Muitos aplicativos maravilhosos foram construídos dessa maneira, e não raramente sou acusado de tornar meus aplicativos muito modulares e abstratos. Quer você adote minha abordagem ou decida se apoiar fortemente em frameworks, eu o saúdo por dedicar tempo e esforço para testar seus aplicativos.

Mantenha suas estruturas à distância

Para o preâmbulo final da lição principal deste artigo, vale a pena discutir por que você pode querer ter uma atitude de minimalismo quando se trata de usar frameworks (e isso se aplica a mais do que apenas testar frameworks). O subtítulo acima é uma paráfrase do magnânimo professor de boas práticas de software: Robert “Uncle Bob” C. Martin. Das muitas jóias que ele me deu desde que estudei suas obras pela primeira vez, esta levou vários anos de experiência direta para entender.

Na medida em que entendo do que se trata esta afirmação, o custo de usar frameworks está no investimento de tempo necessário para aprendê-los e mantê-los. Alguns deles mudam com bastante frequência e alguns deles não mudam com a frequência suficiente. As funções tornam-se obsoletas, os frameworks deixam de ser mantidos e a cada 6-24 meses um novo framework chega para suplantar o anterior. Portanto, se você puder encontrar uma solução que possa ser implementada como uma plataforma ou recurso de linguagem (que tendem a durar muito mais), ela tenderá a ser mais resistente a mudanças dos vários tipos mencionados acima.

Em uma nota mais técnica, frameworks como o Espresso e, em menor grau, o Robolectric , nunca podem ser executados tão eficientemente quanto os testes JUnit simples, ou mesmo o teste livre do framework anterior. Embora o JUnit seja de fato um framework, ele é fortemente acoplado à JVM , que tende a mudar a uma taxa muito mais lenta do que a própria plataforma Android. Menos frameworks quase invariavelmente significam código que é mais eficiente em termos de tempo que leva para executar e escrever um ou mais testes.

A partir disso, você provavelmente pode concluir que agora estaremos discutindo uma abordagem que alavancará algumas técnicas que nos permitirão manter a plataforma Android à distância; o tempo todo nos permitindo bastante cobertura de código, eficiência de teste e a oportunidade de ainda usar uma estrutura aqui ou ali quando for necessário.

A arte da arquitetura

Para usar uma analogia boba, pode-se pensar em frameworks e plataformas como colegas autoritários que assumirão seu processo de desenvolvimento, a menos que você estabeleça limites apropriados com eles. Os princípios de ouro da arquitetura de software podem fornecer os conceitos gerais e técnicas específicas necessárias para criar e impor esses limites. Como veremos em um momento, se você já se perguntou quais são realmente os benefícios de aplicar princípios de arquitetura de software em seu código, alguns diretamente, e muitos indiretamente tornam seu código mais fácil de testar.

Separação de preocupações

Separação de preocupações é, na minha opinião, o conceito mais universalmente aplicável e útil na arquitetura de software como um todo (sem querer dizer que outros devam ser negligenciados). A separação de interesses (SOC) pode ser aplicada, ou completamente ignorada, em todas as perspectivas de desenvolvimento de software que conheço. Para resumir brevemente o conceito, veremos o SOC quando aplicado a classes, mas esteja ciente de que o SOC pode ser aplicado a funções por meio do uso extensivo de funções auxiliares e pode ser extrapolado para módulos inteiros de um aplicativo (“módulos” usados ​​em o contexto do Android/Gradle).

Se você passou muito tempo pesquisando padrões de arquitetura de software para aplicativos GUI, provavelmente encontrará pelo menos um dos seguintes: Model-View-Controller (MVC), Model-View-Presenter (MVP) ou Model-View- ViewModel (MVVM). Tendo construído aplicativos em todos os estilos, direi de antemão que não considero nenhum deles a melhor opção para todos os projetos (ou mesmo recursos dentro de um único projeto). Ironicamente, o padrão que a equipe do Android apresentou alguns anos atrás como sua abordagem recomendada, MVVM, parece ser o menos testável na ausência de estruturas de teste específicas do Android (supondo que você deseja usar as classes ViewModel da plataforma Android, que eu admito ser um fã do).

De qualquer forma, as especificidades desses padrões são menos importantes do que suas generalidades. Todos esses padrões são apenas diferentes sabores de SOC que enfatizam uma separação fundamental de três tipos de código que chamo de: Data , User Interface , Logic .

Então, como exatamente separar Data , User Interface e Logic ajuda você a testar seus aplicativos? A resposta é que, ao extrair a lógica de classes que devem lidar com dependências de plataforma/framework em classes que possuem pouca ou nenhuma dependência de plataforma/framework, o teste se torna fácil e o framework mínimo . Para ser claro, geralmente estou falando de classes que devem renderizar a interface do usuário, armazenar dados em uma tabela SQL ou conectar-se a um servidor remoto. Para demonstrar como isso funciona, vejamos uma arquitetura simplificada de três camadas de um aplicativo Android hipotético.

A primeira classe irá gerenciar nossa interface de usuário. Para manter as coisas simples, usei uma Activity para essa finalidade, mas normalmente opto por Fragments como classes de interface do usuário. Em ambos os casos, ambas as classes apresentam um acoplamento rígido semelhante à 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 você pode ver, a Activity tem dois trabalhos: Primeiro, por ser o ponto de entrada de um determinado recurso de um aplicativo Android , ele atua como uma espécie de contêiner para os demais componentes do recurso. Em termos simples, um contêiner pode ser pensado como uma espécie de classe raiz à qual os outros componentes são vinculados por meio de referências (ou campos de membros privados neste caso). Ele também infla, vincula referências e adiciona ouvintes ao layout XML (a interface do usuário).

Lógica de controle de teste

Ao invés de ter a Activity possuir uma referência a uma classe concreta no back-end, nós fazemos com que ela fale com uma interface do tipo CalculatorContract.IControlLogic. Discutiremos por que esta é uma interface na próxima seção. Por enquanto, apenas entenda que o que estiver do outro lado dessa interface deve ser algo como um Presenter ou Controller . Como essa classe controlará as interações entre a Activity front-end e a Calculator back-end , optei por chamá-la de 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(); } }

Há muitas coisas sutis sobre a maneira como essa classe é projetada que facilitam o teste. Em primeiro lugar, todas as suas referências são da biblioteca padrão Java ou de interfaces definidas dentro do aplicativo. Isso significa que testar essa classe sem nenhum framework é muito fácil e pode ser feito localmente em uma JVM . Outra dica pequena, mas útil, é que todas as diferentes interações dessa classe podem ser chamadas por meio de uma única função handleInput(...) genérica. Isso fornece um único ponto de entrada para testar cada comportamento dessa classe.

Observe também que na evaluateExpression() , estou retornando uma classe do tipo Optional<String> do back-end. Normalmente eu usaria o que os programadores funcionais chamam de Monad , ou como prefiro chamar, Result Wrapper . Qualquer que seja o nome estúpido que você use, é um objeto capaz de representar vários estados diferentes por meio de uma única chamada de função. Optional é uma construção mais simples que pode representar um null ou algum valor do tipo genérico fornecido. De qualquer forma, como o back-end pode receber uma expressão inválida, queremos fornecer à classe ControlLogic alguns meios de determinar o resultado da operação de back-end; responsável tanto pelo sucesso quanto pelo fracasso. Nesse caso, null representará uma falha.

Abaixo está um exemplo de classe de teste que foi escrito usando JUnit , e uma classe que no jargão de teste é chamada de 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 você pode ver, este conjunto de testes não apenas pode ser executado muito rapidamente, mas também não levou muito tempo para ser escrito. De qualquer forma, veremos agora algumas coisas mais sutis que tornaram muito fácil escrever esta classe de teste.

O poder da abstração e inversão de dependência

Existem dois outros conceitos importantes que foram aplicados ao CalculatorControlLogic que o tornaram trivialmente fácil de testar. Em primeiro lugar, se você já se perguntou quais são os benefícios de usar Interfaces e Classes Abstratas (coletivamente chamadas de abstrações ) em Java, o código acima é uma demonstração direta. Como a classe a ser testada faz referência a abstrações em vez de classes concretas , pudemos criar duplos de teste falsos para a interface do usuário e back-end de dentro de nossa classe de teste. Contanto que esses duplos de teste implementem as interfaces apropriadas, a CalculatorControlLogic não poderia se importar menos com o fato de eles não serem reais.

Em segundo lugar, CalculatorControlLogic recebeu suas dependências por meio do construtor (sim, isso é uma forma de injeção de dependência ), em vez de criar suas próprias dependências. Portanto, ele não precisa ser reescrito quando usado em um ambiente de produção ou teste, o que é um bônus para a eficiência.

A injeção de dependência é uma forma de inversão de controle , que é um conceito complicado de definir em linguagem simples. Quer você use a injeção de dependência ou um padrão de localizador de serviço , ambos alcançam o que Martin Fowler (meu professor favorito sobre esses tópicos) descreve como “o princípio de separar a configuração do uso”. Isso resulta em classes que são mais fáceis de testar e mais fáceis de construir isoladas umas das outras.

Testando a lógica de computação

Finalmente, chegamos à classe ComputationLogic , que deve aproximar um dispositivo de E/S como um adaptador para um servidor remoto ou um banco de dados local. Como não precisamos de nenhum deles para uma calculadora simples, ela será apenas responsável por encapsular a lógica necessária para validar e avaliar as expressões que fornecemos:

 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); } } }

Não há muito a dizer sobre essa classe, pois normalmente haveria algum acoplamento rígido a uma biblioteca de back-end específica que apresentaria problemas semelhantes a uma classe fortemente acoplada ao Android. Em um momento discutiremos o que fazer com essas classes, mas esta é tão fácil de testar que podemos tentar:

 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()); } }

As classes mais fáceis de testar são aquelas que simplesmente recebem algum valor ou objeto, e espera-se que retornem um resultado sem a necessidade de chamar algumas dependências externas. De qualquer forma, chega um ponto em que não importa quanta magia de arquitetura de software você aplique, você ainda precisará se preocupar com classes que não podem ser desacopladas de plataformas e frameworks. Felizmente, ainda há uma maneira de empregar a arquitetura de software para: Na pior das hipóteses, tornar essas classes mais fáceis de testar e, na melhor das hipóteses, tão trivialmente simples que o teste pode ser feito de relance .

Objetos humildes e visões passivas

Os dois nomes acima referem-se a um padrão no qual um objeto que deve se comunicar com dependências de baixo nível é simplificado tanto que provavelmente não precisa ser testado. Fui apresentado a esse padrão pela primeira vez no blog de Martin Fowler sobre variações do Model-View-Presenter. Mais tarde, através dos trabalhos de Robert C. Martin, fui apresentado à ideia de tratar certas classes como Humble Objects , o que implica que esse padrão não precisa ser limitado a classes de interface de usuário (embora eu não queira dizer que Fowler nunca implicava tal limitação).

O que quer que você escolha para chamar esse padrão, é deliciosamente simples de entender e, em certo sentido, acredito que seja apenas o resultado da aplicação rigorosa do SOC às suas aulas. Embora esse padrão também se aplique a classes de back-end, usaremos nossa classe de interface de usuário para demonstrar esse princípio em ação. A separação é muito simples: Classes que interagem com dependências de plataforma e framework, não pensam por si mesmas (daí os apelidos Humble e Passive ). Quando ocorre um evento, a única coisa que eles fazem é encaminhar os detalhes desse evento para qualquer classe lógica que esteja escutando:

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

A classe lógica, que deve ser trivialmente fácil de testar, é então responsável por controlar a interface do usuário de maneira muito refinada. Em vez de chamar uma única função genérica updateUserInterface(...) na classe de user interface e deixá-la fazer o trabalho de uma atualização em massa, a user interface (ou outra classe desse tipo) possuirá funções pequenas e específicas que devem ser fáceis de nomear 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(); } //…

Em princípio, esses dois exemplos devem dar a você o suficiente para entender como implementar esse padrão. O objeto que possui a lógica está fracamente acoplado, e o objeto que está fortemente acoplado a dependências incômodas torna-se quase desprovido 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. Ai.

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 .

Leitura adicional no SmashingMag:

  • Sliding In And Out Of Vue.js
  • Designing And Building A Progressive Web Application Without A Framework
  • CSS Frameworks ou CSS Grid: o que devo usar no meu projeto?
  • Usando o Flutter do Google para desenvolvimento móvel verdadeiramente multiplataforma