Como escrever testes automatizados para iOS

Publicados: 2022-03-11

Como um bom desenvolvedor, você faz o possível para testar todas as funcionalidades e todos os caminhos e resultados de código possíveis no software que escreve. Mas é extremamente raro e incomum poder testar manualmente todos os resultados possíveis e todos os caminhos possíveis que um usuário pode seguir.

À medida que o aplicativo fica maior e mais complexo, a probabilidade de você perder algo por meio de testes manuais aumenta significativamente.

Testes automatizados, tanto da interface do usuário quanto das APIs de serviço de back-end, deixarão você mais confiante de que tudo funciona conforme o esperado e reduzirá o estresse ao desenvolver, refatorar, adicionar novos recursos ou alterar os existentes.

Com testes automatizados, você pode:

  • Reduzir bugs: Não existe um método que remova completamente qualquer possibilidade de bugs em seu código, mas testes automatizados podem reduzir bastante o número de bugs.
  • Faça alterações com confiança: evite bugs ao adicionar novos recursos, o que significa que você pode fazer alterações de forma rápida e indolor.
  • Documente nosso código: Ao analisar os testes, podemos ver claramente o que é esperado de certas funções, quais são as condições e quais são os casos de canto.
  • Refatore sem dor: Como desenvolvedor, às vezes você pode ter medo de refatorar, especialmente se precisar refatorar um grande pedaço de código. Os testes de unidade estão aqui para garantir que o código refatorado ainda funcione conforme o esperado.

Este artigo ensina como estruturar e executar testes automatizados na plataforma iOS.

Testes de unidade x testes de interface do usuário

É importante diferenciar entre testes de unidade e de interface do usuário.

Um teste de unidade testa uma função específica em um contexto específico . Os testes de unidade verificam se a parte testada do código (geralmente uma única função) faz o que deveria fazer. Existem muitos livros e artigos sobre testes de unidade, então não abordaremos isso neste post.

Os testes de interface do usuário são para testar a interface do usuário. Por exemplo, ele permite testar se uma visualização é atualizada conforme pretendido ou se uma ação específica é acionada como deveria ser quando o usuário interage com um determinado elemento da interface do usuário.

Cada teste de interface do usuário testa uma interação específica do usuário com a interface do usuário do aplicativo. Os testes automatizados podem e devem ser realizados nos níveis de teste de unidade e teste de interface do usuário.

Configurando testes automatizados

Como o XCode oferece suporte a testes de unidade e UI prontos para uso, é fácil e direto adicioná-los ao seu projeto. Ao criar um novo projeto, basta marcar “Incluir testes de unidade” e “Incluir testes de interface do usuário”.

Quando o projeto for criado, dois novos alvos serão adicionados ao seu projeto quando essas duas opções forem marcadas. Novos nomes de destino têm “Tests” ou “UITests” anexados ao final do nome.

É isso. Você está pronto para escrever testes automatizados para seu projeto.

Imagem: Configurando testes automatizados no XCode.

Se você já tem um projeto existente e deseja adicionar o suporte à interface do usuário e aos testes de unidade, terá que trabalhar um pouco mais, mas também é muito direto e simples.

Vá para Arquivo → Novo → Destino e selecione iOS Unit Testing Bundle para testes de unidade ou iOS UI Testing Bundle para testes de interface do usuário.

Imagem: selecionando o pacote de teste de unidade do iOS.

Pressione Avançar .

Na tela de opções de destino, você pode deixar tudo como está (se tiver vários destinos e quiser testar apenas destinos específicos, selecione o destino no menu suspenso Destino a ser testado).

Pressione Concluir . Repita esta etapa para testes de interface do usuário e você terá tudo pronto para começar a escrever testes automatizados em seu projeto existente.

Escrevendo testes de unidade

