Aplicação prática da pirâmide de teste no microsserviço baseado em mola

Aplicação prática da pirâmide de teste no microsserviço baseado em mola

1. Visão geral

Neste tutorial, vamos entender o popular modelo de teste de software chamado pirâmide de teste.

Veremos como isso é relevante no mundo dos microsserviços. No processo, desenvolveremos um aplicativo de amostra e testes relevantes para estar em conformidade com este modelo. Além disso, tentaremos entender os benefícios e limites de usar um modelo.

2. Vamos dar um passo atrás

Antes de começarmos a entender qualquer modelo específico, como a pirâmide de teste, é fundamental entender por que precisamos de um.

A necessidade de testar o software é inerente e talvez tão antiga quanto a história do próprio desenvolvimento de software. O teste de software percorreu um longo caminho, do manual à automação e além. O objetivo, entretanto, permanece o mesmo - paradeliver software conforming to specifications.

2.1. Tipos de testes

Existem vários tipos diferentes de testes na prática, que se concentram em objetivos específicos. Infelizmente, há uma variação bastante no vocabulário e até na compreensão desses testes.

Vamos revisar alguns dos mais populares e possivelmente inequívocos:

  • Unit Tests: os testes de unidade são os testes quetarget small units of code, preferably in isolation. O objetivo aqui é validar o comportamento do menor pedaço de código testável sem se preocupar com o restante da base de código. Isso implica automaticamente que qualquer dependência precisa ser substituída por um mock ou um stub ou uma construção semelhante.

  • Integration Tests: embora os testes de unidade se concentrem nas partes internas de uma parte do código, o fato é que muita complexidade está fora dele. As unidades de código precisam trabalhar juntas e frequentemente com serviços externos, como bancos de dados, intermediários de mensagens ou serviços da web. Os testes de integração são os testes quetarget the behavior of an application while integrating with external dependencies.

  • UI Tests: um software que desenvolvemos geralmente é consumido por meio de uma interface com a qual os consumidores podem interagir. Muitas vezes, um aplicativo tem uma interface da web. No entanto, as interfaces de API estão se tornando cada vez mais populares. Testes de IUtarget the behavior of these interfaces, which often are highly interactive in nature. Agora, esses testes podem ser conduzidos de maneira completa, ou as interfaces do usuário também podem ser testadas isoladamente.

2.2. Manual vs. Testes automatizados

O teste de software é feito manualmente desde o início do teste e é amplamente praticado até hoje. No entanto, não é difícil entender que o teste manual tem restrições. For the tests to be useful, they have to be comprehensive and run often.

Isso é ainda mais importante nas metodologias de desenvolvimento ágil e na arquitetura de microsserviço nativo da nuvem. No entanto, a necessidade de automação de teste foi percebida muito antes.

Se recordarmos os diferentes tipos de testes que discutimos anteriormente, sua complexidade e escopo aumentam à medida que passamos dos testes de unidade para os testes de integração e interface do usuário. Pelo mesmo motivo,automation of unit tests is easier and bears most of the benefits também. À medida que avançamos, fica cada vez mais difícil automatizar os testes com benefícios indiscutivelmente menores.

Salvo certos aspectos, é possível automatizar o teste da maioria do comportamento do software a partir de hoje. No entanto, isso deve ser ponderado racionalmente com os benefícios comparados ao esforço necessário para automatizar.

3. O que é uma pirâmide de teste?

Agora que reunimos contexto suficiente em torno dos tipos e ferramentas de teste, é hora de entender o que é exatamente uma pirâmide de teste. Vimos que existem diferentes tipos de testes que devemos escrever.

No entanto, como devemos decidir quantos testes devemos escrever para cada tipo? Quais são os benefícios ou armadilhas a serem observados? Esses são alguns dos problemas abordados por um modelo de automação de teste como a pirâmide de teste.

