Teste estático ou salve o soldado Ryan

Uma versão geralmente passa despercebida. E qualquer erro que de repente foi descoberto na sua frente nos ameaça com uma mudança de prazos, hotfixes, trabalho até a manhã seguinte e irritação. Quando essa pressa começou a ocorrer sistematicamente, percebemos que você não pode mais viver assim. Decidiu-se desenvolver um sistema de validação abrangente para salvar o desenvolvedor comum do Ryan Artyom, que voltava para casa antes do lançamento às 21h, às 10 ou às 11 ... bem, você entende. A idéia era que o desenvolvedor descobrisse o erro, enquanto as mudanças ainda não haviam chegado ao repositório e ele próprio não havia perdido o contexto da tarefa.


Hoje, as alterações feitas são cuidadosamente verificadas primeiro localmente e depois com uma série de testes de integração no farm de montagem. Neste artigo, falaremos sobre o primeiro estágio da verificação - teste estático, que monitora a correção dos recursos e analisa o código. Este é o primeiro subsistema da cadeia e é responsável pela maior parte dos erros encontrados.

Como tudo começou


O processo manual de verificação do jogo antes do lançamento começou no controle de qualidade uma semana e meia antes do lançamento. Naturalmente, os bugs que estão nesse estágio precisam ser corrigidos o mais rápido possível.

Devido à falta de tempo para uma boa solução, é adicionada uma "muleta" temporária, que se enraíza por um longo tempo e é cercada por outras soluções não muito populares.

Primeiro, decidimos automatizar a descoberta de erros evidentes: falhas, incapacidade de concluir o conjunto principal de ações do jogo (abrir uma loja, fazer uma compra, jogar um nível). Para fazer isso, o jogo começa em um modo especial de jogo automático e, se algo der errado, saberemos sobre isso logo após passar no teste em nossa fazenda.

Mas a maioria dos erros encontrados pelos testadores e pelo nosso teste automatizado de fumaça foi a falta de um recurso ou configurações incorretas de diferentes sistemas. Portanto, o próximo passo foi o teste estático - verificando a disponibilidade de recursos, seus relacionamentos e configurações sem iniciar o aplicativo. Esse sistema foi lançado por uma etapa adicional no farm de montagem e simplificou bastante a localização e a correção de erros. Mas por que desperdiçar os recursos do farm de montagem se você pode detectar um erro antes mesmo de confirmar e colocar o código do problema no repositório? Isso pode ser feito com ganchos de pré-confirmação , que são iniciados apenas antes da criação e envio do commit ao repositório.

E sim, somos tão legais que o teste estático antes de confirmar e no conjunto de montagem é realizado por um código, o que simplifica bastante o suporte.

Nossos esforços podem ser divididos em três áreas:

  • criação de uma fazenda de montagem - o próprio local onde tudo o que foi coletado e verificado será coletado;
  • desenvolvimento de testes estáticos - checando a correção dos recursos, seus relacionamentos, lançando analisadores de código;
  • desenvolvimento de testes em tempo de execução - iniciando o aplicativo no modo de reprodução automática.

Uma tarefa separada era organizar o lançamento de testes na máquina pelo desenvolvedor. Era necessário minimizar o tempo de execução localmente (o desenvolvedor não precisa esperar 10 minutos para confirmar uma linha) e garantir que todos os sistemas que fazem as alterações tenham nosso sistema instalado.

Muitos requisitos - um sistema


Durante o desenvolvimento, há todo um conjunto de montagens que podem ser úteis: com e sem truques, beta ou alfa, iOS ou Android. Em cada caso, podem ser necessários recursos, configurações ou mesmo códigos diferentes. Escrever scripts para testes estáticos para cada montagem possível resulta em um sistema complexo com muitos parâmetros. Além de ser difícil de manter e modificar, cada projeto também possui seu próprio conjunto de bicicletas para muletas.