Antes de começarmos a escrever testes de unidade, devemos entender sua anatomia. Ao incluir testes de unidade em seu projeto, uma classe de teste de exemplo será criada. No nosso caso, ficará assim:

 import XCTest class TestingIOSTests: XCTestCase { override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } func testExample() { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } func testPerformanceExample() { // This is an example of a performance test case. self.measure { // Put the code you want to measure the time of here. } } }

Os métodos mais importantes para entender são setUp e tearDown . O método setUp é chamado antes de cada método de teste, enquanto o método tearDown é chamado após cada método de teste. Se executarmos testes definidos nesta classe de teste de exemplo, os métodos serão executados assim:

setUp → testExample → tearDown setUp → testPerformanceExample → tearDown

Dica: Os testes são executados pressionando cmd + U, selecionando Produto → Teste ou clicando e mantendo pressionado o botão Executar até que o menu de opções apareça e, em seguida, selecione Teste no menu.

Se você deseja executar apenas um método de teste específico, pressione o botão à esquerda do nome do método (mostrado na imagem abaixo).

Imagem: Selecionando um método de teste específico.

Agora, quando você tiver tudo pronto para escrever testes, você pode adicionar uma classe de exemplo e alguns métodos para testar.

Adicione uma classe que será responsável pelo registro do usuário. Um usuário insere um endereço de e-mail, senha e confirmação de senha. Nossa classe de exemplo validará a entrada, verificará a disponibilidade do endereço de e-mail e tentará o registro do usuário.

Observação: este exemplo está usando o padrão de arquitetura MVVM (ou Model-View-ViewModel).

O MVVM é usado porque torna a arquitetura de um aplicativo mais limpa e fácil de testar.

Com o MVVM, é mais fácil separar a lógica de negócios da lógica de apresentação, evitando assim problemas massivos no controlador de exibição.

Os detalhes sobre a arquitetura MVVM estão fora do escopo deste artigo, mas você pode ler mais sobre isso neste artigo.

Vamos criar uma classe view-model responsável pelo registro do usuário. .

 class RegisterationViewModel { var emailAddress: String? { didSet { enableRegistrationAttempt() } } var password: String? { didSet { enableRegistrationAttempt() } } var passwordConfirmation: String? { didSet { enableRegistrationAttempt() } } var registrationEnabled = Dynamic(false) var errorMessage = Dynamic("") var loginSuccessful = Dynamic(false) var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } }

Primeiro, adicionamos algumas propriedades, propriedades dinâmicas e um método init.

Não se preocupe com o tipo Dynamic . Faz parte da arquitetura MVVM.

Quando um valor Dynamic<Bool> é definido como true, um controlador de exibição vinculado (conectado) ao RegistrationViewModel habilitará o botão de registro. Quando loginSuccessful for definido como true, a visualização conectada será atualizada.

Vamos agora adicionar alguns métodos para verificar a validade da senha e do formato de e-mail.

 func enableRegistrationAttempt() { registrationEnabled.value = emailValid() && passwordValid() } func emailValid() -> Bool { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" let emailTest = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailTest.evaluate(with: emailAddress) } func passwordValid() -> Bool { guard let password = password, let passwordConfirmation = passwordConfirmation else { return false } let isValid = (password == passwordConfirmation) && password.characters.count >= 6 return isValid }

Toda vez que o usuário digitar algo no campo de e-mail ou senha, o método enableRegistrationAttempt verificará se o e-mail e a senha estão no formato correto e habilitará ou desabilitará o botão de registro por meio da propriedade dinâmica registrationEnabled .

Para manter o exemplo simples, adicione dois métodos simples - um para verificar a disponibilidade de um e-mail e outro para tentar o registro com o nome de usuário e a senha fornecidos.

 func checkEmailAvailability(email: String, withCallback callback: @escaping (Bool?)->(Void)) { networkService.checkEmailAvailability(email: email) { (available, error) in if let _ = error { self.errorMessage.value = "Our custom error message" } else if !available { self.errorMessage.value = "Sorry, provided email address is already taken" self.registrationEnabled.value = false callback(available) } } } func attemptUserRegistration() { guard registrationEnabled.value == true else { return } // To keep the example as simple as possible, password won't be hashed guard let emailAddress = emailAddress, let passwordHash = password else { return } networkService.attemptRegistration(forUserEmail: emailAddress, withPasswordHash: passwordHash) { (success, error) in // Handle the response if let _ = error { self.errorMessage.value = "Our custom error message" } else { self.loginSuccessful.value = true } } }

Esses dois métodos estão usando o NetworkService para verificar se um email está disponível e tentar o registro.

Para manter este exemplo simples, a implementação do NetworkService não está usando nenhuma API de back-end, mas é apenas um stub que falsifica os resultados. NetworkService é implementado como um protocolo e sua classe de implementação.

 typealias RegistrationAttemptCallback = (_ success: Bool, _ error: NSError?) -> Void typealias EmailAvailabilityCallback = (_ available: Bool, _ error: NSError?) -> Void protocol NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) }