Mike Cohn veio com uma construção chamada Test Pyramid em seu livro “https://www.pearson.com/us/higher-education/program/Cohn-Succeeding-with-Agile-Software-Development-Using-Scrum /PGM201415.html[Sucesso com Agile] ”. Estepresents a visual representation of the number of tests that we should write at different levels de granularidade.

A idéia é que ele seja o mais alto no nível mais granular e comece a diminuir à medida que ampliamos o escopo do teste. Isso dá a forma típica de uma pirâmide, daí o nome:

image

Embora o conceito seja bastante simples e elegante, muitas vezes é um desafio adotá-lo de forma eficaz. É importante entender que não devemos nos fixar na forma do modelo e nos tipos de testes que ele menciona. O principal argumento deve ser o seguinte:

  • Devemos escrever testes com diferentes níveis de granularidade

  • Precisamos escrever menos testes à medida que nos aproximamos do escopo

4. Ferramentas de automação de teste

Existem várias ferramentas disponíveis em todas as linguagens de programação convencionais para escrever diferentes tipos de testes. Cobriremos algumas das escolhas populares no mundo Java.

4.1. Testes unitários

  • Estrutura de teste: a escolha mais popular aqui em Java éJUnit, que tem uma versão de próxima geração conhecida comoJUnit5. Outras escolhas populares nesta área incluemTestNG, que oferece alguns recursos diferenciados em comparação com JUnit5. No entanto, para a maioria das aplicações, ambas são opções adequadas.

  • Zombando: Como vimos anteriormente, definitivamente queremos deduzir a maioria das dependências, se não todas, durante a execução de um teste de unidade. Para isso, precisamos de um mecanismo para substituir as dependências por um teste duplo como um mock ou stub. Mockito é uma excelente estrutura para provisionar mocks para objetos reais em Java.

4.2. Testes de integração

  • Estrutura de teste: o escopo de um teste de integração é mais amplo que um teste de unidade, mas o ponto de entrada geralmente é o mesmo código em uma abstração mais alta. Por esse motivo, as mesmas estruturas de teste que funcionam para testes de unidade também são adequadas para testes de integração.

  • Zombando: O objetivo de um teste de integração é testar o comportamento de um aplicativo com integrações reais. No entanto, talvez não desejemos acessar um banco de dados ou um intermediário de mensagens real para testes. Muitos bancos de dados e serviços semelhantes oferecem umembeddable version para escrever testes de integração.

4.3. Testes de interface do usuário

  • Estrutura de teste: A complexidade dos testes de interface do usuário varia dependendo do cliente que lida com os elementos de interface do usuário do software. Por exemplo, o comportamento de uma página da web pode variar dependendo do dispositivo, navegador e até do sistema operacional. Selenium é uma escolha popular para emular o comportamento do navegador com um aplicativo da web. Para APIs REST, no entanto, estruturas comoREST-assured são as melhores escolhas.

  • Mocking: as interfaces de usuário estão se tornando mais interativas e renderizadas do lado do cliente com estruturas JavaScript comoAngulareReact. É mais razoável testar esses elementos da IU isoladamente usando uma estrutura de teste comoJasmineeMocha. Obviamente, devemos fazer isso em combinação com testes de ponta a ponta.

5. Adoção de princípios na prática

Vamos desenvolver um pequeno aplicativo para demonstrar os princípios que discutimos até agora. Vamos desenvolver um pequeno microsserviço e entender como escrever testes em conformidade com uma pirâmide de teste.

Microservice architecture ajudastructure an application as a collection of loosely coupled services desenhado em torno dos limites do domínio. Spring Boot oferece uma plataforma excelente para inicializar um microsserviço com uma interface de usuário e dependências como bancos de dados quase instantaneamente.

Vamos aproveitá-los para demonstrar a aplicação prática da pirâmide de teste.

5.1. Arquitetura de Aplicação

Vamos desenvolver um aplicativo básico que nos permite armazenar e consultar filmes que assistimos:

image

Como podemos ver, ele possui um controlador REST simples que expõe três pontos de extremidade:

@RestController
public class MovieController {

    @Autowired
    private MovieService movieService;

