XCTest em execução

Do Command + U ao sucesso!

Matheus de Vasconcelos
10 min readSep 8, 2020

Teste

Na área da computação diversos desenvolvedores buscam escrever o melhor código possível. Para chegar no estado da arte de software, diversos conceitos são comumente aplicados, como por exemplo princípios SOLID, padrões de projetos e outras boas práticas.

Mas, afinal, como garantir que mesmo após usar tantos conceitos o produto entregue é de fato bom?
Para responder essa pergunta, precisamos saber como é possível garantir qualidade na engenharia de software.

Uma forma comum e segura para garantir a qualidade do código é utilizar o recurso de testes automatizados.

O teste de software geralmente é a última etapa na construção de um programa, visando checar o seu nível de qualidade — exceto quando essa prática é o TDD, quando os testes são construídos antes da implementação final.

XCTest

O XCTest é um framework criado pela Apple para viabilizar a criação de testes automatizados. O framework pode ser utilizado para escrita tanto de testes unitários quanto de testes de interface e possui suporte para projetos iOS, iPadOS, macOS, tvOS e watchOS.

Create and run unit tests, performance tests, and UI tests for your Xcode project. — Apple documentation.

XCTestCase

Para criarmos os testes dentro do XCTest, utiliza-se o XCTestCase. Para isso, basta criar uma classe que herde dele como no exemplo a seguir.

Anatomia de um teste em XCTest

O código de exemplo está dividido em seis partes e cada uma delas possui uma função dentro da execução de um teste.

1. Imports

Para criar um teste precisamos importar o framework XCTest e o target que queremos testar. Para que seja possível testar o target é preciso colocar o atributo @testable ao import.
O @testable faz com que os escopos do módulo importado sejam promovidos. Assim, estruturas marcadas como public se tornam open e estruturas internal se tornam public. Desta forma, se torna possível validar as estruturas referentes ao módulo.

2. Declaração

Como já dito, para criar um teste é preciso criar uma classe que herde de XCTestCase. O nome dessa classe, desde que siga os requisitos do Swift, pode seguir qualquer padrão.

3. setUp

O XCTestCase disponibiliza três métodos para configurar seu teste. Estes métodos possuem o nome de setUp.

  • class setUp: Este setUp global tem como objetivo realizar as configurações que são comuns a todos os testes do seu respectivo TestCase. Para isso, durante o ciclo de vida de execução do TestCase ele será chamado uma vez antes de executar os testes.
  • setUp: Este setUp tem como objetivo realizar as configurações que devem ser reconfiguradas para cada teste. Para isso, durante o ciclo de vida de execução do TestCase ele será chamado uma vez antes de executar cada um dos testes.
  • setUpWithError: Este setUp tem como objetivo realizar as configurações que devem ser reconfiguradas para cada teste e que podem falhar, por isso ele pode lançar um erro. O erro lançado fará com que o TestCase falhe. Para isso, durante o ciclo de vida de execução do TestCase ele será chamado uma vez antes de executar cada um dos testes.

4. tearDown

O XCTestCase disponibiliza três métodos para desconfigurar seu teste. Estes métodos possuem o nome de tearDown e servem como um par para cada setUp.

  • class tearDown: Este tearDown global tem como objetivo desfazer as configurções feitas no setUp global. Para isso, durante o ciclo de vida de execução do TestCase ele será chamado uma vez depois de executar os testes.
  • tearDown: Este tearDown tem como objetivo desfazer as configurções feitas no seu respectivo setUp. Para isso, durante o ciclo de vida de execução do TestCase ele será chamado uma vez depois de executar cada um dos testes.
  • tearDownWithError: Este tearDown tem como objetivo desfazer as configurções feitas no respectivo setUp e que podem falhar, por isso ele pode lançar um erro. O erro lançado fará com que o TestCase falhe. Para isso, durante o ciclo de vida de execução do TestCase ele será chamado uma vez depois de executar cada um dos testes.

Super

É importante notar que cada um dos métodos de configuração e desconfiguração são sobreescritas — override — da classe XCTestCase.
Por padrão, é importante sempre chamar a super implementação para que se evite comportamentos estranhos, o que também se aplica para o XCTestCase.

No entanto, é importante lembrar que os métodos de setUp e tearDown implementados no XCTestCase são template methods, o que significa que eles não fazem nada. Ainda sim, é uma boa prática manter suas chamadas, pois caso escolha-se mudar para alguma outra implementação de TestCase — o framework de modo geral indica que a criação de base test cases é uma boa prática — não haverá comportamentos estranhos.

5. Test

Para criar um teste de fato basta criar uma função que tenha seu nome começado com a palavra test. O que vem a seguir, desde que respeite as regras de nomenclatura do Swift, não faz diferença.

6. Asserção

Uma das etapas fundamentais de um teste é a asserção (etapa de verificação do que é esperado). No framework do XCTest é possível realizar as asserções com os XCTAssert. Existem diversos asserts e cada um para uma situação, a escolha de um adequado diz muito sobre o que teste se propõe a validar.

Executando

Como demonstrado, para criar um teste utiliza-se o XCTestCase. Mas, para saber se ele funciona é preciso executá-los. Para isso, basta apertar as teclas Command + U no ambiente do Xcode. Esse comando irá construir a aplicação e executar os testes. Ao fim da execução, mostrará o resultado dos testes que passaram e dos que falharam.

Mas, afinal, como o Xcode sabe quais testes executar e como executá-los?

Para entender melhor como o Xcode executa os testes é preciso entender o log resultante da execução. No playground a visualização é mais clara e objetiva.

No exemplo a seguir há um teste e ele será executado no playground:

O exemplo possui uma classe que será testada — sobre o nome de sut(system under test) — que possui dois métodos e cada um deles irá realizar um print.
O TestCase criado tem duas funções de teste, uma para cada método.

Para executar o TestCase é preciso acessar a propriedade defaultTestSuite — propriedade responsável por guardar as informações de todos os testes do seu respectivo TestCase — e nela utilizar o método run().

O log da execução deste playground é o seguinte:

Test Suite 'SomeTestCase' started at 2020-09-07 11:11:28.922
Test Case
'-[SomeTestCase testMetho1]' started.
>>>> METHOD 1 from <ClassTest: 0x600003d784d0>
Test Case
'-[SomeTestCase testMetho1]' passed (0.004 seconds).
Test Case
'-[SomeTestCase testMethod2]' started.
>>>> METHOD 2 from <ClassTest: 0x600003d78590>
Test Case
'-[SomeTestCase testMethod2]' passed (0.000 seconds).
Test Suite
'SomeTestCase' passed at 2020-09-07 11:11:28.929.
Executed 2 tests, with 0 failures (0 unexpected)
in 0.004 (0.007) seconds

Foram removidos os simbolos adicionados pelo lldb para melhor legibilidade.

O log demonstra alguns pontos importantes.
Primeiro, ao executar o TestCase, o framework lida com ele como uma Test Suite.
Segundo, cada teste é tratado como TestCase e, mesmo não realizando nenhuma asserção, os testes passarão. Isso demonstra uma das características do XCTest que pode gerar testes chamados falsos positivos, ou seja, aqueles que passam mas não validam nada.
Por fim, o log retorna o resultado da execução daquela suite (TestCase).

Evoluindo o teste

Se analisarmos o código do teste executado, percebemos que o sut é construido para cada teste, mas que o comportamento que está sendo testado não possui interdepêndencia. Assim, podemos melhorar a implementação do teste trazendo o sut para o escopo do TestCase.

Executando o teste para garantir que nada tenha quebrado recebemos o seguinte log:

Test Suite 'SomeTestCase' started at 2020-09-07 11:47:23.508
Test Case '-[SomeTestCase testMetho1]' started.
>>>> METHOD 1 from <ClassTest: 0x6000030cc100>
Test Case '-[SomeTestCase testMetho1]' passed (0.004 seconds).
Test Case '-[SomeTestCase testMethod2]' started.
>>>> METHOD 2 from <ClassTest: 0x6000030cc140>
Test Case '-[SomeTestCase testMethod2]' passed (0.000 seconds).
Test Suite 'SomeTestCase' passed at 2020-09-07 11:47:23.515.
Executed 2 tests, with 0 failures (0 unexpected) in 0.005 (0.007) seconds

O log aponta que a refatoração do teste foi um sucesso, visto que nenhum teste quebrou. Porém, um detalhe é importante de se notar.
Ao analisar o print de cada teste é possivel perceber que os objetos são diferentes (0x6000030cc100 e 0x6000030cc140), mesmo trazendo o sut para o escopo do TestCase.

Este comportamento acontece pois o framework cria um instância nova do TestCase para cada teste.

Documentação da Apple. Citação: Para cada método de teste uma nova instância da classe de testes é alocada.
Apple documentation

Por isso, ao analisar o log, cada teste é tratado como TestCase e o conjunto de instâncias criadas é o que foi chamado de Test Suite.

É possível encontrar essas instâncias através da propriedade tests da defaultTestSuite.

Execução completa

Sabendo então como o framework organiza a estrutura dos testes é possível entender como ele executa cada uma das suites.

Fluxograma de execução XCTest

Ao solicitar a execução dos testes, o compilador irá procurar as classes de TestCase utilizando o Objective-C runtime e criará uma lista com as classes encontradas.
Cria-se então um XCTMain com essa lista.
O XCTMain irá iterar sobre a lista, acessando cada uma das suites e chamará o método run().

O método irá iterar sobre a propriedade tests (instâncias do Test Case) e executará o respectivo teste para cada uma das instâncias. Além disso, ao final da execução, incrementa-se o objeto com as informações da execução do teste (se foi um sucesso ou se houve falhas).

Algo semelhante ao que foi feito no playground, porém para cada um dos TestCase.

Indo além

Conhecendo então o ciclo de execução do XCTest, percebe-se que o framework se baseia nas listas — arrays — criadas. Essas listas guardam seus objetos até que o final da execução dos testes aconteça pois, com as informações desses objetos cria-se o relatório de execução.

Ao entender que esses objetos ficam em memória, deve-se lembrar então que por consequência, as respectivas propriedades também ficam alocadas.

É possivel perceber o comportamento no exemplo utilizado ao alterar a classe de testes para que execute um print também nos métodos init e deinit

Ao executar o playground novamente, percebe-se pelo log o comportamento descrito, uma vez que o deinit não é chamado:

>>>> INIT from <ClassTest: 0x6000016383f0>
>>>> INIT from <ClassTest: 0x600001638430>
Test Suite 'SomeTestCase' started at 2020-09-07 15:01:44.382
Test Case '-[SomeTestCase testMetho1]' started.
>>>> METHOD 1 from <ClassTest: 0x6000016383f0>
Test Case '-[SomeTestCase testMetho1]' passed (0.031 seconds).
Test Case '-[SomeTestCase testMethod2]' started.
>>>> METHOD 2 from <ClassTest: 0x600001638430>
Test Case '-[SomeTestCase testMethod2]' passed (0.000 seconds).
Test Suite 'SomeTestCase' passed at 2020-09-07 15:01:44.429.
Executed 2 tests, with 0 failures (0 unexpected) in 0.031 (0.047) seconds

Observação

De modo geral, as propriedades em memória não deveriam ser um problema. Porém, existem alguns cenários que isso pode se tornar algo ruim.

