Otimizando testes de integração de primavera

Otimizando testes de integração de primavera

1. Introdução

Neste artigo, teremos uma discussão holística sobre os testes de integração usando Spring e como otimizá-los.

Em primeiro lugar, discutiremos brevemente a importância dos testes de integração e seu lugar no software moderno com foco no ecossistema Spring.

Posteriormente, cobriremos vários cenários, com foco em aplicativos da web.

Next, we’ll discuss some strategies to improve testing speed, aprendendo sobre diferentes abordagens que podem influenciar a maneira como moldamos nossos testes e a maneira como modelamos o próprio aplicativo.

Antes de começar, é importante ter em mente que este é um artigo de opinião baseado na experiência. Algumas dessas coisas podem servir para você, outras não.

Por fim, este artigo usa Kotlin para os exemplos de código para mantê-los o mais concisos possível, mas os conceitos não são específicos para esta linguagem e os snippets de código devem ser significativos para desenvolvedores Java e Kotlin.

2. Testes de integração

Integration tests are a fundamental part of automated test suites. Embora eles não devam ser tão numerosos quanto os testes de unidade se seguirmos umhealthy test pyramid. Confiar em estruturas como a Spring nos deixa precisando de uma quantidade razoável de testes de integração para arriscar certos comportamentos do nosso sistema.

The more we simplify our code by using Spring modules (data, security, social…), the bigger a need for integration tests.  Isso se torna particularmente verdadeiro quando movemos bits e bobs de nossa infraestrutura para classes@Configuration.

Não devemos "testar a estrutura", mas certamente devemos verificar se a estrutura está configurada para atender às nossas necessidades.

Os testes de integração nos ajudam a criar confiança, mas têm um preço:

  • Essa é uma velocidade de execução mais lenta, o que significa construções mais lentas

  • Além disso, os testes de integração implicam um escopo de teste mais amplo, o que não é ideal na maioria dos casos

Com isso em mente, tentaremos encontrar algumas soluções para mitigar os problemas mencionados acima.

3. Testando aplicativos da Web

O Spring traz algumas opções para testar aplicativos da Web, e a maioria dos desenvolvedores do Spring os conhece, são eles:

  • MockMvc: Simula a API do servlet, útil para aplicativos da web não reativos

  • TestRestTemplate: pode ser usado apontando para nosso aplicativo, útil para aplicativos da web não reativos onde servlets simulados não são desejáveis

  • WebTestClient: é uma ferramenta de teste para aplicativos da web reativos, tanto com solicitações / respostas simuladas ou acessando um servidor real

Como já temos artigos cobrindo esses tópicos, não perderemos tempo falando sobre eles.

Sinta-se à vontade para dar uma olhada se quiser se aprofundar.

4. Otimizando o tempo de execução

Os testes de integração são ótimos. Eles nos dão um bom grau de confiança. Além disso, se implementados adequadamente, eles podem descrever a intenção do nosso aplicativo de uma maneira muito clara, com menos zombaria e ruído de configuração.

No entanto, à medida que nosso aplicativo amadurece e o desenvolvimento aumenta, o tempo de construção inevitavelmente aumenta. À medida que o tempo de construção aumenta, pode ser impraticável continuar executando todos os testes sempre.

Posteriormente, impactando nosso ciclo de feedback e seguindo as melhores práticas de desenvolvimento.

Além disso, os testes de integração são inerentemente caros. Iniciar a persistência de algum tipo, enviar solicitações (mesmo que nunca saiam delocalhost) ou fazer algum IO simplesmente leva tempo.

É fundamental ficar de olho no nosso tempo de construção, incluindo a execução do teste. E existem alguns truques que podemos aplicar no Spring para mantê-lo baixo.

Nas próximas seções, cobriremos alguns pontos para nos ajudar a otimizar nosso tempo de construção, bem como algumas armadilhas que podem afetar sua velocidade:

  • Usando perfis com sabedoria - como os perfis afetam o desempenho

  • Reconsiderando@MockBean – mostra desempenho de ocorrências de simulação

  • Refatorando@MockBean  - alternativas para melhorar o desempenho

  • Pensando cuidadosamente sobre @DirtiesContext – uma anotação útil, mas perigosa e como não usá-la

  • Usando fatias de teste - uma ferramenta interessante que pode ajudar ou seguir nosso caminho

  • Usando herança de classe - uma maneira de organizar testes de maneira segura

  • Gerenciamento estadual - boas práticas para evitar testes de flakey

  • Refatoração em testes de unidade - a melhor maneira de obter uma construção sólida e rápida

