Desenvolvimento

12 jan, 2017

Criando um app iOS da Marvel – Parte 02

Publicidade

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:

1-h2stelwcfzkxdvstsseg-q

1-95aa2-mewhnnvw63qp-bzw

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.

1-klzgit6zap8acp7bbnvmsa

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:

1-yvjx2ybdol2vpdis9auwra

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:

1-lamhlp21mwobbzujqa5vgw

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.

1-zdmzmeryrpjjd9jpwekxda

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/