iOS

18 set, 2018

iOS – TDD com arquitetura MVP

Publicidade

TDD, do inglês Test Driven Development, como próprio nome sugere, é uma abordagem de desenvolvimento guiada por testes. Resumidamente, primeiro projetamos os testes para validar as funcionalidades que serão desenvolvidas.

Particularmente, costumo aplicar a metodologia “green and red validation” que resume-se em criar inicialmente testes que quebrem (red) e após isto, adicionar o código necessário para que esses testes sejam ‘green’.

Lembre-se: TDD não é desenvolver testes após vocês já ter feito todas as suas features, e sim fazer as mesmas com base nos seus testes. Inicialmente isso parece um pouco confuso e acaba-se adicionando testes apenas para alguns métodos que retornam algum valor ou objeto, tendo uma baixa cobertura.

Já ouvi que TDD é utopia no mercado de trabalho e posso garantir por experiência própria que não. Não deixe de começar a estudar e implementar testes com base neste pensamento; o mercado é rápido e requer estudo contínuo, além da utilização de boas práticas.

Para um start point nesta abordagem guiada por testes, resolvi escolher uma arquitetura que não tivesse uma alta burocracia (como VIPER) para um app de pequeno porte e possuísse ao mesmo tempo um aspecto clean para facilitar nosso trabalho, ou seja, camadas que não estejam atadas, resultando minha escolha no MVP.

Entendendo o MVP

Parece ficar claro que, pelo nome, a arquitetura MVP é baseada em três camadas:

M: Model
V: View
P: Presenter

Faz sentindo, certo?

Não pretendo explicar detalhadamente todo o conceito do MVP e sim alguns pontos mais críticos.

Atenção aos Protocols, são core de diversas arquiteturas utilizadas no mercado.

Então agora vamos nos aprofundar em aspectos que considero importantes para um real entendimento dessa arquitetura.

A View é considerada burra. Caso isso pareça estranho para você, uma classe burra é uma classe que não apresenta regras de negócio. Neste caso, a View não processa dados, ela apenas exibe o que é recebido pelo presenter e reage às ações do usuário, repassando-as ao Presenter para que o mesmo decida como processar essas ações.

O Presenter não conhece UIKit. Ou seja: sabemos que o presenter irá executar as regras de negócio desta arquitetura, porém, por não possuir conhecimento do UIKit, é impedido de obter uma referência da UIViewControllere executar a navegação (que também não será executada pela View).

Essas características acabam limitando os desenvolvedores à abordagens que por vezes acabam quebrando estes conceitos (não recomendo), como adicionar UIKit no presenter ou até mesmo navegar pela UIViewController.

Porém, seguindo para as abordagens não tão boas, há melhores; seja criando uma camada de navegação como um FlowController ou até mesmo um Router (muito conhecido para quem já trabalha com VIPER).

Por se tratar de um app introdutório focado em testes e não em escalabilidade, flexibilidade e eficiência, escolhi o que para mim me parecia mais simples de implementar, e neste caso adicionei um Router onde, logicamente, também serão executados testes, chegando ao seguinte resultado:

Mãos na massa!

Para abranger algumas situações de teste, achei legal uma cena no estilo “alteração de senha” envolvendo três componentes diferentes, como UITextField, UILabel e UIButton. Resultando nesta tela ganhadora milhares de prêmios de Design:

Tendo nosso Mock de layout final, podemos iniciar a terraplanagem, criando a seguinte hierarquia de arquivos com adição do Router, correspondendo o que foi explicado anteriormente:

Bom, não sendo um grande fã de Interface Builder simplesmente por não conseguir me sentir muito bem com constraints visuais e sentir que por código as coisas ficam bem mais ‘claras’ e flexíveis, optei por uma abordagem de layout totalmente programática (com exceção da launch screen).

Vamos iniciar removendo o storyboard, e removendo o Main Interface.

Feito isso, não se esqueça de reviver seu app! Inicialize seu módulo root no AppDelegate.

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        
        setRootViewController()
        return true
    }

    private func setRootViewController() {
        window = UIWindow(frame: UIScreen.main.bounds)
        
        let model = EditFieldsPresenter.ViewModel(
            firstFieldTitle: "Password",
            secondFieldTitle: "New Password",
            firstInputPlaceholder: "Type your current password",
            secondInputPlaceholder: "Type your new password",
            saveActionTitle: "Save"
        )
        let router = EditFieldsRouter()
        let viewController = router.buildModule(model: model)
        router.view = viewController
        
        window?.rootViewController = viewController
        window?.makeKeyAndVisible()
    }
}