NetworkService é um protocolo muito simples que contém apenas dois métodos: tentativa de registro e métodos de verificação de disponibilidade de e-mail. A implementação do protocolo é a classe NetworkServiceImpl.

 class NetworkServiceImpl: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } }

Ambos os métodos simplesmente esperam algum tempo (fingindo o atraso de uma solicitação de rede) e então chamam os métodos de retorno de chamada apropriados.

Dica: É uma boa prática usar protocolos (também conhecidos como interfaces em outras linguagens de programação). Você pode ler mais sobre isso se procurar por 'princípio de programação para interfaces'. Você também verá como ele funciona bem com testes de unidade.

Agora, quando um exemplo é definido, podemos escrever testes de unidade para cobrir métodos dessa classe.

  1. Crie uma nova classe de teste para nosso modelo de exibição. Clique com o botão direito do mouse na pasta TestingIOSTests no painel Project Navigator, selecione New File → Unit Test Case Class e nomeie-a RegistrationViewModelTests .

  2. Exclua os métodos testExample e testPerformanceExample , pois queremos criar nossos próprios métodos de teste.

  3. Como o Swift usa módulos e nossos testes estão em um módulo diferente do código do nosso aplicativo, temos que importar o módulo do nosso aplicativo como @testable . Abaixo da instrução de importação e definição de classe, adicione @testable import TestingIOS (ou o nome do módulo do seu aplicativo). Sem isso, não poderíamos referenciar nenhuma das classes ou métodos do nosso aplicativo.

  4. Adicione a variável registrationViewModel .

É assim que nossa classe de teste vazia se parece agora:

 import XCTest @testable import TestingIOS class RegistrationViewModelTests: XCTestCase { var registrationViewModel: RegisterationViewModel? override func setUp() { super.setUp() } override func tearDown() { super.tearDown() } }

Vamos tentar escrever um teste para o método emailValid . Criaremos um novo método de teste chamado testEmailValid . É importante adicionar a palavra-chave test no início do nome. Caso contrário, o método não será reconhecido como um método de teste.

Nosso método de teste fica assim:

 func testEmailValid() { let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl()) registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "email@test" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = nil XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "[email protected]" XCTAssert(registrationVM.emailValid(), "\(registrationVM.emailAddress) should be correct") }

Nosso método de teste usa um método de asserção, XCTAssert , que no nosso caso verifica se uma condição é verdadeira ou falsa.

Se a condição for falsa, assert falhará (junto com o teste) e nossa mensagem será escrita.

Existem muitos métodos assert que você pode usar em seus testes. Descrever e mostrar cada método assert pode facilmente criar seu próprio artigo, então não entrarei em detalhes aqui.

Alguns exemplos de métodos assert disponíveis são: XCTAssertEqualObjects , XCTAssertGreaterThan , XCTAssertNil , XCTAssertTrue ou XCTAssertThrows .

