Test Doubles Swift

Ou o isolamento de testes unitários

Matheus de Vasconcelos
Accenture Digital Product Dev
8 min readFeb 11, 2020

--

Se você é um desenvolvedor ou desenvolvedora e acompanha o Blog da Concrete há algum tempo já sabe que os testes são parte essencial do desenvolvimento de produtos, certo? Neste post, vamos falar um pouco mais sobre os testes unitários e como garantir uma das suas características principais: o isolamento.

Mas, antes de tudo: o que são testes unitários?

O teste unitário é um código que testa a menor parte testável de um sistema, a chamada unit, com o objetivo de ajudar a detectar falhas durante a fase de desenvolvimento e evitar problemas nas fases de testes de integração, além de servir como documentação para seu código e garantir que mudanças e adições não alterem o comportamento esperado do sistema já desenvolvido. Estes testes, quando agrupados dentro do contexto que estão se propondo a testar, são chamados de “suíte”.

Características de um teste unitário

De modo geral, uma suíte de testes deve seguir as seguintes características para ser considerada de boa qualidade:

  • Deve ser isolada: testes não devem interferir em outros testes, assim como não devem depender de dados ou ambientes externos (como APIs e Bancos de Dados).
  • Deve ser rápida: um teste deve ser capaz de rodar em segundos, entretanto isso não necessariamente quer dizer que a execução de todas as suítes de teste será rápida, uma vez que os tempos são somados. Porém, é necessário que o teste seja rápido para que a execução completa de todos eles não demore muito.
  • Deve ser coesa: como a definição pressupõe, um teste deve testar apenas a menor parte do sistema. Um teste que testa diversas partes fica complexo e com baixa coesão.

Para garantir o isolamento

Das características de um teste unitário, uma das mais importantes e desafiadoras no desenvolvimento é o isolamento. Para que isso não se torne uma dor de cabeça durante o desenvolvimento, temos algumasboas práticas e padrões que podem ajudar a deixar o seu sistema mais facilmente isolável.

A utilização de injeção de dependência, por exemplo, é uma das principais práticas, uma vez que, com ela, os objetos do sistema apenas conhecem o objeto auxiliar que precisam para executar uma função, mas não necessariamente sabem como criar ele. Assim, é possível mudar o funcionamento desse objeto auxiliar para facilitar o teste sem que o objeto em testes seja afetado. Além disso, é interessante manter a coesão alta e acoplamento baixo dos objetos.

Ambientes caóticos

Até agora, falamos sobre o que seria o ideal em desenvolvimento de testes. Entretanto, nem todo projeto que existe está nesse estado. A deficiência desses projetos acaba gerando um grande desafio durante o desenvolvimento de testes, pois para garantir o isolamento é necessário utilizar lógicas complexas ou fazer o teste do objeto, o que em alguns casos é inviável em termos de tempo.

E aí… chegamos ao objetivo deste post! É possível utilizar Test Doubles para realizar um teste de maneira isolada e sem precisar de grandes refatorações de códigos legado ou até mesmo lógicas muito complexas.

Test Doubles

O termo refere-se a todo objeto que se passa por outro, para que — em um contexto de teste — seja possível testar objetos garantindo o isolamento completo deles. Este termo é descrito no livro xunit test patterns: refactoring test code (Gerard Meszaros).

Os Test Doubles descritos no livro são cinco, e provavelmente todo desenvolvedor já usou algum deles em algum teste. Antes de explicar cada um deles, porém, é importante conhecer os seguintes conceitos de teste:

  • SUT: sistema que se está testando.
  • OUT: objeto que se está testando.
  • Collaborator: sistemas ou objetos auxiliares para o teste.

Além dos tipos de verificação:

  • State verification: o teste verifica se o fluxo do sistema funciona, ou seja, se ao final do teste o estado do sistema (conjunto de valores das variáveis naquele momento) do SUT e dos Collaborators é o esperado.
Exemplo de um fluxo de State verification, no qual o teste validaria o estado B
  • Behavior verification: o teste verifica se um método faz o que deveria fazer ao ser chamado. Em alguns casos o estado do sistema após o teste pode ser ignorado.
No exemplo acima um teste validaria se X, Y e Z foram chamados e se foram chamados na ordem correta, por exemplo

Agora sim… Os Doubles

Dummy

Doubles do tipo Dummy são objetos que não são usados, geralmente parâmetros desnecessários para chamadas de funções (Navigation controller para fazer algum teste que depende de ter um controller em uma navigation na Stack).

Código de exemplo

Fake

Os Fakes são objetos que têm uma implementação para facilitar o teste, mas não devem estar em produção. Seu comportamento costuma ser estático, ou seja, sempre retorna a mesma coisa quando solicitado.

Código de exemplo

Stub

Uma Stub provê uma resposta esperada quando chamada e geralmente tem variáveis que vão ser o retorno de funções. Essas variáveis podem ser alteradas durante o teste, fazendo com que o objeto tenha um funcionamento controlado.

Diferente do Fake, uma Stub pode mudar seu comportamento, ou seja, é dinâmico, enquanto um Fake tem sempre o mesmo comportamento.

Código de exemplo

Spy

O Spy, como o próprio nome diz, funciona como um espião que coleta dados durante o teste, geralmente validando se ou quantas vezes algum método foi chamado. Alguns spies mais complexos podem armazenar o estado de um sistema durante o teste. Esse double pode se utilizar do padrão de projeto Memento para armazenar estados de sistema.

Código de exemplo

Mock