Vamos começar!

4.1. Usando perfis com sabedoria

Profiles são uma ferramenta muito legal. Ou seja, tags simples que podem ativar ou desativar determinadas áreas do nosso aplicativo. Poderíamos atéimplement feature flags com eles!

Conforme nossos perfis ficam mais ricos, é tentador trocar de vez em quando em nossos testes de integração. Existem ferramentas convenientes para fazer isso, como@ActiveProfiles. No entanto,every time we pull a test with a new profile, a new ApplicationContext gets created.

A criação de contextos de aplicativo pode ser rápida com um aplicativo de inicialização com mola de baunilha sem nada nele. Adicione um ORM e alguns módulos e ele disparará rapidamente para mais de 7 segundos.

Adicione um monte de perfis e os distribua por alguns testes e obteremos rapidamente uma compilação de mais de 60 segundos (assumindo que executamos testes como parte de nossa compilação - e devemos).

Quando enfrentamos um aplicativo suficientemente complexo, corrigir isso é assustador. No entanto, se planejarmos com antecedência, torna-se trivial manter um tempo de construção razoável.

Há alguns truques que podemos ter em mente quando se trata de perfis nos testes de integração:

  • Crie um perfil agregado, ou seja, test, inclua todos os perfis necessários - siga nosso perfil de teste em todos os lugares

  • Crie nossos perfis com a testabilidade em mente. Se acabarmos tendo que mudar de perfil, talvez haja uma maneira melhor

  • Declare nosso perfil de teste em um local centralizado - falaremos sobre isso mais tarde

  • Evite testar todas as combinações de perfis. Como alternativa, poderíamos ter um conjunto de testes e2e por ambiente testando o aplicativo com esse conjunto de perfis específico

4.2. Os problemas com@MockBean

@MockBean é uma ferramenta muito poderosa.

Quando precisamos de alguma mágica do Spring, mas queremos simular um componente em particular,@MockBean é muito útil. Mas faz isso a um preço.

Every time @MockBean appears in a class, the ApplicationContext cache gets marked as dirty, hence the runner will clean the cache after the test-class is done. O que novamente adiciona um monte de segundos extras à nossa construção.

Isso é controverso, mas tentar exercitar o aplicativo real em vez de zombar para esse cenário específico pode ajudar. Claro, não há bala de prata aqui. Os limites ficam borrados quando não nos permitimos simular dependências.

Podemos pensar: Por que persistiríamos quando tudo o que queremos testar é a nossa camada REST? Este é um ponto justo e sempre há um meio-termo.

No entanto, com alguns princípios em mente, isso pode realmente ser transformado em uma vantagem que leva a um melhor design dos testes e do nosso aplicativo e reduz o tempo de teste.

4.3. Refatorando@MockBean

Nesta seção, tentaremos refatorar um teste 'lento' usando@MockBean para fazê-lo reutilizar oApplicationContext em cache.

Vamos supor que queremos testar um POST que cria um usuário. Se estivéssemos simulando - usando@MockBean, poderíamos simplesmente verificar se nosso serviço foi chamado com um usuário serializado bem.

Se testamos nosso serviço adequadamente, essa abordagem deve ser suficiente:

class UsersControllerIntegrationTest : AbstractSpringIntegrationTest() {

    @Autowired
    lateinit var mvc: MockMvc

    @MockBean
    lateinit var userService: UserService

    @Test
    fun links() {
        mvc.perform(post("/users")
          .contentType(MediaType.APPLICATION_JSON)
          .content("""{ "name":"jose" }"""))
          .andExpect(status().isCreated)

        verify(userService).save("jose")
    }
}

interface UserService {
    fun save(name: String)
}

Porém, queremos evitar@MockBean. Então, vamos acabar persistindo a entidade (assumindo que é isso que o serviço faz).

A abordagem mais ingênua aqui seria testar o efeito colateral: Após o POST, meu usuário está no meu banco de dados, em nosso exemplo, isso usaria o JDBC.