Você pode ler mais sobre os métodos assert disponíveis aqui.

Se você executar o teste agora, o método de teste será aprovado. Você criou com sucesso seu primeiro método de teste, mas ainda não está pronto para o horário nobre. Este método de teste ainda possui três problemas (um grande e dois menores), conforme detalhado abaixo.

Problema 1: você está usando a implementação real do protocolo NetworkService

Um dos princípios fundamentais do teste de unidade é que cada teste deve ser independente de quaisquer fatores ou dependências externas. Os testes unitários devem ser atômicos.

Se você estiver testando um método, que em algum momento chama um método de API do servidor, seu teste depende do seu código de rede e da disponibilidade do servidor. Se o servidor não estiver funcionando no momento do teste, seu teste falhará, acusando erroneamente o método testado de não funcionar.

Nesse caso, você está testando um método do RegistrationViewModel .

RegistrationViewModel depende da classe NetworkServiceImpl , mesmo sabendo que seu método testado, emailValid , não depende diretamente do NetworkServiceImpl .

Ao escrever testes de unidade, todas as dependências externas devem ser removidas. Mas como você deve remover a dependência NetworkService sem alterar a implementação da classe RegistrationViewModel ?

Existe uma solução fácil para este problema, e chama-se Object Mocking . Se você observar atentamente o RegistrationViewModel , verá que ele realmente depende do protocolo NetworkService .

 class RegisterationViewModel { … // It depends on NetworkService. RegistrationViewModel doesn't even care if NetworkServiceImple exists var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } ...

Quando o RegistrationViewModel está sendo inicializado, uma implementação do protocolo NetworkService é fornecida (ou injetada) ao objeto RegistrationViewModel .

Esse princípio é chamado de injeção de dependência via construtor ( existem mais tipos de injeções de dependência ).

Existem muitos artigos interessantes sobre injeção de dependência online, como este artigo no objc.io.

Há também um artigo curto, mas interessante, explicando a injeção de dependência de maneira simples e direta aqui.

Além disso, um ótimo artigo sobre princípio de responsabilidade única e DI está disponível no blog da Toptal.

Quando o RegistrationViewModel é instanciado, ele está injetando uma implementação do protocolo NetworkService em seu construtor (daí o nome do princípio de injeção de dependência):

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

Como nossa classe de modelo de exibição depende apenas do protocolo, não há nada que nos impeça de criar nossa classe de implementação NetworkService personalizada (ou simulada) e injetar a classe simulada em nosso objeto de modelo de exibição.

Vamos criar nossa implementação do protocolo NetworkService simulado.

Adicione um novo arquivo Swift ao nosso destino de teste clicando com o botão direito do mouse na pasta TestingIOSTests no Project Navigator, escolha “New File”, selecione “Swift file” e nomeie-o NetworkServiceMock .

É assim que nossa classe simulada deve ficar:

 import Foundation @testable import TestingIOS class NetworkServiceMock: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(false, nil) }) } }

Neste ponto, não é muito diferente de nossa implementação real ( NetworkServiceImpl ), mas em uma situação do mundo real, o NetworkServiceImpl real teria um código de rede, tratamento de resposta e funcionalidade semelhante.

Nossa classe simulada não faz nada, que é o objetivo de uma classe simulada. Se não fizer nada, não interferirá em nossos testes.

Para corrigir o primeiro problema do nosso teste, vamos atualizar nosso método de teste substituindo:

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

com:

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())

Problema 2: você está instanciando o registrationVM no corpo do método de teste

Há os métodos setUp e tearDown por um motivo.

Esses métodos são usados ​​para iniciar ou configurar todos os objetos necessários em um teste. Você deve usar esses métodos para evitar a duplicação de código escrevendo os mesmos métodos de inicialização ou configuração em cada método de teste. Não usar os métodos setup e tearDown nem sempre é um grande problema, especialmente se você tiver uma configuração realmente específica para um método de teste específico.

