Teste eficaz com padrão de empréstimo no Scala

Os testes são cruciais em sistemas que dependem de CI / CD como parte de seu ciclo de lançamento. Um dos desafios é escrever testes estáveis ​​que funcionem para você sem gastar muito tempo mantendo testes ruins.

Os testes são difíceis

Eles são difíceis de escrever, de manter e é ainda mais difícil estabilizar um teste superficial. No Avance Network, temos um orgulho especial em nossa capacidade (na maior parte) de fornecer novos recursos à produção e fazê-lo com a confiança que somente testes confiáveis ​​podem oferecer. Esses testes desempenham um papel crucial em nossa capacidade de fornecer código rápido, bom e estável, garantindo que nenhum erro de regressão seja introduzido no processo. É crucial, portanto, não apenas manter bons conjuntos de testes (testes de unidade, integração e e2e), mas também corrigir qualquer teste que se comporte incorretamente (testes inadequados).

Temos um ambiente especial para facilitar a integração e os testes e2e chamados ambiente de simulação (é apenas um dos conjuntos de ferramentas que temos para esse fim). Este é um conjunto dedicado de servidores que usamos para simular nosso ambiente de produção. Implantamos todas as novas versões de nossos serviços nesse ambiente antes de implantarmos em produção e executamos testes que verificam novos fluxos de código, regressão e interoperabilidade para outros serviços.

Para escrever um teste eficaz para um novo recurso, às vezes precisamos configurar o ambiente com entidades necessárias para o recurso que estamos testando. Se, por exemplo, nosso novo recurso é registrar um carro em um proprietário (uma entidade Pessoa). Antes de executar os testes, precisamos das entidades necessárias, um carro e uma pessoa em nosso banco de dados. Não estamos tentando testar um fluxo para criar um carro novo ou uma nova pessoa nesse cenário. Portanto, não há necessidade de criar explicitamente o carro e / ou as entidades da pessoa no teste antes que o cenário de teste real ocorra. E para tornar nossos testes o mais claro e sucinto possível - não queremos criar esses dados explicitamente em todos os testes.

Más Práticas

Portanto, era uma prática comum (embora ruim) ter dados preexistentes nos quais confiaríamos para executar testes (para todo o ambiente de simulação!). Isso levou a dois grandes problemas (interconectados):

  1. Sem isolamento de teste - um teste que exclui por engano alguns ou todos os dados preexistentes, por exemplo, faria isso para todos os testes executados nesse ambiente
  2. Testes esquisitos - testes executados simultaneamente estão criando, excluindo e geralmente alterando dados que afetam outras pessoas, o que, por sua vez, falharia nos testes sem uma boa razão - o que torna muito difícil analisar e corrigir um teste com falha

Resolvemos esse problema criando os dados necessários antes dos testes em uma classe de teste e excluindo-os após a execução do teste. O que mitigou um pouco o problema - não apenas os testes da mesma classe foram interconectados, mas também adicionaram clichê à classe de teste. Agora, uma classe de teste era algo parecido (assumindo que sejam entidades geradas automaticamente pelo Scalike para as tabelas relevantes):

ScalaTest:

 classe MyTestClass estende o WordSpec com BeforeAndAfterAll {
 val person = Person (carId = None ) .save () // criando dados para teste
 val car = Car (cor = " verde " , ownerId = None ) .save ()
  
 substituir definição afterAll () : Unit = {
 // Limpe o banco de dados após o teste.
 person.destroy ()
 car.destroy ()
 }
  
 " Meu serviço " deve {
 " defina o proprietário do carro " em {
 val serviço = novo serviço
 service.setCarOwner (carId = car.id, ownerId = person.id)
 service.getPerson (person.id) .carId shouldEqual Some (car.id)
 }
 }
 }
 

Especificações2:

 classe MyTestClass estende SpecificationWithJUnit com AfterAll {
 val person = Person (carId = None ) .save () // criando dados para teste
 val car = Car (cor = " verde " , ownerId = None ) .save ()
  
 substituir definição afterAll () : Unit = {
 // Limpe o banco de dados após o teste.
 person.destroy ()
 car.destroy ()
 }
  
 " Meu serviço " deve {
 " defina o proprietário do carro " em {
 val serviço = novo serviço
  
 service.setCarOwner (carId = car.id, ownerId = person.id)
  
 service.getPerson (person.id) .carId deve ser igual a alguns (car.id)
 }
 }
 }
 
Olhando para isso, fomos apresentados a um desafio. Primeiro, os dados são criados para todos os testes executados em uma classe, que devem ser excluídos somente após a conclusão de todos os testes - isso significa que os testes não são isolados um do outro e podem se tornar escassos. Segundo, queríamos uma maneira elegante de criar e excluir as entidades necessárias sem problemas, a fim de minimizar o padrão para cada classe de teste.

Nota: No entanto, no Specs2, é possível obter uma solução melhor usando a característica 'Scope' da seguinte forma:

 O contexto de característica estende o escopo com Depois {
 val person = Person (carId = None ) .save () // criando dados para teste
 val car = Car (cor = " verde " , ownerId = None ) .save ()
  
 substituir definição após : Qualquer = {
 // Limpe o banco de dados após o teste.
 person.destroy ()
 car.destroy ()
 }
 }
 