Por tentativa e erro, chegamos a um sistema, em cada teste que pode levar em conta o contexto de inicialização e decidir se deve ou não executá-lo, o que exatamente e como verificar. No início dos testes, identificamos três propriedades principais:

  • tipo de montagem: para recursos de liberação e depuração, as verificações diferem em termos de rigor, integridade da cobertura, além de configurações para identificadores e verificação da funcionalidade disponível;
  • plataforma: o que é válido para o Android pode estar incorreto para o iOS, os recursos também são coletados de maneira diferente e nem todos os recursos da versão para Android estarão no iOS e vice-versa;
  • local de inicialização: onde exatamente estamos iniciando - no agente de compilação, onde todos os testes disponíveis são necessários ou no computador do usuário, onde a lista de inicializações precisa ser minimizada.


Sistema de teste estático


O núcleo do sistema e o conjunto básico de testes estáticos são implementados em python. A base são apenas algumas entidades:


O contexto de teste é um conceito extenso. Ele armazena os parâmetros de compilação e inicialização, sobre os quais falamos acima, bem como as meta-informações que os testes preenchem e usam.

Primeiro, você precisa entender quais testes executar. Para isso, as meta-informações contêm os tipos de recursos nos quais estamos interessados ​​especificamente neste lançamento. Os tipos de recursos são determinados por testes registrados no sistema. Um teste pode ser "associado" a um único tipo ou vários, e se no momento da confirmação for constatado que os arquivos que esse teste verifica foram alterados, o recurso associado foi alterado. Isso se encaixa convenientemente em nossa ideologia - executar localmente o menor número possível de verificações: se os arquivos pelos quais o teste é responsável não foram alterados, não é necessário executá-lo.

Por exemplo, há uma descrição do peixe, na qual o modelo 3D e a textura são indicados.Se o arquivo de descrição foi alterado, é verificado se o modelo e a textura indicados nele existem. Em outros casos, não há necessidade de executar uma verificação de peixes.

Por outro lado, alterar um recurso pode exigir alterações e entidades, dependendo dele: se o conjunto de texturas que armazenamos em arquivos xml mudou, é necessário verificar modelos 3D adicionais, pois pode ser que a textura necessária seja removida. As otimizações descritas acima são aplicadas apenas localmente na máquina do usuário no momento da confirmação e, quando iniciadas no farm de montagem, supõe-se que todos os arquivos foram alterados e executamos todos os testes.

O próximo problema é a dependência de alguns testes de outros: é impossível verificar o peixe antes de encontrar todas as texturas e modelos. Portanto, dividimos toda a execução em dois estágios:

  • preparação do contexto
  • verificação

Na primeira etapa, o contexto é preenchido com informações sobre os recursos encontrados (no caso dos peixes, com identificadores de padrões e texturas). No segundo estágio, usando as informações armazenadas, basta verificar se o recurso desejado existe. Um contexto simplificado é apresentado abaixo.

class VerificationContext(object):   def __init__(self, app_path, build_type, platform, changed_files=None):       self.__app_path = app_path       self.__build_type = build_type       self.__platform = platform       #          self.__modified_resources = set()       self.__expected_resources = set()       #      ,              self.__changed_files = changed_files       # -  ,          self.__resources = {} def expect_resources(self, resources):   self.__expected_resources.update(resources) def is_resource_expected(self, resource):   return resource in self.__expected_resources def register_resource(self, resource_type, resource_id, resource_data=None):   self.__resources.setdefault(resource_type, {})[resource_id] = resource_data def get_resource(self, resource_type, resource_id):   if resource_type not in self.__resources or resource_id not in self.__resources[resource_type]:       return None, None   return resource_id, self.__resources[resource_type][resource_id] 

