Construa com confiança: um guia para testes JUnit
Publicados: 2022-03-11Com o avanço da tecnologia e da indústria passando do modelo em cascata para o Agile e agora para o DevOps, as alterações e os aprimoramentos em um aplicativo são implantados na produção no momento em que são feitos. Com o código sendo implantado em produção tão rápido, precisamos ter certeza de que nossas alterações funcionam e também de que não quebram nenhuma funcionalidade preexistente.
Para construir essa confiança, devemos ter uma estrutura para testes de regressão automática. Para fazer testes de regressão, existem muitos testes que devem ser realizados do ponto de vista de nível de API, mas aqui abordaremos dois tipos principais de testes:
- Teste de unidade , onde qualquer teste cobre a menor unidade de um programa (uma função ou procedimento). Pode ou não receber alguns parâmetros de entrada e pode ou não retornar alguns valores.
- Teste de integração , onde unidades individuais são testadas juntas para verificar se todas as unidades interagem entre si conforme o esperado.
Existem vários frameworks disponíveis para cada linguagem de programação. Vamos nos concentrar em escrever testes unitários e de integração para um aplicativo da Web escrito no framework Spring do Java.
Na maioria das vezes, escrevemos métodos em uma classe, e estes, por sua vez, interagem com métodos de alguma outra classe. No mundo de hoje - especialmente em aplicativos corporativos - a complexidade dos aplicativos é tal que um único método pode chamar mais de um método de várias classes. Portanto, ao escrever o teste de unidade para esse método, precisamos de uma maneira de retornar dados simulados dessas chamadas. Isso ocorre porque a intenção deste teste de unidade é testar apenas um método e não todas as chamadas que esse método específico faz.
Vamos pular para o teste de unidade Java no Spring usando o framework JUnit. Começaremos com algo que você já deve ter ouvido falar: zombaria.
O que é zombar e quando ele entra em cena?
Suponha que você tenha uma classe, CalculateArea
, que tem uma função calculateArea(Type type, Double... args)
que calcula a área de uma forma do tipo fornecido (círculo, quadrado ou retângulo).
O código é algo assim em um aplicativo normal que não usa injeção de dependência:
public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; CalculateArea(SquareService squareService, RectangleService rectangeService, CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { switch (type) { case RECTANGLE: if(r.length >=2) return rectangleService.area(r[0],r[1]); else throw new RuntimeException("Missing required params"); case SQUARE: if(r.length >=1) return squareService.area(r[0]); else throw new RuntimeException("Missing required param"); case CIRCLE: if(r.length >=1) return circleService.area(r[0]); else throw new RuntimeException("Missing required param"); default: throw new RuntimeException("Operation not supported"); } } }
public class SquareService { public Double area(double r) { return r * r; } }
public class RectangleService { public Double area(Double r, Double h) { return r * h; } }
public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
public enum Type { RECTANGLE,SQUARE,CIRCLE; }
Agora, se quisermos testar a unidade da função calculateArea()
da classe CalculateArea
, nosso motivo deve ser verificar se os casos de switch
e as condições de exceção funcionam. Não devemos testar se os serviços de forma estão retornando os valores corretos, pois, como mencionado anteriormente, o motivo do teste de unidade de uma função é testar a lógica da função, não a lógica das chamadas que a função está fazendo.
Então, vamos simular os valores retornados por funções de serviço individuais (por exemplo, rectangleService.area()
e testar a função de chamada (por exemplo, CalculateArea.calculateArea()
) com base nesses valores simulados.
Um caso de teste simples para o serviço de retângulo — testando que calculateArea()
realmente chama rectangleService.area()
com os parâmetros corretos — ficaria assim:
import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; public class CalculateAreaTest { RectangleService rectangleService; SquareService squareService; CircleService circleService; CalculateArea calculateArea; @Before public void init() { rectangleService = Mockito.mock(RectangleService.class); squareService = Mockito.mock(SquareService.class); circleService = Mockito.mock(CircleService.class); calculateArea = new CalculateArea(squareService,rectangleService,circleService); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
Duas linhas principais a serem observadas aqui são:
-
rectangleService = Mockito.mock(RectangleService.class);
—Isso cria um mock, que não é um objeto real, mas um mocked. -
Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d);
—Isso diz que, quando zombado, e o métodoarea
do objetorectangleService
é chamado com os parâmetros especificados, então retorna20d
.
Agora, o que acontece quando o código acima faz parte de um aplicativo Spring?
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; public CalculateArea(@Autowired SquareService squareService, @Autowired RectangleService rectangeService, @Autowired CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
Aqui temos duas anotações para a estrutura Spring subjacente para detectar no momento da inicialização do contexto:
-
@Component
: Cria um bean do tipoCalculateArea
-
@Autowired
: Procura os beansrectangleService
,squareService
ecircleService
e os injeta no beancalculatedArea
Da mesma forma, criamos beans para outras classes também:
import org.springframework.stereotype.Service; @Service public class SquareService { public Double area(double r) { return r*r; } }
import org.springframework.stereotype.Service; @Service public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
import org.springframework.stereotype.Service; @Service public class RectangleService { public Double area(Double r, Double h) { return r*h; } }
Agora, se executarmos os testes, os resultados serão os mesmos. Usamos injeção de construtor aqui e, felizmente, não mudamos nosso caso de teste.
Mas há outra maneira de injetar os beans dos serviços de quadrado, círculo e retângulo: injeção de campo. Se usarmos isso, nosso caso de teste precisará de algumas pequenas alterações.
Não entraremos na discussão de qual mecanismo de injeção é melhor, pois isso não está no escopo do artigo. Mas podemos dizer o seguinte: não importa que tipo de mecanismo você use para injetar beans, sempre há uma maneira de escrever testes JUnit para ele.
No caso de injeção de campo, o código fica assim:
@Component public class CalculateArea { @Autowired SquareService squareService; @Autowired RectangleService rectangleService; @Autowired CircleService circleService; public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
Nota: Como estamos usando injeção de campo, não há necessidade de um construtor parametrizado, então o objeto é criado usando o padrão e os valores são definidos usando o mecanismo de injeção de campo.
O código para nossas classes de serviço permanece o mesmo acima, mas o código para a classe de teste é o seguinte:
public class CalculateAreaTest { @Mock RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Before public void init() { MockitoAnnotations.initMocks(this); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
Algumas coisas são diferentes aqui: não o básico, mas a maneira como o alcançamos.
Primeiro, a maneira como zombamos de nossos objetos: usamos anotações @Mock
junto com initMocks()
para criar simulações. Segundo, injetamos mocks no objeto real usando @InjectMocks
junto com initMocks()
.
Isso é feito apenas para reduzir o número de linhas de código.
O que são executores de teste e que tipos de corredores existem?
No exemplo acima, o executor básico usado para executar todos os testes é o BlockJUnit4ClassRunner
que detecta todas as anotações e executa todos os testes de acordo.
Se quisermos mais algumas funcionalidades, podemos escrever um runner personalizado. Por exemplo, na classe de teste acima, se quisermos pular a linha MockitoAnnotations.initMocks(this);
então poderíamos usar um runner diferente que é construído em cima do BlockJUnit4ClassRunner
, por exemplo, MockitoJUnitRunner
.
Usando MockitoJUnitRunner
, nem precisamos inicializar mocks e injetá-los. Isso será feito pelo próprio MockitoJUnitRunner
apenas lendo as anotações.
(Há também SpringJUnit4ClassRunner
, que inicializa o ApplicationContext
necessário para o teste de integração do Spring - assim como um ApplicationContext
é criado quando um aplicativo Spring é iniciado. Isso será abordado mais tarde.)
Simulação parcial
Quando queremos que um objeto na classe de teste zombe de algum(s) método(s), mas também chame algum(s) método(s) real(is), então precisamos de zombaria parcial. Isso é feito via @Spy
no JUnit.
Ao contrário de usar @Mock
, com @Spy
, um objeto real é criado, mas os métodos desse objeto podem ser simulados ou podem ser realmente chamados - o que precisarmos.

Por exemplo, se o método area
na classe RectangleService
chama um método extra log()
e realmente queremos imprimir esse log, o código muda para algo como o abaixo:
@Service public class RectangleService { public Double area(Double r, Double h) { log(); return r*h; } public void log() { System.out.println("skip this"); } }
Se alterarmos a anotação @Mock
de rectangleService
para @Spy
, e também fizermos algumas alterações no código, conforme mostrado abaixo, nos resultados, veremos os logs sendo impressos, mas o método area()
será ridicularizado. Ou seja, a função original é executada apenas por seus efeitos colaterais; seus valores de retorno são substituídos por valores simulados.
@RunWith(MockitoJUnitRunner.class) public class CalculateAreaTest { @Spy RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Test public void calculateRectangleAreaTest() { Mockito.doCallRealMethod().when(rectangleService).log(); Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
Como vamos testar um Controller
ou RequestHandler
?
Pelo que aprendemos acima, o código de teste de um controlador para nosso exemplo seria algo como o abaixo:
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class AreaController { @Autowired CalculateArea calculateArea; @RequestMapping(value = "api/area", method = RequestMethod.GET) @ResponseBody public ResponseEntity calculateArea( @RequestParam("type") String type, @RequestParam("param1") String param1, @RequestParam(value = "param2", required = false) String param2 ) { try { Double area = calculateArea.calculateArea( Type.valueOf(type), Double.parseDouble(param1), Double.parseDouble(param2) ); return new ResponseEntity(area, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity(e.getCause(), HttpStatus.INTERNAL_SERVER_ERROR); } } }
import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @RunWith(MockitoJUnitRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; @Test public void calculateAreaTest() { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); ResponseEntity responseEntity = areaController.calculateArea("RECTANGLE", "5", "4"); Assert.assertEquals(HttpStatus.OK,responseEntity.getStatusCode()); Assert.assertEquals(20d,responseEntity.getBody()); } }
Olhando para o código de teste do controlador acima, ele funciona bem, mas tem um problema básico: ele apenas testa a chamada do método, não a chamada real da API. Todos os casos de teste em que os parâmetros da API e o status das chamadas da API precisam ser testados para diferentes entradas estão ausentes.
Este código é melhor:
import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE¶m1=5¶m2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }
Aqui podemos ver como o MockMvc
assume o trabalho de realizar chamadas de API reais. Ele também possui alguns matchers especiais, como status()
e content()
, que facilitam a validação do conteúdo.
Teste de Integração Java usando JUnit e Mocks
Agora que sabemos que unidades individuais do código funcionam, vamos garantir que elas também interajam umas com as outras como esperamos.
Primeiro, precisamos instanciar todos os beans, a mesma coisa que acontece no momento da inicialização do contexto Spring durante a inicialização do aplicativo.
Para isso, definimos todos os beans em uma classe, digamos TestConfig.java
:
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TestConfig { @Bean public AreaController areaController() { return new AreaController(); } @Bean public CalculateArea calculateArea() { return new CalculateArea(); } @Bean public RectangleService rectangleService() { return new RectangleService(); } @Bean public SquareService squareService() { return new SquareService(); } @Bean public CircleService circleService() { return new CircleService(); } }
Agora vamos ver como usamos essa classe e escrevemos um teste de integração:
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = {TestConfig.class}) public class AreaControllerIntegrationTest { @Autowired AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE¶m1=5¶m2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }
Algumas coisas mudam aqui:
-
@ContextConfiguration(classes = {TestConfig.class})
— informa ao caso de teste onde residem todas as definições de bean. - Agora, em vez de
@InjectMocks
, usamos:
@Autowired AreaController areaController;
Tudo o resto permanece o mesmo. Se depurarmos o teste, veremos que o código realmente é executado até a última linha do método area()
em RectangleService
, onde o return r*h
é calculado. Em outras palavras, a lógica de negócios real é executada.
Isso não significa que não haja simulação de chamadas de método ou chamadas de banco de dados disponíveis no teste de integração. No exemplo acima, não havia nenhum serviço ou banco de dados de terceiros usado, portanto, não precisamos usar mocks. Na vida real, esses aplicativos são raros e muitas vezes encontramos um banco de dados ou uma API de terceiros, ou ambos. Nesse caso, quando criamos o bean na classe TestConfig
, não criamos o objeto real, mas um mocked, e o usamos sempre que necessário.
Bônus: como criar dados de teste de objetos grandes
Muitas vezes, o que impede os desenvolvedores de back-end de escrever testes de unidade ou integração são os dados de teste que temos que preparar para cada teste.
Normalmente, se os dados são pequenos o suficiente, tendo uma ou duas variáveis, é fácil apenas criar um objeto de uma classe de dados de teste e atribuir alguns valores.
Por exemplo, se esperamos que um objeto simulado retorne outro objeto, quando uma função é chamada no objeto simulado, faríamos algo assim:
Class1 object = new Class1(); object.setVariable1(1); object.setVariable2(2);
E então para usar este objeto, faríamos algo assim:
Mockito.when(service.method(arguments...)).thenReturn(object);
Isso é bom nos exemplos de JUnit acima, mas quando as variáveis de membro na classe Class1
acima continuam aumentando, definir campos individuais se torna um problema. Às vezes pode até acontecer que uma classe tenha outro membro de classe não primitivo definido. Em seguida, criar um objeto dessa classe e definir campos obrigatórios individuais aumenta ainda mais o esforço de desenvolvimento apenas para realizar algum clichê.
A solução é gerar um esquema JSON da classe acima e adicionar os dados correspondentes no arquivo JSON uma vez. Agora na classe de teste onde criamos o objeto Class1
, não precisamos criar o objeto manualmente. Em vez disso, lemos o arquivo JSON e, usando ObjectMapper
, o mapeamos na classe Class1
necessária:
ObjectMapper objectMapper = new ObjectMapper(); Class1 object = objectMapper.readValue( new String(Files.readAllBytes( Paths.get("src/test/resources/"+fileName)) ), Class1.class );
Este é um esforço único de criar um arquivo JSON e adicionar valores a ele. Qualquer novo teste depois disso pode usar uma cópia desse arquivo JSON com campos alterados de acordo com as necessidades do novo teste.
Noções básicas de JUnit: Múltiplas abordagens e habilidades transferíveis
Está claro que há muitas maneiras de escrever testes de unidade Java dependendo de como escolhemos injetar beans. Infelizmente, a maioria dos artigos sobre o assunto tendem a assumir que há apenas um caminho, então é fácil ficar confuso, especialmente ao trabalhar com código que foi escrito sob uma suposição diferente. Esperamos que nossa abordagem aqui economize tempo dos desenvolvedores para descobrir a maneira correta de simular e qual executor de teste usar.
Independentemente da linguagem ou estrutura que usamos - talvez até mesmo qualquer nova versão do Spring ou JUnit - a base conceitual permanece a mesma explicada no tutorial JUnit acima. Feliz teste!