Analisando as inicializações, você percebe que o Router está guardando uma referência da View para navegação e o Presenter guardando a referência do Router, sem precisar saber nada de UIKit. Fácil!

A ideia é que você passe como parâmetros para o módulo, um título e um placeholder para cada seção de input, além do título do botão que será utilizado para salvar. A regra para validação é que o usuário digite a senha atual de acordo com a do “sessão do usuário”, simulando uma session de login contendo os dados do usuário. Além disso, a nova senha não pode ser igual nem mesmo vazia.

import Foundation

class UserSession {   
    static let shared = UserSession.init()
    let password = "highsecret"
}

Ok! Estrutura montada, regras de negócio esclarecidas; hora de criar nossa folha de testes. Existem diversas abordagens para iniciar sua cobertura de testes. Cada desenvolvedor pode defender um ponto ou outro e acredito que a abordagem que estou mostrando aqui não seja a bala de prata, valendo sempre a mente aberta para outras perspectivas.

Costumo partir pelo contexto da View sendo inicializada, ou seja, recebendo os parâmetros da inicialização e passando para os componentes. Para isso testamos se cada parâmetro recebido na inicialização é o que está sendo recebido na View e se está sendo recebido no método correto.

A segunda etapa está mais ligada às ações que são tomadas na tela em questão. Ou seja, o que você espera quando o usuário clicar em salvar?

  • Caso a validação falhe?
  • Caso a validação tenha sucesso?
  • Navegará para outra tela?
  • Passará algum valor para a tela navegada?
  • Apresentará um alerta?

Você começa a perceber que a partir de uma ação, começam a ser criados contextos, alguns podendo ser simplesmente descartados por questões de funcionamento do app em si, como a questão de navegar para outra tela ou apresentar um Alert ao usuário ou questões de verificar se o Alert de erro está sendo apresentado realmente quando há o erro e o de sucesso realmente na ocasião de sucesso.

Lógicamente, é testado dentro dos contextos propostos, se as ações do Router estão sendo chamadas de maneira correta e se a passagem de valores pelo mesmo também estão corretas, garantindo uma navegação sólida, além de uma melhor abrangência de testes.

Para um template mais genérico, resolvi não integrar biblioteca de terceiros, utilizando XCTest nativo.

O resultado da folha de testes para a tela de input de dados foi o seguinte:

import XCTest
@testable import UnitTestsBasicMVP

class EditFieldsUnitTests: XCTestCase {

    var view: EditFieldsViewSpy!
    var presenter: EditFieldsPresenter!
    var router: EditFieldsWireframeSpy!
    
    func setup() {
        
        let model = EditFieldsPresenter.ViewModel(
            firstFieldTitle: "Password",
            secondFieldTitle: "New Password",
            firstInputPlaceholder: "Current password",
            secondInputPlaceholder: "New password",
            saveActionTitle: "Save"
        )
        
        view = EditFieldsViewSpy()
        router = EditFieldsWireframeSpy()
        presenter = EditFieldsPresenter(router: router, model: model)
        
        presenter.attatchView(view: view)
    }
    
    override func setUp() {
        setup()
    }
    
    //********************************************************//
    //* Testing if Cur.Password label got the right title *//
    //********************************************************//
    
    func testfirstFieldTitlePassed(){
        XCTAssert(
            view.firstFieldTitlePassed == "Password",
            "Incorrect title passed to the first field"
        )
    }
    
    func testSetFirstFieldTitleCalled (){
        XCTAssert(
            view.setFirstFieldTitleCalled == true,
            "Not calling the correct method to set first field Title"
        )
    }
    
    //********************************************************//
    //* Testing if Cur.Password txtField got the right value *//
    //********************************************************//
   
    func testFirstInputPlaceholderPassed() {
        XCTAssert(
            view.firstInputPlaceholderPassed == "Current password",
            "Incorrect value passed to the first input field"
        )
    }
    
    func testSetFirstInputPlaceholderCalled() {
        XCTAssert(
            view.setFirstInputPlaceholderCalled == true,
            "Not calling the correct method"
        )
    }
    