Tendo determinado todos os parâmetros que afetam o início do teste, conseguimos ocultar toda a lógica dentro da classe base. Em um teste específico, resta escrever apenas o próprio teste e os valores necessários para os parâmetros.

 class TestCase(object):  def __init__(self, name, context, build_types=None, platforms=None, predicate=None,               expected_resources=None, modified_resources=None):      self.__name = name      self.__context = context      self.__build_types = build_types      self.__platforms = platforms      self.__predicate = predicate      self.__expected_resources = expected_resources      self.__modified_resources = modified_resources      #               #   ,          self.__need_run = self.__check_run()      self.__need_resource_run = False  @property  def context(self):      return self.__context  def fail(self, message):      print('Fail: {}'.format(message))  def __check_run(self):      build_success = self.__build_types is None or self.__context.build_type in self.__build_types      platform_success = self.__platforms is None or self.__context.platform in self.__platforms      hook_success = build_success      if build_success and self.__context.is_build('hook') and self.__predicate:          hook_success = any(self.__predicate(changed_file) for changed_file in self.__context.changed_files)      return build_success and platform_success and hook_success  def __set_context_resources(self):      if not self.__need_run:          return      if self.__modified_resources:          self.__context.modify_resources(self.__modified_resources)      if self.__expected_resources:          self.__context.expect_resources(self.__expected_resources)   def init(self):      """        ,                    ,          """      self.__need_resource_run = self.__modified_resources and any(self.__context.is_resource_expected(resource) for resource in self.__modified_resources)  def _prepare_impl(self):      pass  def prepare(self):      if not self.__need_run and not self.__need_resource_run:          return      self._prepare_impl()  def _run_impl(self):      pass  def run(self):      if self.__need_run:          self._run_impl() 

Voltando ao exemplo do peixe, precisamos de dois testes, um dos quais encontra texturas e as registra em contexto, o outro procura texturas para os modelos encontrados.

 class VerifyTexture(TestCase):  def __init__(self, context):      super(VerifyTexture, self).__init__('VerifyTexture', context,                                          build_types=['production', 'hook'],                                          platforms=['windows', 'ios'],                                          expected_resources=None,                                          modified_resources=['Texture'],                                          predicate=lambda file_path: os.path.splitext(file_path)[1] == '.png')  def _prepare_impl(self):      texture_dir = os.path.join(self.context.app_path, 'resources', 'textures')      for root, dirs, files in os.walk(texture_dir):          for tex_file in files:              self.context.register_resource('Texture', tex_file) class VerifyModels(TestCase):  def __init__(self, context):      super(VerifyModels, self).__init__('VerifyModels', context,                                         expected_resources=['Texture'],                                         predicate=lambda file_path: os.path.splitext(file_path)[1] == '.obj')  def _run_impl(self):      models_descriptions = etree.parse(os.path.join(self.context.app_path, 'resources', 'models.xml'))      for model_xml in models_descriptions.findall('.//Model'):          texture_id = model_xml.get('texture')          texture = self.context.get_resource('Texture', texture_id)          if texture is None:              self.fail('Texture for model {} was not found: {}'.format(model_xml.get('id'), texture_id)) 

Propagação do Projeto


O desenvolvimento de jogos no Playrix ocorre em seu próprio mecanismo e, portanto, todos os projetos têm uma estrutura de arquivo e código similares, usando as mesmas regras. Portanto, existem muitos testes gerais que são escritos uma vez e estão no código geral. É suficiente para os projetos atualizarem a versão do sistema de teste e conectar um novo teste a eles mesmos.

Para simplificar a integração, escrevemos um corredor que recebe o arquivo de configuração e os testes de design (sobre eles posteriormente). O arquivo de configuração contém informações básicas sobre as quais escrevemos acima: tipo de montagem, plataforma, caminho para o projeto.

 class Runner(object):  def __init__(self, config_str, setup_function):      self.__tests = []      config_parser = RawConfigParser()      config_parser.read_string(config_str)      app_path = config_parser.get('main', 'app_path')      build_type = config_parser.get('main', 'build_type')      platform = config_parser.get('main', 'platform')      '''      get_changed_files         CVS      '''      changed_files = None if build_type != 'hook' else get_changed_files()      self.__context = VerificationContext(app_path, build_type, platform, changed_files)      setup_function(self)  @property  def context(self):      return self.__context  def add_test(self, test):      self.__tests.append(test)  def run(self):      for test in self.__tests:          test.init()      for test in self.__tests:          test.prepare()      for test in self.__tests:          test.run() 