Isso, no entanto, viola os limites de teste:

@Test
fun links() {
    mvc.perform(post("/users")
      .contentType(MediaType.APPLICATION_JSON)
      .content("""{ "name":"jose" }"""))
      .andExpect(status().isCreated)

    assertThat(
      JdbcTestUtils.countRowsInTable(jdbcTemplate, "users"))
      .isOne()
}

Neste exemplo em particular, violamos os limites de teste porque tratamos nosso aplicativo como uma caixa preta HTTP para enviar ao usuário, mas mais tarde afirmamos o uso de detalhes de implementação, ou seja, nosso usuário foi persistido em algum banco de dados.

Se exercitarmos nosso aplicativo por HTTP, também podemos afirmar o resultado por HTTP?

@Test
fun links() {
    mvc.perform(post("/users")
      .contentType(MediaType.APPLICATION_JSON)
      .content("""{ "name":"jose" }"""))
      .andExpect(status().isCreated)

    mvc.perform(get("/users/jose"))
      .andExpect(status().isOk)
}

Existem algumas vantagens se seguirmos a última abordagem:

  • Nosso teste começará mais rápido (sem dúvida, pode demorar um pouco mais para ser executado, mas deve ser recompensado)

  • Além disso, nosso teste não está ciente dos efeitos colaterais não relacionados aos limites HTTP, ou seja, DBs

  • Finalmente, nosso teste expressa com clareza a intenção do sistema: Se você POSTAR, poderá OBTER usuários

Obviamente, isso nem sempre é possível por vários motivos:

  • Podemos não ter o ponto final de efeito colateral: uma opção aqui é considerar a criação de pontos de extremidade de teste

  • A complexidade é muito alta para atingir todo o aplicativo: uma opção aqui é considerar as fatias (falaremos sobre elas mais tarde)

4.4. Pensando com cuidado sobre@DirtiesContext

Às vezes, podemos precisar modificar oApplicationContext em nossos testes. Para este cenário,@DirtiesContext oferece exatamente essa funcionalidade.

Pelas mesmas razões expostas acima,@DirtiesContext  é um recurso extremamente caro quando se trata de tempo de execução e, como tal, devemos ter cuidado.

Some misuses of @DirtiesContext include application cache reset or in memory DB resets. Existem maneiras melhores de lidar com esses cenários em testes de integração, e vamos cobrir algumas nas próximas seções.

4.5. Usando fatias de teste

Fatias de teste são um recurso do Spring Boot introduzido no 1.4. A ideia é bastante simples, o Spring criará um contexto de aplicativo reduzido para uma parte específica do seu aplicativo.

Além disso, a estrutura cuidará da configuração do mínimo.

Há um número razoável de fatias disponíveis imediatamente no Spring Boot e também podemos criar nossas próprias:

  • @JsonTest:  Registra componentes relevantes JSON

  • @DataJpaTest: Registra beans JPA, incluindo o ORM disponível

  • @JdbcTest: Útil para testes JDBC brutos, cuida da fonte de dados e dos bancos de dados de memória sem enfeites de ORM

  • @DataMongoTest: tenta fornecer uma configuração de teste mongo na memória

  • @WebMvcTest: uma fatia de teste MVC simulada sem o resto do aplicativo

  • … (Podemos verificarthe source para encontrar todos eles)

Esse recurso específico, se usado com sabedoria, pode nos ajudar a criar testes restritos sem uma penalidade tão grande em termos de desempenho, especialmente para aplicativos de pequeno / médio porte.

No entanto, se nosso aplicativo continuar crescendo, ele também se acumula à medida que cria um (pequeno) contexto de aplicativo por fatia.

4.6. Usando herança de classe

Usar uma única classeAbstractSpringIntegrationTest como pai de todos os nossos testes de integração é uma maneira simples, poderosa e pragmática de manter a construção rápida.

If we provide a solid setup, our team will simply extend it, knowing that everything ‘just works'. Dessa forma, podemos nos preocupar menos em gerenciar o estado ou configurar a estrutura e focar no problema em questão.

Poderíamos definir todos os requisitos de teste lá:

  • O corredor da Primavera - ou preferencialmente governa, caso precisemos de outros corredores mais tarde

  • perfis - idealmente, nosso sprofiletest agregado

  • configuração inicial - definindo o estado do nosso aplicativo