    //********************************************************//
    //* Testing if New Password label got the right title *//
    //********************************************************//
    
    func testSecondFieldTitlePassed() {
        XCTAssert(
            view.secondFieldTitlePassed == "New Password",
            "Incorrect title passed to the first field"
        )
    }
    
    func testSetSecondFieldTitleCalled() {
        XCTAssert(
            view.setSecondFieldTitleCalled == true,
            "Not calling the correct method"
        )
    }
    
    //********************************************************//
    //* Testing if New Password txtField got the right value *//
    //********************************************************//
    
    func testSecondInputPlaceholderPassed() {
        XCTAssert(
            view.secondInputPlaceholderPassed == "New password",
            "Incorrect value passed to the second input field"
        )
    }
    
    func testSetSecondInputPlaceholderCalled() {
        XCTAssert(
            view.setSecondInputPlaceholderCalled == true,
            "Not calling the correct method"
        )
    }
    
    //********************************************************//
    //* Testing if the Save action is correctly set          *//
    //********************************************************//
    
    func testSaveButtonTitlePassed() {
        XCTAssert(
            view.saveButtonTitlePassed == "Save",
            "Incorrect value passed to the current button"
        )
    }
    
    func testSetSaveButtonTitleCalled() {
        XCTAssert(
            view.setSaveButtonTitleCalled == true,
            "Not calling the correct method"
        )
    }

    //********************************************************//
    //* Testing some contexts when saving the new password   *//
    //********************************************************//
    
    func testContextNewPasswordDifferentNotEmpty() {
        presenter.firstInputDidChange("highsecret")
        presenter.secondInputDidChange("pass2")
        presenter.saveActionTouched()
        
        XCTAssert(
            router.showResultScreenCalled == true,
            "Not calling the correct method"
        )
        
        XCTAssert(
            router.resultPassed == true,
            "result success"
        )
    }
    
    func testContextNewPasswordEquals() {
        presenter.firstInputDidChange("hightsecret")
        presenter.secondInputDidChange("highsecret")
        presenter.saveActionTouched()
        
        XCTAssert(
            router.showResultScreenCalled == true,
            "Not calling the correct method"
        )
        
        XCTAssert(
            router.resultPassed == false,
            "result fail"
        )
    }
    
    func testContextNewPasswordEmpty() {
        presenter.firstInputDidChange("highsecret")
        presenter.secondInputDidChange("")
        presenter.saveActionTouched()
        
        XCTAssert(
            router.showResultScreenCalled == true,
            "Not calling the correct method"
        )
        
        XCTAssert(
            router.resultPassed == false,
            "result fail"
        )
    }
    
}

class EditFieldsViewSpy: EditFieldsView {
    
    var firstFieldTitlePassed: String?
    var firstInputPlaceholderPassed: String?
    var secondFieldTitlePassed: String?
    var secondInputPlaceholderPassed: String?
    var saveButtonTitlePassed: String? 
    var setFirstFieldTitleCalled: Bool?
    var setFirstInputPlaceholderCalled: Bool?
    var setSecondFieldTitleCalled: Bool?
    var setSecondInputPlaceholderCalled: Bool?
    var setSaveButtonTitleCalled: Bool?
    
    func setFirstFieldTitle(_ text: String) {
        firstFieldTitlePassed = text
        setFirstFieldTitleCalled = true
    }
    
    func setFirstInputFieldPlaceholder(_ text: String) {
        firstInputPlaceholderPassed = text
        setFirstInputPlaceholderCalled = true
    }
    
    func setSecondFieldTitle(_ text: String) {
        secondFieldTitlePassed = text
        setSecondFieldTitleCalled = true
    }
    
    func setSecondInputFieldPlaceholder(_ text: String) {
        secondInputPlaceholderPassed = text
        setSecondInputPlaceholderCalled = true
    }
    
    func setSaveButtonTitle(_ text: String) {
        saveButtonTitlePassed = text
        setSaveButtonTitleCalled = true
    }
}

class EditFieldsWireframeSpy: EditFieldsWireFrame {
    
    var showResultScreenCalled: Bool?
    var resultPassed: Bool?
    
    func showResultScreen(isSuccess: Bool) {
        showResultScreenCalled = true
        resultPassed = isSuccess
    }
}

Após testar, quebrar, testar, quebrar, testar e quebrar mais um pouco.