Como nossa inicialização da classe RegistrationViewModel é bastante simples, você refatorará sua classe de teste para usar os métodos setup e tearDown.

RegistrationViewModelTests deve ficar assim:

 class RegistrationViewModelTests: XCTestCase { var registrationVM: RegisterationViewModel! override func setUp() { super.setUp() registrationVM = RegisterationViewModel(networkService: NetworkServiceMock()) } override func tearDown() { registrationVM = nil super.tearDown() } func testEmailValid() { registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") ... } }

Problema 3: você tem várias declarações em um método de teste

Mesmo que isso não seja um grande problema, existem alguns defensores de ter uma declaração por método.

O principal raciocínio para este princípio é a detecção de erros.

Se um método de teste tiver várias declarações e o primeiro falhar, todo o método de teste será marcado como com falha. Outros asserts nem serão testados.

Dessa forma, você descobriria apenas um erro por vez. Você não saberia se outras assertivas falhariam ou seriam bem-sucedidas.

Nem sempre é ruim ter várias declarações em um método porque você só pode corrigir um erro por vez, portanto, detectar um erro por vez pode não ser um problema tão grande.

No nosso caso, a validade de um formato de e-mail é testada. Como esta é apenas uma função, pode ser mais lógico agrupar todas as declarações em um método para tornar o teste mais fácil de ler e entender.

Como esse problema não é realmente um grande problema e alguns podem até argumentar que não é um problema, você manterá seu método de teste como está.

Quando você escreve seus próprios testes de unidade, cabe a você decidir qual caminho deseja seguir para cada método de teste. Muito provavelmente, você descobrirá que há lugares onde a filosofia de uma afirmação por teste faz sentido e outros onde não.

Métodos de teste com chamadas assíncronas

Não importa quão simples seja o aplicativo, há uma grande chance de haver um método que precise ser executado em outro thread de forma assíncrona, especialmente porque você normalmente gosta de ter a interface do usuário executando em seu próprio thread.

O principal problema com testes de unidade e chamadas assíncronas é que uma chamada assíncrona leva tempo para ser concluída, mas o teste de unidade não espera até que seja concluído. Como o teste de unidade é concluído antes que qualquer código dentro de um bloco assíncrono seja executado, nosso teste sempre terminará com o mesmo resultado (não importa o que você escreva em seu bloco assíncrono).

Para demonstrar isso, vamos criar um teste para o método checkEmailAvailability .

 func testCheckEmailAvailability() { registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled") } }

Aqui você quer testar se uma variável registrationEnabled será definida como false depois que nosso método informar que o email não está disponível (já usado por outro usuário).

Se você executar este teste, ele passará. Mas tente mais uma coisa. Altere sua declaração para:

 XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")

Se você executar o teste novamente, ele passará novamente.

Isso ocorre porque nossa afirmação nem sequer foi afirmada. O teste de unidade terminou antes que o bloco de retorno de chamada fosse executado (lembre-se, em nossa implementação de serviço de rede simulada, ele é configurado para aguardar um segundo antes de retornar).

Felizmente, com o Xcode 6, a Apple adicionou expectativas de teste ao framework XCTest como a classe XCTestExpectation . A classe XCTestExpectation funciona assim:

  1. No início do teste, você define sua expectativa de teste - com um texto simples descrevendo o que você esperava do teste.
  2. Em um bloco assíncrono após a execução do código de teste, você atende à expectativa.
  3. No final do teste você precisa definir o bloco waitForExpectationWithTimer . Ele será executado quando a expectativa for cumprida ou se o cronômetro se esgotar - o que ocorrer primeiro.
  4. Agora, o teste de unidade não terminará até que a expectativa seja cumprida ou até que o cronômetro de expectativa se esgote.

Vamos reescrever nosso teste para usar a classe XCTestExpectation .

 func testCheckEmailAvailability() { // 1. Setting the expectation let exp = expectation(description: "Check email availability") registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled") // 2. Fulfilling the expectation exp.fulfill() } // 3. Waiting for expectation to fulfill waitForExpectations(timeout: 3.0) { error in if let _ = error { XCTAssert(false, "Timeout while checking email availability") } } }

Se você executar o teste agora, ele falhará - como deveria. Vamos corrigir o teste para fazê-lo passar. Altere a assertiva para:

 XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled")

Execute o teste novamente para vê-lo passar. Você pode tentar alterar o tempo de atraso na implementação simulada do serviço de rede para ver o que acontece se o temporizador de expectativa se esgotar.

Testando métodos com chamadas assíncronas sem retorno de chamada

Nosso método de projeto de exemplo attemptUserRegistration usa o método NetworkService.attemptRegistration que inclui código que é executado de forma assíncrona. O método tenta registrar um usuário com o serviço de back-end.

Em nosso aplicativo de demonstração, o método apenas aguardará um segundo para simular uma chamada de rede e falsificar um registro bem-sucedido. Se o registro for bem-sucedido, o valor loginSuccessful será definido como true. Vamos fazer um teste de unidade para verificar esse comportamento.

 func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.attemptUserRegistration() XCTAssert(registrationVM.loginSuccessful.value, "Login must be successful") }

Se executado, esse teste falhará porque o valor loginSuccessful não será definido como true até que o método assíncrono networkService.attemptRegistration seja concluído.

Como você criou um NetworkServiceImpl simulado em que o método attemptRegistration aguardará um segundo antes de retornar um registro bem-sucedido, basta usar o Grand Central Dispatch (GCD) e utilizar o método asyncAfter para verificar sua declaração após um segundo. Depois de adicionar o asyncAfter do GCD, nosso código de teste ficará assim:

 func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") } }

Se você prestou atenção, saberá que isso ainda não funcionará porque o método de teste será executado antes que o bloco asyncAfter seja executado e o método sempre passará com sucesso como resultado. Felizmente, existe a classe XCTestException .

Vamos reescrever nosso método para usar a classe XCTestException :

 func testAttemptRegistration() { let exp = expectation(description: "Check registration attempt") registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") exp.fulfill() } waitForExpectations(timeout: 4.0) { error in if let _ = error { XCTAssert(false, "Timeout while attempting a registration") } } }

Com testes de unidade cobrindo nosso RegistrationViewModel , agora você pode ter mais certeza de que adicionar novas funcionalidades ou atualizar as existentes não prejudicará nada.

Nota importante: Os testes unitários perderão seu valor se não forem atualizados quando a funcionalidade dos métodos que eles cobrem for alterada. Escrever testes de unidade é um processo que precisa acompanhar o restante do aplicativo.

Dica: Não adie a redação dos testes até o final. Escreva testes durante o desenvolvimento. Assim você terá uma melhor compreensão do que precisa ser testado e quais são os casos de fronteira.

Escrevendo testes de interface do usuário

Depois que todos os testes de unidade forem totalmente desenvolvidos e executados com sucesso, você pode ter certeza de que cada unidade de código está funcionando corretamente, mas isso significa que seu aplicativo como um todo está funcionando conforme o esperado?

É aí que entram os testes de integração, dos quais os testes de UI são um componente essencial.

Antes de começar com o teste de interface do usuário, é necessário que haja alguns elementos de interface do usuário e interações (ou histórias de usuário) para testar. Vamos criar uma visão simples e seu controlador de visão.

  1. Abra o Main.storyboard e crie um controlador de visualização simples que se parecerá com o da imagem abaixo.

Imagem: Criando uma visualização simples e seu controlador de visualização.

Defina a tag de campo de texto de e-mail para 100, a tag de campo de texto de senha para 101 e a tag de confirmação de senha para 102.

  1. Adicione um novo arquivo de controlador de visualização RegistrationViewController.swift e conecte todas as tomadas com o storyboard.
 import UIKit class RegistrationViewController: UIViewController, UITextFieldDelegate { @IBOutlet weak var emailTextField: UITextField! @IBOutlet weak var passwordTextField: UITextField! @IBOutlet weak var passwordConfirmationTextField: UITextField! @IBOutlet weak var registerButton: UIButton! private struct TextFieldTags { static let emailTextField = 100 static let passwordTextField = 101 static let confirmPasswordTextField = 102 } var viewModel: RegisterationViewModel? override func viewDidLoad() { super.viewDidLoad() emailTextField.delegate = self passwordTextField.delegate = self passwordConfirmationTextField.delegate = self bindViewModel() } }

Aqui você está adicionando IBOutlets e uma estrutura TextFieldTags à classe.

Isso permitirá que você identifique qual campo de texto está sendo editado. Para fazer uso das propriedades dinâmicas no modelo de exibição, você deve 'vincular' propriedades dinâmicas no controlador de exibição. Você pode fazer isso no método bindViewModel :

 fileprivate func bindViewModel() { if let viewModel = viewModel { viewModel.registrationEnabled.bindAndFire { self.registerButton.isEnabled = $0 } } }

Vamos agora adicionar um método delegado de campo de texto para acompanhar quando qualquer um dos campos de texto está sendo atualizado:

 func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { guard let viewModel = viewModel else { return true } let newString = (textField.text! as NSString).replacingCharacters(in: range, with: string) switch textField.tag { case TextFieldTags.emailTextField: viewModel.emailAddress = newString case TextFieldTags.passwordTextField: viewModel.password = newString case TextFieldTags.confirmPasswordTextField: viewModel.passwordConfirmation = newString default: break } return true }
  1. Atualize AppDelegate para vincular o controlador de exibição ao modelo de exibição apropriado (observe que esta etapa é um requisito da arquitetura MVVM). O código AppDelegate atualizado deve ficar assim:
 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { initializeStartingView() return true } fileprivate func initializeStartingView() { if let rootViewController = window?.rootViewController as? RegistrationViewController { let networkService = NetworkServiceImpl() let viewModel = RegisterationViewModel(networkService: networkService) rootViewController.viewModel = viewModel } }

O arquivo de storyboard e o RegistrationViewController são realmente simples, mas são adequados para demonstrar como funciona o teste automatizado de UI.

Se tudo estiver configurado corretamente, o botão de registro deve ser desativado quando o aplicativo for iniciado. Quando, e somente quando, todos os campos estiverem preenchidos e válidos, o botão de registro deve ser habilitado.

Depois de configurado, você pode criar seu primeiro teste de interface do usuário.

Nosso teste de interface do usuário deve verificar se o botão Registrar será ativado se e somente se um endereço de e-mail válido, uma senha válida e uma confirmação de senha válida tiverem sido inseridos. Veja como configurar isso:

  1. Abra o arquivo TestingIOSUITests.swift .
  2. Exclua o método testExample() e adicione um método testRegistrationButtonEnabled() .
  3. Coloque o cursor no método testRegistrationButtonEnabled como se fosse escrever algo lá.
  4. Pressione o botão Record UI test (círculo vermelho na parte inferior da tela).

Imagem: Captura de tela mostrando o botão de teste Record UI.

  1. Quando o botão Gravar é pressionado, o aplicativo será iniciado
  2. Depois que o aplicativo for iniciado, toque no campo de texto do e-mail e escreva '[email protected]'. Você notará que o código está aparecendo automaticamente dentro do corpo do método de teste.

Você pode gravar todas as instruções da interface do usuário usando esse recurso, mas pode descobrir que escrever instruções simples manualmente será muito mais rápido.

Este é um exemplo de uma instrução do gravador para tocar em um campo de texto de senha e inserir um endereço de e-mail '[email protected]'

 let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]")
  1. Depois que as interações da interface do usuário que você deseja testar forem gravadas, pressione o botão Parar novamente (o rótulo do botão de gravação mudou para parar quando você começou a gravar) para interromper a gravação.
  2. Depois de ter seu gravador de interações da interface do usuário, agora você pode adicionar vários XCTAsserts para testar vários estados do aplicativo ou elementos da interface do usuário.

Imagem: Animação mostrando uma instrução do gravador para tocar em um campo de senha.

As instruções gravadas nem sempre são autoexplicativas e podem até tornar todo o método de teste um pouco difícil de ler e entender. Felizmente, você pode inserir manualmente as instruções da interface do usuário.

Let's create the following UI instructions manually:

  1. User taps on the password text field.
  2. User enters a 'password'.

To reference a UI element, you can use a placeholder identifier. A placeholder identifier can be set in the storyboard in the Identity Inspector pane under Accessibility. Set the password text field's accessibility identifier to 'passwordTextField'.

The password UI interaction can now be written as:

 let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password")

There is one more UI interaction left: the confirm password input interaction. This time, you'll reference the confirm password text field by its placeholder. Go to storyboard and add the 'Confirm Password' placeholder for the confirm password text field. The user interaction can now be written like this:

 let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password")

Now, when you have all required UI interactions, all that is left is to write a simple XCTAssert (the same as you did in unit testing) to verify if the Register button's isEnabled state is set to true. The register button can be referenced using its title. Assert to check a button's isEnabled property looks like this:

 let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled")

The whole UI test should now look like this:

 func testRegistrationButtonEnabled() { // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password") let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }

If the test is run, Xcode will start the simulator and launch our test application. After the application is launched, our UI interaction instructions will be run one by one and at the end the assert will be successfully asserted.

To improve the test, let's also test that the isEnabled property of the register button is false whenever any of the required fields have not been not entered correctly.

The complete test method should now look like this:

 func testRegistrationButtonEnabled() { let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("pass") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") confirmPasswordTextField.typeText("word") // the whole confirm password word will now be "password" XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }

Tip: The preferred way to identify UI elements is by using accessibility identifiers. If names, placeholders, or some other property that can be localized is used, the element won't be found if a different language is used in which case the test would fail.

The example UI test is very simple, but it demonstrates the power of automated UI testing.

The best way to discover all possibilities (and there are many) of the UI testing framework included in Xcode is to start writing UI tests in your projects. Start with simple user stories, like the one shown, and slowly move to more complex stories and tests.

Become a Better Developer by Writing Good Tests

From my experience, learning and trying to write good tests will make you think about other aspects of development. It will help you become a better iOS developer altogether.

To write good tests, you will have to learn how to better organize your code.

Organized, modular, well-written code is the main requirement for successful and stress-free unit and UI testing.

In some cases, it is even impossible to write tests when code is not organized well.

When thinking about application structure and code organization, you'll realize that by using MVVM, MVP, VIPER, or other such patterns, your code will be better structured, modular, and easy to test (you will also avoid Massive View Controller issues).

When writing tests, you will undoubtedly, at some point, have to create a mocked class. It will make you think and learn about the dependency injection principle and protocol-oriented coding practices. Knowing and using those principles will notably increase your future projects' code quality.

Once you begin writing tests, you will probably notice yourself thinking more about corner cases and edge conditions as you write your code. This will help you eliminate possible bugs before they become bugs. Thinking about possible issues and negative outcomes of methods, you won't only test positive outcomes, but you will also start to test negative outcomes too.

As you can see, unit tests can have impact on different development aspects, and by writing good unit and UI tests, you will likely become a better and happier developer (and you won't have to spend as much time fixing bugs).

Start writing automated tests, and eventually you'll see the benefits of automated testing. When you see it for yourself, you'll become its strongest advocate.