Vamos dar uma olhada em uma classe base simples que cuida dos pontos anteriores:

@SpringBootTest
@ActiveProfiles("test")
abstract class AbstractSpringIntegrationTest {

    @Rule
    @JvmField
    val springMethodRule = SpringMethodRule()

    companion object {
        @ClassRule
        @JvmField
        val SPRING_CLASS_RULE = SpringClassRule()
    }
}

4.7. Gerenciamento de Estado

É importante lembrar onde 'unidade' no Teste de Unidadecomes from. Simplificando, significa que podemos executar um único teste (ou um subconjunto) a qualquer momento, obtendo resultados consistentes.

Portanto, o estado deve estar limpo e conhecido antes do início de cada teste.

Em outras palavras, o resultado de um teste deve ser consistente, independentemente de ser executado isoladamente ou em conjunto com outros testes.

Essa ideia se aplica da mesma forma aos testes de integração. Precisamos garantir que nosso aplicativo tenha um estado conhecido (e repetível) antes de iniciar um novo teste. Quanto mais componentes reutilizarmos para acelerar as coisas (contexto do aplicativo, bancos de dados, filas, arquivos ...), mais chances de contaminar o estado.

Supondo que participássemos da herança de classe, agora temos um lugar central para gerenciar o estado.

Vamos aprimorar nossa classe abstrata para garantir que nosso aplicativo esteja em um estado conhecido antes de executar os testes.

Em nosso exemplo, vamos assumir que existem vários repositórios (de várias fontes de dados) e um servidorWiremock:

@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureWireMock(port = 8666)
@AutoConfigureMockMvc
abstract class AbstractSpringIntegrationTest {

    //... spring rules are configured here, skipped for clarity

    @Autowired
    protected lateinit var wireMockServer: WireMockServer

    @Autowired
    lateinit var jdbcTemplate: JdbcTemplate

    @Autowired
    lateinit var repos: Set>

    @Autowired
    lateinit var cacheManager: CacheManager

    @Before
    fun resetState() {
        cleanAllDatabases()
        cleanAllCaches()
        resetWiremockStatus()
    }

    fun cleanAllDatabases() {
        JdbcTestUtils.deleteFromTables(jdbcTemplate, "table1", "table2")
        jdbcTemplate.update("ALTER TABLE table1 ALTER COLUMN id RESTART WITH 1")
        repos.forEach { it.deleteAll() }
    }

    fun cleanAllCaches() {
        cacheManager.cacheNames
          .map { cacheManager.getCache(it) }
          .filterNotNull()
          .forEach { it.clear() }
    }

    fun resetWiremockStatus() {
        wireMockServer.resetAll()
        // set default requests if any
    }
}

4.8. Refatorando em testes de unidade

Este é provavelmente um dos pontos mais importantes. Vamos nos encontrar repetidamente com alguns testes de integração que estão, na verdade, exercendo alguma política de alto nível do nosso aplicativo.

Sempre que encontramos alguns testes de integração testando um monte de casos de lógica de negócios central, é hora de repensar nossa abordagem e dividi-los em testes de unidade.

Um padrão possível aqui para realizar isso com êxito pode ser:

  • Identifique testes de integração que estão testando vários cenários da lógica de negócios principal

  • Duplicar o conjunto e refatorar a cópia em testes de unidade - nesse estágio, talvez seja necessário quebrar o código de produção também para torná-lo testável

  • Obtenha todos os testes em verde

  • Deixe um exemplo de caminho feliz que seja notável o suficiente no conjunto de integração - talvez seja necessário refatorar ou ingressar e remodelar alguns

  • Remova os testes de integração restantes

Michael Feathers cobre muitas técnicas para conseguir isso e muito mais em Trabalhando efetivamente com o código legado.

5. Sumário

Neste artigo, tivemos uma introdução aos testes de integração com foco no Spring.

Primeiro, falamos sobre a importância dos testes de integração e por que eles são particularmente relevantes nos aplicativos Spring.

Depois disso, resumimos algumas ferramentas que podem ser úteis para certos tipos de testes de integração no Web Apps.

Por fim, analisamos uma lista de possíveis problemas que diminuem o tempo de execução do teste, além de truques para melhorá-lo.