Essa é a segunda parte de uma série de artigos na qual pretendo abordar como criar um app iOS do início, usando diversos pods e ferramentas que tornam a nossa vida mais fácil. Se você ainda não viu a primeira parte, é só clicar aqui. Hoje vou falar sobre testes, relatórios de cobertura e como automatizar o processo usando o Fastlane, entre outras coisas.
O código-fonte desse projeto está disponível neste repositório. Eu criei uma tag para esse artigo, a v0.2; então, você só precisa clonar o repositório e trocar para a tag v0.2, ok?
Primeiros passos
Antes de tudo, é melhor ter certeza que o projeto tem um target para testes unitários. Se não for o caso, nós podemos criar um só clicando em File > New > Target:
Pods de teste
Para criar os testes para valer, vou usar várias libraries diferentes. Elas podem nos ajudar bastante no processo de escrever testes, então vamos adicioná-las ao Podfile. Abaixo, você pode ver alguns pods como Quick, Nimble e Fakery, que vão melhorar muito a forma como nós lidamos com os testes.
# Uncomment the next line to define a global platform for your project platform :ios, '9.0' target 'Marvel' do # Comment the next line if you're not using Swift and don't want to use dynamic frameworks use_frameworks! plugin 'cocoapods-keys', { :project => "Marvel", :keys => [ "MarvelApiKey", "MarvelPrivateKey" ]} # Pods for Marvel pod 'SwiftGen' pod 'RxSwift', '~> 3.0.0-beta.2' pod 'Moya/RxSwift','~> 8.0.0-beta.1' pod 'Moya-ObjectMapper/RxSwift', :git => 'https://github.com/ivanbruel/Moya-ObjectMapper' pod 'CryptoSwift' pod 'Dollar' pod 'Kingfisher' pod "Reusable" end target 'MarvelTests' do use_frameworks! pod 'Quick' pod 'Nimble' pod 'Fakery' pod 'ObjectMapper' end post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| config.build_settings['SWIFT_VERSION'] = '3.0' end end end
Depois disso, podemos modificar o teste inicial, adicionando a ele nossos pods de testes. Vamos manter tudo bem simples por enquanto, pra ter certeza que está rodando direitinho.
Esse teste não faz muita coisa, apenas cria uma expectativa, true igual a true, o que sempre é verdade. Pode parecer simples, mas isso serve a um propósito maior. Garantir que tudo está configurado e funcionando conforme esperado.
As pessoas que até agora só escreveram testes usando o XCTest podem achar um pouco estranha essa forma de escrever, usando describes e expects ao invés de XCAssert. Entretanto, é muito mais próximo da linguagem natural, o que pode aumentar consideravelmente a legibilidade dos seus testes. É por isso que as pessoas escolhem essa forma, afinal, depois da curva de aprendizado, escrever testes fica muito mais fácil e mais intuitivo, o que aumenta a adoção no seu projeto.
Uma coisa é certa: se os testes forem difíceis de escrever, ninguém vai escrever! O objetivo final dos frameworks de testes como Quick e Nimble é diminuir a curva de aprendizado o suficiente para que as pessoas escrevam.
Próximo passo: automatizar antes de complicar
Antes de adicionar qualquer outro teste, vamos ter certeza que nós podemos rodar os que já existem e gerar o relatório de cobertura do código da linha cmd. Com isso, vamos garantir que teremos tudo configurado do jeito certo, enquanto a complexidade ainda é baixa. Assim, se alguma coisa não estiver certa, nós teremos poucos pontos para procurar o que está acontecendo.
Esse passo é muito importante, pois automatizando nosso pipeline de desenvolvimento nós podemos garantir que mais tarde vai ser muito mais fácil adicionar um processo de integração contínua.
O Fastlane é uma ferramenta incrível, que pode nos ajudar com isso. Hoje vamos usar duas de suas actions, o Scan para rodar os testes e o Slather para gerar o relatório de cobertura.
Vamos com calma! Mais algumas configurações
Antes de entrar no processo de automação, vamos garantir que todo mundo está na mesma página, o que significa ter certeza de que estamos usando as mesmas versões do Fastlane, Cocoapods etc. Nós podemos fazer isso com o Bundler. Vamos criar um Gemfile como esse aqui:
# frozen_string_literal: true source "https://rubygems.org" # gem "rails" gem "cocoapods", "1.1.1" gem "cocoapods-keys", "1.7.0" gem "slather", "2.3.0" gem "fastlane", "1.110.0"
E agora nós podemos rodar o cmd bundle para fazer o download e instalar essas dependências específicas:
Automatizando com Fastlane
O Fastlane é muito intuitivo. Tudo o que precisamos fazer é rodar o cmd e seguir os passos para setup. Depois que você fizer isso, vai ver uma pasta nova chamada Fastlane, com alguns arquivos. Vamos focar só no Fastfile por enquanto.
fastlane_version "1.110.0" default_platform :ios platform :ios do before_all do # ENV["SLACK_URL"] = "https://hooks.slack.com/services/..." cocoapods end desc "Runs all the tests" lane :test do scan(scheme: "Marvel") slather( output_directory: "coverage", workspace: "Marvel.xcworkspace", scheme: "Marvel", proj: "Marvel.xcodeproj", html: true ) end end
Seu Fastfile gerado vai ter mais algumas coisas, mas para este artigo vamos nos preocupar só com a “test lane”. Essa “lane” vai rodar seus testes usando as actions de Scan e gerar um relatório de cobertura usando Slather, parecido com esse aqui:
A foto mostra um relatório de cobertura do projeto na tag v0.2, com 97.16%. Nada mal, né? Estamos chegando lá.
Agora, os testes. Vamos começar com uma camada modelo. Assim, podemos mapear a resposta da API e conseguimos carregar o json nos nossos testes e criar nossos modelos a partir dele. Para isso, vamos criar no nosso teste target uma struct chamada MockLoader.
import Foundation import ObjectMapper @testable import Marvel struct MockLoader { let data: Data let json: String init?(file: String, withExtension fileExt: String = "json", in bundle:Bundle = Bundle.main) { guard let path = bundle.path(forResource: file, ofType: fileExt) else { return nil } let pathURL = URL(fileURLWithPath: path) do { data = try Data(contentsOf: pathURL, options: .dataReadingMapped) if let decoded = NSString(data: data, encoding: 0) as? String { json = decoded } else { return nil } } catch{ return nil } } } extension MockLoader { func map<T: Mappable>(to type: T.Type) -> T? { return Mapper<T>().map(JSONString: json) } }
Essa struct pode lidar com a complexidade de carregar um arquivo json definido no nosso projeto. Ela também é capaz de parsear o json em um modelo usando nosso framework de mapping, o ObjectMapper. Agora, nós só precisamos criar arquivos json e usar o MockLoader para recuperar essas informações em nossos testes.
O próximo passo é criar o primeiro spec de verdade. Vamos criar um CharacterSpec para testar nosso primeiro modelo.
import Quick import Nimble @testable import Marvel class CharacterSpec: QuickSpec { override func spec() { describe("a character") { var character: Marvel.Character! beforeEach { let testBundle = Bundle(for: type(of: self)) let mockLoader = MockLoader(file: "character", in: testBundle) character = mockLoader?.map(to: Character.self) } it("should be able to create a chracter from json") { expect(character).toNot(beNil()) } it("should have a thumbImage") { expect(character.thumImage).toNot(beNil()) } } } }
c
Não tem muita coisa acontecendo aqui, mas mesmo esse pequeno teste pode atestar que o nosso modelo pode ser usado para mapear nossa resposta à API. Nós podemos afirmar isso com confiança, mesmo sem ter realizado até o momento qualquer chamada à API. Isso mostra o quão poderosos e úteis os testes podem ser.
Testar esse tipo de coisa manualmente, sem testes automatizados, além de repetitivo, é bem propenso a erros. Existem muitos pontos no caminho nos quais as coisas podem sair do controle e se comportar de forma inesperada, como problemas de internet, com as camadas de rede, erros de chamada à API etc. Os testes podem nos ajudar a evitar tudo isso e focar no que realmente queremos que, neste caso, é criar um modelo a partir de um json.
Não vou cobrir todos os testes nesse projeto, se não esse artigo vai ficar enorme. Mas eu sugiro que você clone o projeto e confira todos eles. Agora, vamos passar para o próximo tipo de teste, as view controllers.
Testando View Controllers
Aqui tem um exemplo maior, com um monte de coisa acontecendo. Vamos abordar a essência e falar sobre algumas partes importantes.
import Quick import Nimble @testable import Marvel struct MarvelAPICallsMock: MarvelAPICalls { let characters: [Marvel.Character] func characters(query: String? = nil, completion: @escaping ([Marvel.Character]?) -> Void) { completion(characters) } } class CharactersViewControllerSpec: QuickSpec { override func spec() { describe("CharactersViewController") { var controller: CharactersViewController! var apiMock: MarvelAPICalls! beforeEach { let testBundle = Bundle(for: type(of: self)) let mockLoader = MockLoader(file: "character", in: testBundle) let character = (mockLoader?.map(to: Character.self))! apiMock = MarvelAPICallsMock(characters: [character]) controller = Storyboard.Main.charactersViewControllerScene.viewController() as! CharactersViewController controller.apiManager = apiMock //Load view components let _ = controller.view } it("should have expected props setup") { controller.viewDidLoad() expect(controller.apiManager).toNot(beNil()) expect(controller.tableDatasource).toNot(beNil()) expect(controller.tableDelegate).toNot(beNil()) expect(controller.collectionDatasource).to(beNil()) expect(controller.collectionDelegate).to(beNil()) expect(controller.characters).toNot(beNil()) expect(controller.searchBar).toNot(beNil()) expect(controller.activityIndicator).toNot(beNil()) expect(controller.tableView).toNot(beNil()) expect(controller.collectionView).toNot(beNil()) } it("should use mock response on fetchCharacters") { controller.viewDidLoad() let count = controller.tableDatasource?.items.count ?? 0 expect(count).toEventually(equal(1)) } it("should be able to display content as tableView") { controller.viewDidLoad() controller.showAsTable(UIButton()) expect(controller.collectionView.isHidden).to(beTruthy()) expect(controller.tableView.isHidden).to(beFalsy()) } it("should be able to display content as collectionView") { controller.viewDidLoad() controller.showAsGrid(UIButton()) expect(controller.tableView.isHidden).to(beTruthy()) expect(controller.collectionView.isHidden).to(beFalsy()) } context("Empty search") { it("should not fetchCharacters when no searchTerm is provided") { controller.searchBar.text = "" let searchBar = controller.searchBar controller.characters = [] controller.searchBarSearchButtonClicked(searchBar!) expect(controller.characters.isEmpty).to(beTruthy()) } } context("Not empty search") { it("should fetchCharacters when searchTerm is provided") { controller.searchBar.text = "searchThis" let searchBar = controller.searchBar controller.characters = [] controller.searchBarSearchButtonClicked(searchBar!) expect(controller.characters.isEmpty).to(beFalsy()) } } it("should hide keyboard with click on searchbar cancel button") { let searchBar = controller.searchBar! searchBar.becomeFirstResponder() controller.searchBarCancelButtonClicked(searchBar) expect(searchBar.isFirstResponder).to(beFalsy()) } context("didSelectCharacter") { beforeEach { let navController: UINavigationController = Storyboard.Main.initialViewController() controller = navController.viewControllers.first as! CharactersViewController controller.apiManager = apiMock let _ = controller.view controller.viewDidLoad() } it("should navigate do next controller when selecting a character") { let indexPath = IndexPath(row: 0, section: 0) let controllerCounts = controller.navigationController?.viewControllers.count expect(controllerCounts).to(equal(1)) controller.didSelectCharacter(at: indexPath) expect(controller.navigationController?.viewControllers.count ?? 0) .toEventually(equal(2), timeout: 3) } } } } }
O bloco de beforeEach é o lugar onde podemos configurar as coisas necessárias para todas as assertivas e expectativas que vamos criar.
Primeiro, precisamos carregar nosso controller, o que queremos testar, e nós podemos fazer isso facilmente usando o enum Storyboard. Depois, precisamos mockar a API; isso garantirá que nós estaremos rodando sem nenhuma dependência externa, como rede, por exemplo. Nós podemos fazer isso usando um setter injection. Deste momento em diante, as chamadas à API vão ser tratadas pela nossa classe de mock, que vai retornar o Json do arquivo.
Tem uma coisa importante sobre testes unitários: eles devem rodar em um ambiente controlado, o que significa sem chamadas de rede, sem acesso a banco de dados etc.
Tem mais uma coisa que precisamos fazer como parte da receita para garantir que todos os nossos componentes de view sejam carregados. A instrução abaixo tem essa intenção:
//Load view components let _ = controller.view
Os outros testes são intuitivos e mudarão muito de projeto para projeto, por isso não vou cobri-los em detalhes. Entretanto, tem uma lição importante aqui. Você já ficou perdido, se perguntando sobre o que testar depois? A resposta é simples: procure por dicas no relatório de cobertura. O relatório vai mostrar áreas e funções do seu projeto que não estão cobertas. Para mim, essa é a melhor função de um relatório de cobertura. É muito mais que uma métrica por si só e deveria ser usada para guiar seus testes, assim como seus testes deveriam ser usados para refatorar e melhorar seu código.
Aviso: Se você está vindo do Objective-C, assim como eu, você deve estar se perguntando por que é que não estou usando um framework de mock. Acredite, eu me perguntei sobre isso também. Embora o Swift tenha alguns pods de mock, se você está adotando protocolos no seu código, você não vai realmente precisar deles. Tudo o que você precisa é criar classes customizadas e structs no seu target de teste que estejam de acordo com o protocolo, assim como fiz acima, e substituir a implementação real por essa. Dessa forma tudo vai ficar mais simples. É verdade que algumas coisas são difíceis de fazer em Swift – o velho “parcial mock” do OCMock era super útil. Dito isso, a comunidade Swift tem uma filosofia de testar só coisas públicas, o que faz sentido. Então, se você está tendo uma experiência ruim com seus testes, talvez o que você realmente precisa é focar em refatorar a implementação.
Testando datasources
Esse projeto usa uma técnica chamada de (na falta de um nome melhor) “external datasource”. De fato, é só um jeito de remover a lógica de datasources e delegates da view controller, evitando a criação de “massive” view controllers no processo, que possuem muita responsabilidade. Esse tipo de design tem outros benefícios, como já contei na parte 1 desse artigo.
Abaixo estão os testes dos datasources e delegates. Não tem nada de realmente novo.
import Foundation import Quick import Nimble @testable import Marvel class CharactersDatasourceSpec: QuickSpec { override func spec() { describe("CharactersDatasource") { var controller: CharactersViewController! var character: Marvel.Character! beforeEach { let testBundle = Bundle(for: type(of: self)) let mockLoader = MockLoader(file: "character", in: testBundle) character = (mockLoader?.map(to: Character.self))! let apiMock = MarvelAPICallsMock(characters: [character]) controller = Storyboard.Main.charactersViewControllerScene.viewController() as! CharactersViewController controller.apiManager = apiMock //Load view components let _ = controller.view } it("should have a valid datasource") { expect(controller.tableDatasource).toNot(beNil()) } it("should have a cell of expected type") { let indexPath = IndexPath(row: 0, section: 0) let cell = controller.tableDatasource!.tableView(controller.tableView, cellForRowAt: indexPath) expect(cell.isKind(of: CharacterTableCell.self)).to(beTruthy()) } it("should have a configured cell") { let indexPath = IndexPath(row: 0, section: 0) let cell = controller.tableDatasource!.tableView(controller.tableView, cellForRowAt: indexPath) as! CharacterTableCell let name = cell.name.text! expect(name).to(equal(character.name)) } it("should have the right numberOfRowsInSection") { let count = controller.tableDatasource!.tableView(controller.tableView, numberOfRowsInSection: 0) expect(count).to(equal(1)) } } } }
import Foundation import Quick import Nimble @testable import Marvel class CharactersDatasourceSpec: QuickSpec { override func spec() { describe("CharactersDatasource") { var controller: CharactersViewController! var character: Marvel.Character! beforeEach { let testBundle = Bundle(for: type(of: self)) let mockLoader = MockLoader(file: "character", in: testBundle) character = (mockLoader?.map(to: Character.self))! let apiMock = MarvelAPICallsMock(characters: [character]) controller = Storyboard.Main.charactersViewControllerScene.viewController() as! CharactersViewController controller.apiManager = apiMock //Load view components let _ = controller.view } it("should have a valid datasource") { expect(controller.tableDatasource).toNot(beNil()) } it("should have a cell of expected type") { let indexPath = IndexPath(row: 0, section: 0) let cell = controller.tableDatasource!.tableView(controller.tableView, cellForRowAt: indexPath) expect(cell.isKind(of: CharacterTableCell.self)).to(beTruthy()) } it("should have a configured cell") { let indexPath = IndexPath(row: 0, section: 0) let cell = controller.tableDatasource!.tableView(controller.tableView, cellForRowAt: indexPath) as! CharacterTableCell let name = cell.name.text! expect(name).to(equal(character.name)) } it("should have the right numberOfRowsInSection") { let count = controller.tableDatasource!.tableView(controller.tableView, numberOfRowsInSection: 0) expect(count).to(equal(1)) } } } }
Pra terminar
Neste artigo, eu usei várias técnicas e ferramentas que você pode incorporar ao seu projeto para testá-lo e melhorar a sua qualidade. Os testes criam uma “safety net” para o seu código, uma que você realmente pode confiar em qualquer momento. Daqui a seis meses, quando ninguém lembrar dos detalhes da implementação, você tiver confiança para mudá-la e se os testes não falharem, tudo estará funcionando como deveria – e isso por si só não tem preço.
No próximo artigo, vou falar sobre integração contínua e porque você deveria querer em seu projeto. Também vou falar sobre o Danger e como ele pode ser usado para melhorar a colaboração no seu time dentro do seu repositório.
Aviso: É muito importante para seguir esses passos clonar o projeto e brincar com ele, pois eu não vou conseguir colocar todas as informações em um artigo só, ok? Ficaria gigante e inteligível.
Como sempre, feedbacks, dúvidas e opiniões são bem-vindas! =)
***
Esse artigo foi originalmente publicado (em inglês) na Cocoa Academy, no Medium. Veja aqui.
***
Este artigo foi publicado originalmente em: http://www.concretesolutions.com.br/2016/12/23/app-ios-do-inicio-parte-2/