    @GetMapping("/movies")
    public List retrieveAllMovies() {
        return movieService.retrieveAllMovies();
    }

    @GetMapping("/movies/{id}")
    public Movie retrieveMovies(@PathVariable Long id) {
        return movieService.retrieveMovies(id);
    }

    @PostMapping("/movies")
    public Long createMovie(@RequestBody Movie movie) {
        return movieService.createMovie(movie);
    }
}

O controlador apenas roteia para serviços apropriados, além de manipular o empacotamento e o desempacotamento de dados:

@Service
public class MovieService {

    @Autowired
    private MovieRepository movieRepository;

    public List retrieveAllMovies() {
        return movieRepository.findAll();
    }

    public Movie retrieveMovies(@PathVariable Long id) {
        Movie movie = movieRepository.findById(id)
          .get();
        Movie response = new Movie();
        response.setTitle(movie.getTitle()
          .toLowerCase());
        return response;
    }

    public Long createMovie(@RequestBody Movie movie) {
        return movieRepository.save(movie)
          .getId();
    }
}

Além disso, temos um repositório JPA que mapeia para nossa camada de persistência:

@Repository
public interface MovieRepository extends JpaRepository {
}

Por fim, nossa entidade de domínio simples para armazenar e transmitir dados de filmes:

@Entity
public class Movie {
    @Id
    private Long id;
    private String title;
    private String year;
    private String rating;

    // Standard setters and getters
}

Com este aplicativo simples, agora estamos prontos para explorar testes com granularidade e quantidade diferentes.

5.2. Teste de Unidade

Primeiro, vamos entender como escrever um teste de unidade simples para nosso aplicativo. Como é evidente nesta aplicação,most of the logic tends to accumulate in the service layer. Isso exige que testemos isso extensivamente e com mais frequência - um bom ajuste para testes de unidade:

public class MovieServiceUnitTests {

    @InjectMocks
    private MovieService movieService;

    @Mock
    private MovieRepository movieRepository;

    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
    }

    @Test
    public void givenMovieServiceWhenQueriedWithAnIdThenGetExpectedMovie() {
        Movie movie = new Movie(100L, "Hello World!");
        Mockito.when(movieRepository.findById(100L))
          .thenReturn(Optional.ofNullable(movie));

        Movie result = movieService.retrieveMovies(100L);

        Assert.assertEquals(movie.getTitle().toLowerCase(), result.getTitle());
    }
}

Aqui, estamos usando JUnit como nossa estrutura de teste e Mockito para simular dependências. Esperava-se que nosso serviço, por algum requisito estranho, retornasse títulos de filmes em letras minúsculas, e é isso que pretendemos testar aqui. Pode haver vários comportamentos que devemos abordar extensivamente com esses testes de unidade.

5.3. Teste de integração

Em nossos testes de unidade, zombamos do repositório, que era nossa dependência da camada de persistência. Embora tenhamos testado exaustivamente o comportamento da camada de serviço, ainda podemos ter problemas quando ela se conecta ao banco de dados. É aqui que os testes de integração entram em cena:

@RunWith(SpringRunner.class)
@SpringBootTest
public class MovieControllerIntegrationTests {

    @Autowired
    private MovieController movieController;

    @Test
    public void givenMovieControllerWhenQueriedWithAnIdThenGetExpectedMovie() {
        Movie movie = new Movie(100L, "Hello World!");
        movieController.createMovie(movie);

        Movie result = movieController.retrieveMovies(100L);

        Assert.assertEquals(movie.getTitle().toLowerCase(), result.getTitle());
    }
}

Observe algumas diferenças interessantes aqui. Agora, não estamos zombando de nenhuma dependência. No entanto,we may still need to mock a few dependencies depending upon the situation. Além disso, estamos executando esses testes comSpringRunner.

Isso significa essencialmente que teremos um contexto de aplicativo Spring e um banco de dados ativo para executar este teste. Não é de admirar, isso será mais lento! Portanto, escolhemos muito menos cenários para testes aqui.