Voilà! Testes verdes e feature se solidificando.

O interessante é que lendo a folha de testes, você começa a perceber outros cenários de testes, e isso é algo incrível que faz parte do mundo de desenvolvimento. Você verá ao longo do seu desenvolvimento (principalmente em time), que a construção de uma aplicação sólida é gradual. Muitas vezes verá testes sem sentidos, muitas vezes, verão testes que você não viu e deste modo chegarão a resultados sensacionais.

Analisando os testes é possível visualizar qual o comportamento do aplicativo. Verifica-se a existência de testes de navegação para uma tela que resulta no sucesso ou não da alteração de senha. Esta tela recebe um status boleano e exibe um modal ao usuário, que quando clicado em qualquer parte sobre ele é executado uma ação de dismiss para uma nova alteração.

A folha de testes para essa tela de resultado de validação pode ser observada a seguir.

import XCTest
@testable import UnitTestsBasicMVP

class ValidationResultUnitTests: XCTestCase {
    
    var view: ValidationResultViewSpy!
    var presenter: ValidationResultPresenter!
    var router: ValidationResultWireframeSpy!
    
    func setup(state: Bool) {
        let model = state
    
        view = ValidationResultViewSpy()
        router = ValidationResultWireframeSpy()
        presenter = ValidationResultPresenter(router: router, isSuccess: model)
        presenter.attatchView(view: view)
    }
    
    //********************************************************//
    //* Validation with success context                      *//
    //********************************************************//
    
    func testContextSuccessTitlePassed(){
        setup(state: true)
        XCTAssert(
            view.titlePassed == "Succeeded!",
            "Incorrect title passed"
        )
    }
    
    func testContextSuccessSetTitleCalled (){
        setup(state: true)
        XCTAssert(
            view.setTitleCalled == true,
            "Not calling the correct method to set the Title"
        )
    }
    
    //********************************************************//
    //* Validation with fail context                         *//
    //********************************************************//
    
    func testContextFailureTitlePassed() {
        setup(state: false)
        XCTAssert(
            view.titlePassed == "Failed!",
            "Incorrect title passed"
        )
    }
    
    func testContextFailureSetTitleCalled () {
        setup(state: false)
        XCTAssert(
            view.setTitleCalled == true,
            "Not calling the correct method to set the Title"
        )
    }
    
    //********************************************************//
    //* Testing view method to set color when success        *//
    //********************************************************//
    
    func testContextSuccessSetColorCalled (){
        setup(state: true)
        XCTAssert(
            view.setColorCalled == true,
            "Not calling the correct method to set correct color"
        )
    }
    
    //********************************************************//
    //* Testing view method to set color when failure        *//
    //********************************************************//
    
    func testContextFailureSetColorCalled (){
        setup(state: false)
        XCTAssert(
            view.setColorCalled == true,
            "Not calling the correct method to set correct color"
        )
    }
    
    //********************************************************//
    //* Verify is dismiss action is properly executed        *//
    //********************************************************//
    
    func testDismissCalled() {
        setup(state: true)
        presenter.screenTouched()
        
        XCTAssert(
            router.moduleDismissCalled == true,
            "Not calling the correct method"
        )
    }
    
}

class ValidationResultViewSpy: ValidationResultView {

    var titlePassed: String?
    var setTitleCalled: Bool?
    var setColorCalled: Bool?
    
    func setTitle(_ text: String) {
        titlePassed = text
        setTitleCalled = true
    }
    
    func setSuccessColor() {
        setColorCalled = true
    }
    
    func setFailColor() {
        setColorCalled = true
    }
}

class ValidationResultWireframeSpy: ValidationResultWireFrame {
    
    var moduleDismissCalled: Bool?

    func dismissModule() {
        moduleDismissCalled = true
    }
}

Mais um pouco de testar, quebrar, testar, quebrar, testar e quebrar mais um pouco.

Voilà! Testes verdes. Mas não sonhe com idealidade; ainda há code review e QA para a feature ser definida como pronta!

Por fim, chegamos ao seguinte resultado:

 

Particularmente não vejo o XCTest como a alternativa mais “matadora” para testes, principalmente para equipes com QAs. Aconselho a utilização do Quick/Nimble para uma estruturação melhor dos testes, além de como os mesmos estão sendo descritos.

Deixo, por fim, o link do repositório proposto e do Quick/Nimble para curiosos: