Internos do IL2CPP: Estruturas de teste

A equipe da IL2CPP tem uma forte mentalidade de desenvolvimento que prioriza os testes. Grande parte do código da IL2CPP é escrito usando a prática de desenvolvimento orientado por testes (TDD), e pouquíssimas solicitações de pull são mescladas ao código da IL2CPP sem uma cobertura de teste significativa.
Como o IL2CPP tem um conjunto finito (embora bastante grande) de entradas - a especificação ECMA 335 -, o processo de desenvolvimento se encaixa perfeitamente nos conceitos de TDD. A maioria dos testes é escrita antes do código de produção, e esses testes sempre precisam falhar de uma forma esperada antes que o código para fazê-los passar seja escrito.
Esse processo ajuda a orientar o projeto do IL2CPP, mas também fornece à equipe de desenvolvimento um grande banco de testes que são executados rapidamente e exercitam quase todo o comportamento existente no IL2CPP. Como equipe de desenvolvimento, esse conjunto de testes oferece dois benefícios importantes.
1) Confiança: A maioria das alterações para refatorar o código no IL2CPP pode ser feita com alta confiança. Se os testes forem aprovados, é muito improvável que uma regressão tenha sido introduzida.
2) Solução de problemas: Como o código do IL2CPP se comporta como esperamos, os bugs são quase sempre seções não implementadas do código ou casos que ainda não consideramos. Ao reduzir o espaço das possíveis causas de um determinado bug dessa forma, podemos corrigi-los muito mais rapidamente.
Os vários tipos de testes que executamos na base de código do IL2CPP se dividem em alguns níveis diferentes. Aqui está o número de testes que temos atualmente em cada nível (discutirei o que cada tipo de teste realmente é abaixo).
- Testes unitários
- C#: 472
- C++: 44
- Testes de integração
- C#: 1735
- IL: 173
Se todos esses testes estiverem corretos, estaremos confiantes de que poderemos enviar o IL2CPP naquele momento. Mantemos uma ramificação de desenvolvimento principal para o IL2CPP, que sempre acompanha a ramificação de ponta para o desenvolvimento no Unity como um todo. Os testes são sempre verdes nesse ramo principal de desenvolvimento. Quando eles quebram (o que acontece de vez em quando), geralmente alguém os conserta em poucos minutos.
Como os desenvolvedores da nossa equipe estão bifurcando esse ramo principal para desenvolvimento pessoal com frequência, ele precisa estar sempre verde. O status de compilação e teste para a ramificação principal de desenvolvimento e as ramificações pessoais são mantidos no Katana, o sistema interno de gerenciamento de compilação do Unity.
Usamos o NUnit para executar todos esses testes e acionamos o NUnit de três maneiras diferentes
- Windows: ReSharper
- OSX: Xamarin Studio
- Linha de comando no Windows e OSX em nossas máquinas de construção: um script Perl personalizado
Tipos de testes
Mencionei quatro tipos diferentes de testes acima sem muita explicação. Cada um desses tipos de testes tem uma finalidade diferente, e todos trabalham juntos para ajudar a manter o desenvolvimento do IL2CPP em andamento.
Os testes de unidade verificam o comportamento de uma pequena parte do código, normalmente um método. Eles configuram uma situação, executam o código em teste e, por fim, afirmam algum comportamento esperado.
Na verdade, os testes de integração do IL2CPP executam o utilitário il2cpp.exe em um conjunto, compilam o código C++ gerado em um executável e, em seguida, executam o executável. Como temos uma boa referência para o comportamento do IL2CPP (a versão existente do Mono usada no Unity), esses testes de integração também executam o mesmo assembly com o Mono (e o .Net, no Windows). Nosso executor de testes compara os resultados das duas (ou três) execuções despejadas na saída padrão e relata quaisquer diferenças. Portanto, os testes de integração do IL2CPP não têm valores esperados explícitos ou afirmações listadas no código de teste, como fazem os testes de unidade.
Testes de unidade C#
Esses testes são os mais rápidos e de nível mais baixo que escrevemos. Eles são usados para verificar o comportamento de muitas partes do il2cpp.exe, o utilitário do compilador AOT para IL2CPP. Como o il2cpp.exe foi escrito inteiramente em C#, podemos usar testes rápidos de unidade do C# para obter um bom tempo de resposta para as alterações. Todos os testes de unidade C# são concluídos em poucos segundos em uma boa máquina de desenvolvimento.
Testes de unidade C++
A grande maioria do código de tempo de execução do IL2CPP (chamado libil2cpp) é escrita em C++. Para partes desse código que não são facilmente acessíveis a partir de uma API pública, usamos testes de unidade C++. Temos relativamente poucos desses testes, pois a maior parte do comportamento do código no libil2cpp pode ser exercitada por meio do nosso conjunto de testes de integração maior. Esses testes exigem mais tempo do que o esperado para a execução de testes de unidade, pois eles precisam executar o próprio il2cpp.exe para configurar os dados de fixação.
Testes de integração C#
O maior e mais abrangente conjunto de testes do IL2CPP é o conjunto de testes de integração do C#. Esses testes são divididos em segmentos menores, com foco em testes que verificam o comportamento de icalls, geração de código, p/invoke e comportamento geral. A maioria dos testes desse conjunto é bastante curta, com apenas cerca de 5 a 10 linhas. Todo o conjunto é executado em menos de um minuto na maioria dos computadores, mas podemos executá-lo com várias opções do IL2CPP relacionadas a itens como remoção e geração de código.
Testes de integração de IL
Esses testes são semelhantes em termos de cadeia de ferramentas aos testes de integração do C#. No entanto, em vez de escrever o código de teste em C#, usamos a classe ILGenerator para criar diretamente um assembly. Embora esses testes possam levar um pouco mais de tempo para serem escritos do que os testes em C#, eles oferecem maior flexibilidade. Frequentemente, encontramos problemas com código IL inválido ou não gerado pelo nosso compilador Mono C# atual. Nesses casos, geralmente podemos escrever um bom caso de teste com código IL. Os testes também são úteis para testes abrangentes de códigos operacionais como conv.i (e códigos operacionais semelhantes em sua família), que têm um comportamento claro com muitas pequenas variações. Todos os testes de IL são concluídos de ponta a ponta em menos de um minuto.
Executamos todos esses testes em diversas variações e opções no Katana. De uma extração limpa do código-fonte a execuções de teste concluídas, vemos cerca de 20 a 30 minutos de tempo de execução, dependendo da carga no farm de compilação.
Com base nessas descrições, pode parecer que a nossa pirâmide de testes para o IL2CPP está de cabeça para baixo. E, de fato, os testes de integração de ponta a ponta (próximos ao topo da pirâmide) constituem a maior parte da nossa cobertura de testes.
Seguir a prática de TDD com tempos de teste superiores a alguns segundos também pode ser difícil. Trabalhamos para mitigar isso permitindo que segmentos individuais dos conjuntos de testes de integração sejam executados e fazendo a construção incremental do código C++ gerado nos conjuntos de testes (é assim que estamos provando algumas possibilidades de construção incremental para projetos Unity com IL2CPP, portanto, fique atento). Então, o tempo de resposta para um teste individual é razoável (embora ainda não seja tão rápido quanto gostaríamos).
No entanto, esse uso intenso de testes de integração foi uma decisão consciente. Grande parte do código no IL2CPP parece diferente do que costumava ser, mesmo em nossos lançamentos públicos iniciais em janeiro de 2015. Aprendemos muito e alteramos muitos dos detalhes de implementação na base de código do IL2CPP desde seu início, mas ainda temos muitos dos testes originais escritos anos atrás. Depois de experimentar testes em vários níveis diferentes (incluindo até mesmo a validação do conteúdo do código-fonte C++ gerado), decidimos que esses testes de integração nos proporcionam a melhor relação entre tempo de execução e estabilidade do teste. Raramente, ou nunca, precisamos modificar um dos testes de integração existentes quando algo muda no código do IL2CPP. Esse fato nos dá uma enorme confiança de que uma alteração no código que faz com que um teste falhe é realmente um problema. Ele também nos permite refatorar e aprimorar o código do IL2CPP o quanto for necessário, sem medo.
Além do IL2CPP em si, o código do IL2CPP se encaixa no ecossistema de testes do Unity, que é muito maior. Para cada plataforma que enviamos com suporte ao IL2CPP, executamos os testes de tempo de execução do Unity Player. Esses testes criam um único projeto Unity com mais de 1.000 cenas, executam cada cena e validam o comportamento esperado por meio de asserções. Normalmente, não adicionamos novos testes a esse conjunto para alterações no IL2CPP (esses testes geralmente acabam ficando em um nível inferior). Esse conjunto serve como uma verificação contra regressões que podemos introduzir com o IL2CPP em uma determinada plataforma. Esse conjunto também nos permite testar o código usado na integração do IL2CPP à cadeia de ferramentas de compilação do Unity, que, mais uma vez, varia de acordo com cada plataforma. Um conjunto típico de testes em tempo de execução é concluído em cerca de 60 a 90 minutos, embora muitas vezes executemos testes individuais localmente com muito mais rapidez.
Os testes maiores e mais lentos que usamos para o IL2CPP são os testes de integração do editor Unity. Na verdade, cada um desses testes executa uma instância diferente do editor do Unity. A maioria dos testes de integração do editor IL2CPP concentra-se na criação e execução de um projeto, geralmente com várias configurações de criação do editor. Usamos esses testes para verificar aspectos como integração de editores complexos, relatórios de mensagens de erro e tamanho da compilação do projeto (entre muitos outros). Dependendo da plataforma, os conjuntos de testes de integração são executados em algumas horas e, geralmente, são executados pelo menos à noite, se não com mais frequência.
Na Unity, um de nossos princípios orientadores é "resolver problemas difíceis". Gosto de pensar na dificuldade dos problemas em termos de fracasso. Quanto mais difícil for a solução de um problema, mais falhas precisarei realizar antes de encontrar a solução.
Criar um novo compilador AOT de alto desempenho e altamente portátil e uma máquina virtual para usar como back-end de script no Unity é um problema difícil. Não é preciso dizer que tivemos milhares de fracassos ao longo do caminho. Há mais problemas a serem resolvidos e, portanto, mais fracassos estão por vir. Mas, ao capturar as informações úteis de quase todas essas falhas em um conjunto de testes abrangente e rápido, podemos iterar muito rapidamente.
Para os desenvolvedores do IL2CPP, nosso conjunto de testes não é tanto um meio de verificar se o código está livre de bugs (embora ele detecte bugs) ou de ajudar a portar o IL2CPP para várias plataformas (ele também faz isso), mas é uma ferramenta que podemos usar para falhar rapidamente e resolver problemas difíceis para que nossos usuários possam se concentrar na criação de coisas bonitas.
Esperamos que você tenha gostado da série de postagens IL2CPP Internals. Teremos prazer em compartilhar detalhes de implementação e fornecer dicas de depuração e desempenho quando possível. Se quiser saber mais sobre outros tópicos relacionados ao design e à implementação do IL2CPP, entre em contato conosco.