Imagine um produto que possua por volta de 1000 (mil) arquivos de teste, sendo que cada arquivo possui no mínimo 6 testes. No final da execução teremos 6000 (seis mil) instâncias de teste em memória, cada uma com as respectivas propriedades criadas. Ao exercitar um pouco a imaginação, é possível imaginar que no final da execução, por volta de 9000 (nove mil) instâncias podem estar alocadas em memória.

Esse cenário descrito pode não causar problemas em Macs mais recentes e com mais poder computacional, porém, vale ressaltar que, em um projeto desse porte, nem todos os desenvolvedores terão máquinas que suportem tamanho consumo de memória.

Evoluindo um pouco mais esse cenário, chega-se a um cenário até mais comum. Em boa parte dos projetos, as CIs costumam ser Mac Minis — com configurações de harware medianas — que rodam até três pipelines em paralelo. Imagine como um Mac Mini se sairia com 9000 (nove mil) instâncias por pipeline, ou seja, 27000 (vinte e sete mil) instâncias.

Portando, logo percebe-se que os problemas de memória aumentam na mesma proporção de crescimento do projeto.

Solução

Para resolver esse problema, é preciso realizar uma tarefa que no Objective-C era comum antes da entrada do ARC. Deve-se desalocar instâncias desnecessárias.

Automatic Reference Counting (ARC) é o gerenciador de uso de memória das aplicações no ecossistema Apple.

Para desalocar a instância em Swift deve-se atribuir o valor nil à propriedade.

Retomando o exemplo em que a inicialização estava na declaração podemos refatorá-lo para que seja possível liberar a memória. Para isso deve-se:

  1. Transformar a propriedade em uma variável opcional
  2. Criar e resetar a propriedade utilizando as funções de configuração e desconfiguração

Após a refatoração, chega-se a este resultado:

Executando o teste, o seguinte log é apresentado:

Test Suite 'SomeTestCase' started at 2020-09-07 15:04:24.404
Test Case '-[SomeTestCase testMetho1]' started.
>>>> INIT from <ClassTest: 0x60000027c1e0>
>>>> METHOD 1 from <ClassTest: 0x60000027c1e0>
>>>> DEINIT from <ClassTest: 0x60000027c1e0>
Test Case '-[SomeTestCase testMetho1]' passed (0.005 seconds).
Test Case '-[SomeTestCase testMethod2]' started.
>>>> INIT from <ClassTest: 0x60000027c1e0>
>>>> METHOD 2 from <ClassTest: 0x60000027c1e0>
>>>> DEINIT from <ClassTest: 0x60000027c1e0>
Test Case '-[SomeTestCase testMethod2]' passed (0.000 seconds).
Test Suite 'SomeTestCase' passed at 2020-09-07 15:04:24.411.
Executed 2 tests, with 0 failures (0 unexpected) in 0.005 (0.007) seconds

A partir do log acima, é possível notar que os objetos indesejados foram liberados da memória devido a utilização dos métodos setUp e tearDown.

É importante notar que para isso foi preciso transformar o sut em uma propriedade optional. Porém, como todo teste utilizará a propriedade sut, é pressuposto que a propriedade existirá durante o tempo de execução. Assim, utiliza-se o simbolo de force (!) para evitar lidar com um optional durante todo o teste.

Conclusão

Ao entender de maneira mais profunda o framework XCTest, as possibilidades se abrem para escrever testes melhores e com mais segurança. Ao conhecer o ciclo de execução e o gerenciamento de memória do XCTest, é possível identificar problemas de maneira mais objetiva.

Indo além

Quer aprender um pouco mais sobre estratégias de testes e como escrever testes melhores? Então dá uma conferida nesses posts!!!

Agradecimentos

Um agradecimento especial para Leandro Romano pela ajuda na revisão do texto. Agredeço também ao Ricardo Rachaus, Erika Albizzati e Julia Botan pelo incentivo em continuar meus estudos em testes.

--

--

Matheus de Vasconcelos

iOS Developer — Apple Developer Academy Alumni | Mackenzie. Studying Unit Tests.