5.4. Teste de UI

Por fim, nosso aplicativo possui pontos de extremidade REST para consumir, que podem ter suas próprias nuances para testar. Como esta é a interface do usuário para nosso aplicativo, vamos nos concentrar em abordá-la em nossos testes de IU. Agora vamos usar o REST garantido para testar o aplicativo:

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class MovieApplicationE2eTests {

    @Autowired
    private MovieController movieController;

    @LocalServerPort
    private int port;

    @Test
    public void givenMovieApplicationWhenQueriedWithAnIdThenGetExpectedMovie() {
        Movie movie = new Movie(100L, "Hello World!");
        movieController.createMovie(movie);

        when().get(String.format("http://localhost:%s/movies/100", port))
          .then()
          .statusCode(is(200))
          .body(containsString("Hello World!".toLowerCase()));
    }
}

Como podemos ver,these tests are run with a running application and access it through the available endpoints. Nosso foco é testar cenários típicos associados ao HTTP, como o código de resposta. Esses serão os testes mais lentos a serem executados por razões óbvias.

Portanto, devemos ser muito específicos para escolher cenários para testar aqui. Devemos nos concentrar apenas nas complexidades que não conseguimos cobrir em testes anteriores mais granulares.

6. Pirâmide de teste para microsserviços

Agora vimos como escrever testes com granularidade diferente e estruturá-los de forma adequada. No entanto, o objetivo principal é capturar a maior parte da complexidade do aplicativo com testes mais granulares e mais rápidos.

Ao abordar isso em amonolithic application gives us the desired pyramid structure, this may not be necessary for other architectures.

Como sabemos, a arquitetura de microsserviços pega um aplicativo e nos fornece um conjunto de aplicativos fracamente acoplados. Ao fazer isso, externaliza algumas das complexidades inerentes ao aplicativo.

Agora, essas complexidades se manifestam na comunicação entre serviços. Nem sempre é possível capturá-los por meio de testes de unidade, e temos que escrever mais testes de integração.

Embora isso possa significar que nos afastamos do modelo clássico da pirâmide, isso não significa que também nos afastamos dos princípios. Lembre-se,we’re still capturing most of the complexities with as granular tests as possible. Contanto que estejamos claros sobre isso, um modelo que pode não corresponder a uma pirâmide perfeita ainda terá valor.

O importante a entender aqui é que um modelo só é útil se agregar valor. Geralmente, o valor está sujeito ao contexto, que neste caso é a arquitetura que escolhemos para nosso aplicativo. Portanto, embora seja útil usar um modelo como uma diretriz, devemosfocus on the underlying principlese finalmente escolher o que faz sentido em nosso contexto de arquitetura.

7. Integração com CI

O poder e o benefício dos testes automatizados são amplamente percebidos quando os integramos ao pipeline de integração contínua. Jenkins é uma escolha popular para definir build edeployment pipelines declarativamente.

Nóscan integrate any tests which we’ve automated in the Jenkins pipeline. No entanto, devemos entender que isso aumenta o tempo para a execução do pipeline. Um dos principais objetivos da integração contínua é o feedback rápido. Isso pode entrar em conflito se começarmos a adicionar testes que o tornam mais lento.

A conclusão principal deve seradd tests that are fast, like unit tests, to the pipeline that is expected to run more frequently. Por exemplo, podemos não nos beneficiar da adição de testes de interface do usuário ao pipeline que é acionado a cada confirmação. Mas, esta é apenas uma orientação e, finalmente, depende do tipo e da complexidade da aplicação com a qual estamos lidando.

8. Conclusão

Neste artigo, abordamos os conceitos básicos de teste de software. Compreendemos diferentes tipos de teste e a importância de automatizá-los usando uma das ferramentas disponíveis.

Além disso, entendemos o que significa uma pirâmide de teste. Implementamos isso usando um microsserviço criado usando o Spring Boot.

Por fim, passamos pela relevância da pirâmide de teste, especialmente no contexto da arquitetura, como microsserviços.