E usá-lo em um teste como este:
 
 classe MyTestClass estende SpecificationWithJUnit {
 " Meu serviço " deve {
 " defina o proprietário do carro " no novo contexto {
 val serviço = novo serviço
  
 service.setCarOwner (carId = car.id, ownerId = person.id)
  
 service.getPerson (person.id) .carId deve ser igual a alguns (car.id)
 }
 }
 }
 
É uma boa solução, para um problema mais simples do que enfrentamos. Precisávamos dos testes executados em uma única transação, com uma sessão fornecida e um nome de banco de dados configurável (indicando um conjunto de parâmetros de conexão do Scalike).

Inserir padrão de empréstimo

Encontramos esse padrão pela primeira vez ao usar o ScalaTest e passamos rapidamente a usá-lo também no Specs2 (como a maioria dos nossos testes está escrita no Specs2). Na documentação do ScalaTest para compartilhar dispositivos elétricos:

“Um acessório de teste é composto pelos objetos e outros artefatos (arquivos, soquetes, conexões com o banco de dados, etc.) que os testes usam para realizar seu trabalho. Quando vários testes precisam trabalhar com os mesmos equipamentos, é importante tentar evitar a duplicação do código do equipamento nesses testes. ”
“Se você precisar passar um objeto de fixação em um teste e executar a limpeza no final do teste, precisará usar o padrão de empréstimo”

O que significa que podemos usar acessórios para configurar 'artefatos' para os testes, promovendo o princípio DRY, minimizando a duplicação de código. Também é uma boa maneira de reduzir o clichê ao escrever testes. Então, nós escrevemos essa característica simples:

ScalaTest:

 A característica TestDataSupport estende o DefaultGenerator {
  
 def testContext ( testCode : DefaultObjects = Qualquer ) : Unidade = {
 val testData = createTestData ()
 tente {
 testCode (testData) // "empresta" o dispositivo elétrico ao teste
 }
 finalmente clearTestData (testData) // limpa o aparelho
 }
  
 private def createTestData () : DefaultObjects = ???
  
 definição privada clearTestData ( testData : DefaultObjects ) : Unit = ???
 }
 
Vamos examinar o que está acontecendo nessa característica. Estamos misturando uma característica personalizada chamada 'DefaultGenerator', que nos fornece os 'DefaultObjects', que são as entidades que precisamos ser pré-criadas para que nossos testes sejam executados. Temos dois métodos particulares. Um que chama 'create' em 'DefaultObjects' com um nome personalizado para gerar as entidades necessárias. As outras chamam 'limpeza' nos dados de teste para limpar o ambiente após a conclusão da execução do teste. E a estrela dessa característica, o método (ou acessório, se desejar) 'withTestData', que obtém a função de teste como parâmetro, chama o método privado 'createTestData', chama o teste e passa os dados que acabamos de gerar e finalmente limpa os dados gerados após a conclusão do teste.

Ao misturar essa característica em nossa classe de teste, obtemos o seguinte código:

 classe MyTestClass estende o WordSpec com TestDataSupport {
 " Meu serviço " deve {
 " defina o proprietário do carro " em testContext {testData =
 val serviço = novo serviço
 service.setCarOwner (carId = testData.car.id, ownerId = testData.person.id)
 service.getPerson (testData.person.id) .carId shouldEqual Some (testData.car.id)
 }
 }
 }
 
'testData' são os dados gerados no nosso método 'withTestData' (um carro e uma pessoa no nosso caso).

A versão Specs2 do Loan Pattern é um pouco mais complexa, pois adicionamos mais alguns sinos e assobios para facilitar a criação dessas entidades em nosso domínio. Estamos usando o Scalike para criar as entidades no banco de dados MySQL, e precisamos de um controle um pouco mais refinado sobre a sessão que estamos usando, nome do banco de dados etc '.

Especificações2:

 característica DataContextName {
 def className : String
 }

 

 característica DataContextDbName {
 val dbName : Symbol = 'padrão
 }

 

 import scalikejdbc.{DBSession, NamedDB}
  
 objeto de pacote testdata {
 classe implícita privada [testdata] NamedDbSession ( namedDB : NamedDB ) {
 def withSession [ A ] ( sessão : DBSession ) ( execução : DBSession = A ) : A =
 execução (sessão)
 }
 }

 

 org de importação . specs2 . executar . { AsResult , Result }
 org de importação . specs2 . especificação . Para cada
 import scalikejdbc . { DB , DBSession , NamedDB }
  
 import scala.util.Try
  
 característica DefaultDataContext estende ForEach [ DefaultObjects ]
 com DataContextDbName com DataContextName com DefaultGenerator {
  
 implícita preguiçoso val sessão : DBSession = DB .autoCommitSession ()
  
 substituir def foreach [ R ] ( f : ( DefaultObjects ) = R ) ( evidência implícita $ 3 : AsResult [ R ]) : Resultado =
 NamedDB (dbName) .withSession (session) { sessão implícita : DBSession =
  
 val testData = DefaultObjects&l

Strong

5178 Blog indlæg

Kommentarer