Até o momento todos os doubles apresentados costumam ser utilizados para testes de state verification, facilitando simular os estados esperados ou a realizar a asserção desses estados.

O Mock, no entanto, é um Double para testes de behavior verification, ou seja, aqueles em que o estado do sistema não é necessariamente importante, mas sim o fluxo dentro do objeto. Um Mock costuma utilizar herança para que seja possível manter o comportamento do SUT e ainda assim adicionar estruturas de controle, validando, por exemplo, a ordem em que métodos são chamados e se de fato eles são.

Código de exemplo

Problemas com isolamento

De modo geral, as principais fontes de quebra de isolamento costumam ser chamadas de APIs, de Analytics e uso de variáveis compartilhadas.
A seguir vamos falar de algumas possíveis situações e soluções desses problemas.

Chamada de APIs

Situação 1

No exemplo acima é inviável testar o objeto, uma vez que ele utiliza um URLSession para se comunicar com uma API e fere, assim, o isolamento do teste.

Solução
Para resolver este problemas podemos utilizar os Test Doubles e o princípio de injeção de dependência. Em um primeiro passo é possível fazer com que o objeto receba o URLSession que vai utilizar; e, para o teste, é possível criar um Fake ou um Stub do URLSession (como no exemplo de uma Stub acima).

Com essa solução, quase toda situação de chamadas de API pode ser isolada. No entanto, uma situação específica pode passar despercebida. Para entender melhor, é importante saber que o Xcode possibilita a execução de dois tipos de teste, um teste com uma host application e um teste sem.

Um teste com host application implica que ao executar as suítes de teste o Xcode vai dar lauch da aplicação, ou seja, vai chamar a função:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool

Enquanto um sem host application não realizaria o lauch. Entender isso é importante pois no desenvolvimento costumamos nos preocupar em isolar as chamadas nos nossos testes, mas nem sempre é apenas no teste que uma API é chamada.

Situação 2
Um app que, ao iniciar, coloca uma viewController como root da aplicação e essa view controller chama uma API em seu método de viewDidLoad para carregar uma informação em tela.

Problema
Se os testes forem executados com uma host application, antes de executar os testes aquela view controller vai entrar como root e vai chamar a API.

Solução
Para que isso não ocorra o ideal é que os testes rodem sem uma host application.

Vale ressaltar que, ao fazer isso, para que seja possível ver uma classe de algum arquivo dentro de um teste ele deve ficar visível no target de testes também.

Entretanto, quando o teste precisar necessariamente de uma host, uma segunda solução é criar um Fake AppDelegate que será executado apenas para o target de testes. Basta seguir os seguintes passos:

  1. Criar um arquivo main.swift
  2. Adicionar as seguintes linhas de código:

Além deste fake é possível apenas não iniciar um AppDelegate, assim a host application vai iniciar de maneira mais rápida, mas não será possível utilizar, por exemplo, a UIApplication.shared.

3. Criar o arquivo AppDelegateFake caso queira

Chamadas de Analytics

O analytics é uma ferramenta fundamental para entender melhor o usuário de uma aplicação.

Problema
Uma vez que um teste deve ser isolado, ele não pode comunicar um servidor de analytics que um evento ocorreu, além de comprometer os dados com informações geradas apenas em testes.

Solução
Para resolver este problema pode-se criar um Spy que armazena se um analitycs foi chamado corretamente ou até mesmo quantas vezes ele foi chamado.

Uso de variáveis compartilhadas

Em alguns projetos e frameworks é comum encontrar objetos únicos que permanecem em memória por toda a aplicação. De modo geral, esses objetos costumam seguir o padrão de projeto Singleton ou estão como propriedade de comum, como por exemplo a UIWindow.

Problema
Ao utilizar uma variável compartilhada em um teste podemos criar um problema para um teste subsequente, uma vez que essa variável se manterá em memória e o próximo teste vai utilizá-la com os valores atribuídos no teste anterior, o que pode causar inconsistência de resultados, ainda mais se os testes estiverem executando em ordem aleatória.

Solução
A solução é simples e envolve entender a estrutra que um teste unitário tem.
Um teste unitário geralmente possui 3 etapas: setUp, assert e tearDown.

  • setUp: fase de preparação do teste, neste momento deve-se criar o SUT e fazer as configurações necessárias nele.
  • assert: momento de validação, nesta etapa o que se pretende testar do SUT passa por uma asserção. É interessante evitar lógicas de preparação aqui.
  • tearDown: etapa que se inicia após a asserção, e neste momento toda a configuração realizada no setUp deve ser desfeita. De modo geral, nesta etapa eliminamos as instâncias utilizadas na asserção para que o próximo setUp as recrie.

Essa estrutura é executada para cada teste criado, ou seja, em uma suíte de teste o setUp e o tearDown sempre serão executados respectivamente no começo e final do teste.

Com isso definido, para evitar resultados inconsistentes com variáveis compartilhadas basta guardar o estado delas no setUp e retornar esse estado no tearDown.

E é isso! Ficou alguma dúvida ou tem algo a dizer? Aproveite e deixe um comentário. Quer aprender mais sobre testes na prática? Candidate-se a uma de nossas vagas e vamos aprender juntos. =)

Falando em aprender, aqui embaixo tem algumas referências caso você queira estudar mais sobre o assunto. Até a próxima!

Mocks Aren’t Stubs (Martin Fowler)

Why mocking in iOS tests may not stop network and DB activity entirely (Mike Apostolakis)

A tricky case with Application Tests (AliSoftware)

--

--

Matheus de Vasconcelos
Accenture Digital Product Dev

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