A beleza do arquivo de configuração é que ele pode ser gerado no farm de assemblies para diferentes assemblies no modo automático. Mas passar as configurações para todos os testes nesse arquivo pode não ser muito conveniente. Para fazer isso, há um xml de configuração especial que é armazenado no repositório do projeto e as listas de arquivos ignorados, máscaras para pesquisa no código e assim por diante são gravadas nele.

Exemplo de arquivo de configuração
 [main] app_path = {app_path} build_type = production platform = ios 

Exemplo de ajuste de xml
 <root> <VerifySourceCodepage allow_utf8="true" allow_utf8Bom="false" autofix_path="ci/autofix"> <IgnoreFiles>*android/tmp/*</IgnoreFiles> </VerifySourceCodepage> <VerifyCodeStructures> <Checker name="NsStringConversion" /> <Checker name="LogConstructions" /> </VerifyCodeStructures> </root> 

Além da parte geral, os projetos têm peculiaridades e diferenças próprias; portanto, existem conjuntos de testes de projeto que são conectados ao sistema através da configuração do corredor. Para o código nos exemplos, algumas linhas serão suficientes para executar:

 def setup(runner):  runner.add_test(VerifyTexture(runner.context))  runner.add_test(VerifyModels(runner.context)) def run():  raw_config = '''  [main]  app_path = {app_path}  build_type = production  platform = ios  '''  runner = Runner(raw_config, setup)  runner.run() 

Ancinho coletado


Embora o próprio python seja multiplataforma, tivemos problemas regularmente com o fato de os usuários terem seu próprio ambiente exclusivo, no qual eles podem não ter a versão que esperamos, várias versões ou nenhum intérprete. Como resultado, ele não funciona como esperamos ou nem funciona. Havia várias iterações para resolver esse problema:

  1. Python e todos os pacotes são instalados pelo usuário. Mas existem dois "buts": nem todos os usuários são programadores e a instalação via pip install para designers, e também para programadores, pode ser um problema.
  2. Há um script que instala todos os pacotes necessários. Isso já é melhor, mas se o usuário tiver o python errado instalado, poderão ocorrer conflitos no trabalho.
  3. Fornecendo a versão correta do interpretador e dependências do armazenamento de artefato (Nexus) e executando testes em um ambiente virtual.

Outra questão é o desempenho. Quanto mais testes, mais longa a alteração é verificada no computador do usuário. A cada poucos meses, há um perfil e otimização de gargalos. Portanto, o contexto foi aprimorado, um cache para arquivos de texto apareceu e os mecanismos de predicado foram aprimorados (determinando que esse arquivo é interessante para o teste).

E resta apenas resolver o problema de como implementar o sistema em todos os projetos e forçar todos os desenvolvedores a incluir ganchos usados, mas essa é uma história completamente diferente ...

Conclusão


Durante o processo de desenvolvimento, dançamos no rake, lutamos muito, mas ainda temos um sistema que nos permite encontrar erros durante a confirmação, reduzimos o trabalho dos testadores e as tarefas antes do lançamento sobre a textura que faltava eram coisa do passado. Para uma felicidade completa, uma configuração simples do ambiente e a otimização de testes individuais não são suficientes, mas os golens do departamento de ci trabalham duro nisso.

Um exemplo completo do código usado como exemplos no artigo pode ser encontrado em nosso repositório .

Source: https://habr.com/ru/post/pt452926/


All Articles