Alura > Cursos de Mobile > Cursos de iOS > Conteúdos de iOS > Primeiras aulas do curso iOS: aplicando testes de unidade com mocks, stubs e outros frameworks

iOS: aplicando testes de unidade com mocks, stubs e outros frameworks

Teste de classe com dependência - Apresentação

Olá, gostaríamos de dar as boas-vindas a mais um curso de iOS na Alura com Ândriu Coelho.

Audiodescrição: Ândriu é uma pessoa branca de olhos e cabelos castanhos. Tem barba e bigode e usa uma camiseta azul escuro com o logotiop da Alura. Ao fundo, parede branca e lisa sem decorações.

O que vamos aprender?

A ideia é continuar o desenvolvimento do aplicativo Chef Delivery, que já começamos a testar no curso anterior. Queremos avançar a escrita dos testes com classes mais complexas.

Vamos entender como testamos classes que possuem dependências. Nesse momento, vamos introduzir os conceitos de stub (imitador) e mock (simulador), além aprender a como injetar um imitador via dependência.

A ideia é que a classe não saiba qual é a classe de produção ou qual é o imitador que estamos usando. Para isso, também iremos trabalhar com protocolos.

Depois, vamos aprender como testar métodos assíncronos, ou seja, métodos que demoram para responder. E abordaremos como fazer o assert (afirmação) nesse tipo de método.

Vamos avançar também falando sobre variáveis que têm a keyword (palavra-chave) Published, ou seja, variáveis que podem emitir ou receber eventos. Assim, entenderemos como testar esse tipo de variável.

Por último, vamos falar também sobre testes da camada de serviço, a camada que faz de fato a requisição para o servidor e devolve a resposta. Como testamos esse tipo de classe? Vamos usar uma biblioteca para nos ajudar nesse tipo de teste.

Pré-requisitos

Como pré-requisito, é importante que você tenha familiaridade com a linguagem Swift, que saiba trabalhar com layouts usando SwiftUI e que saiba também fazer requisições HTTP.

Esperamos você na primeira aula!

Teste de classe com dependência - Testando classe com dependências

Para iniciar nosso curso, vamos continuar com o desenvolvimento do projeto Chef Delivery, que já começamos no curso anterior.

Mesmo que você tenha feito o curso anterior de testes, é importante que você faça o download do projeto novamente, pois fizemos pequenos ajustes no projeto. O projeto estará disponível para download nas atividades do curso.

Já havíamos escrito alguns testes simples para o Chef Delivery, que foram úteis para estudarmos os fundamentos dos testes de unidade. Porém, a partir de agora, a ideia é aprofundar nossos conhecimentos sobre testes.

Testando classes com dependências

Na classe SearchStoreViewModel, já havíamos escrito um teste para o filteredStores(), que é o método que filtra os restaurantes e os estabelecimentos na lista. Testamos o fluxo comum, que é o caminho feliz, e também o fluxo de exceção, caso ocorra algum erro.

SearchStoreViewModel.swift:

class SearchStoreViewModel: ObservableObject {

    // MARK: - Attributes

    let service = SearchService()
    @Published var storesType: [StoreType] = []
    @Published var searchText: String = ""

    init() {
        fetchData()
    }

    // MARK: - Class methods

    func fetchData() {
        Task {
            do {
                let result = try await service.fetchData()
                switch result {
                case .success(let stores):
                    self.storesType = stores
                case .failure(let error):
                    print(error.localizedDescription)
                }
            } catch {
                print(error.localizedDescription)
            }
        }
    }

    func filteredStores() throws -> [StoreType] {
        if searchText.isEmpty {
            return storesType
        }

        let filteredList = storesType.filter { $0.matches(query: searchText.lowercased()) }

        if filteredList.isEmpty {
            throw SearchError.noResultsFound
        }

        return filteredList
    }
}

A ideia é continuar explorando essa classe de ViewModel inicialmente. Além do método filteredStores(), também temos um método que é o fetchData(), que é um método que chama a camada de serviço para buscar, de fato, os restaurantes em uma requisição HTTP.

Estamos usando o Apiary para simular a resposta de uma requisição, já que não temos um back-end próprio para nosso projeto. Basicamente, temos a View, que se conecta com o ViewModel, o qual chama a camada de serviço, que, por sua vez, faz a requisição e devolve a lista de restaurantes para o ViewModel.

O ViewModel possui uma variável na linha 19 chamada storesType, que é a lista de restaurantes. Assim que recebemos a resposta do servidor com os restaurantes, como é uma variável @Published, ela fica escutando qualquer alteração e notifica a View quando chegam esses novos valores.

Queremos continuar o estudo dos nossos testes a partir de agora, testando o próximo método dessa classe do ViewModel, que é o método fetchData().

Testando fetchData()

Já temos um arquivo de teste, que é o SearchStoreViewModelTest.swift, localizado no target de teste. Vamos abrir esse arquivo para começar a escrever esse novo teste.

Logo após a linha 132, após o método testFilteredStoresException(), vamos escrever um novo método de teste. Vamos começar declarando uma função através da palavra-chave func. Todo método de teste começa com o prefixo test, seguido do nome do método, que será FetchDataWithSuccess.

Entre as chaves, vamos pegar a referência para a camada de serviço usando um let searchService, que é do tipo SearchService, e já o instanciamos.

Depois, teremos os resultados da requisição armazenados em let result. Como é uma Throwing Function, vamos declarar um try. É uma função que também usa async-await, então vamos aguardar a resposta da requisição com o await. Em seguida, chamamos a camada de serviço através de searchService.fetchData().

O único detalhe é que, como usamos o async-await, precisamos marcar o método como async. Além disso, como é uma Throwing Function, devemos marcar a assinatura do método com essa keyword throws.

SearchStoreViewModelTest.swift:

func testFetchDataWithSuccess() async throws {
    let searchService = SearchService()
    
    let result = try await searchService.fetchData()
}

Em seguida, vamos fazer um switch-case para verificar qual foi o resultado dessa requisição. Caso seja sucesso, teremos a lista de lojas e restaurantes, ou seja, case .success(let stores).

Podemos fazer um assert nesse case. Faremos um XCTAssertFalse(), passando stores.IsEmpty, pois a lista não pode estar vazia.

Também existe o caso de falha, onde teremos um erro. Para isso, basta escrever case .failure(let error).

Também faremos um assert para verificar se o teste falhou nesse momento. Com ajuda do XCTFail(), informaremos que foi um "Erro ao fazer uma requisição".

func testFetchDataWithSuccess() async throws {
    let searchService = SearchService()
    
    let result = try await searchService.fetchData()
    
    switch result {
    case .success(let stores):
        XCTAssertFalse(stores.isEmpty)
    case .failure(let error):
        XCTFail("Erro ao fazer uma requisição")
    }
}

Vamos conferir se o código funciona? Vamos rodar todos os testes para fazer uma primeira validação.

O primeiro teste de unidade que fizemos passou!

Duas novidades nesse teste em relação à assinatura do método:

  1. Quando usamos async-await, podemos adicionar a palavra-chave async no início do método;
  2. Quando a função pode lançar exceções (Throwing Function), também adicionamos a palavra-chave throws ao método, permitindo o uso de try dentro do teste.

Qual é o objetivo desse teste? Estamos na camada do ViewModel que faz a chamada da camada de serviço para buscar os restaurantes. Nesse teste, estamos validando se a requisição traz, de fato, a lista com os restaurantes que é o que nos interessa para devolver para a View.

Só isso nos traz alguns problemas. Escrevemos esse teste justamente para explorar essa lógica.

Ultrapassando a fronteira de unidade

Primeiro, devemos pensar muito bem em relação à unidade que estamos testando. Queremos testar o comportamento dessa classe do ViewModel, a SearchStoreViewModel.

Contudo, repare que em nenhum momento chamamos o sut, o qual utilizamos em todos os outros métodos de teste. Lembre-se que o declaramos logo no início da classe:

final class SearchStoreViewModelTests: XCTestCase {
    
    // MARK: - Attributes
    
    var sut: SearchStoreViewModel!

    // código omitido…
}

O que é o sut? É o System Under Test (Sistema Sob Teste). Na verdade, é a unidade que estamos testando no nosso código.

Estamos testando o SearchStoreViewModel, e não a camada de serviço, como declaramos na função testFetchDataWithSuccess().

Na verdade, estamos ultrapassando o limite da unidade que estamos testando. Embora o ViewModel faça referência com a camada de serviço, ele é apenas uma dependência do ViewModel. Mas, nesse caso, estamos testando a camada de serviço.

Outro ponto muito importante é que estamos fazendo a requisição de verdade, quando chamamos o fetchData(). Isso não é interessante em teste de unidade. Na verdade, evitamos fazer requisições para a API - ao invés disso, utilizamos mocks e stubs, que é o que vamos estudar mais adiante nesse curso.

Em suma, queríamos chamar atenção a esse ponto entre a unidade que estamos testando e uma outra unidade. Não podemos ultrapassar essa fronteira da unidade. Mas como vamos testar o ViewModel, já que ele depende da camada de serviço?

Vamos analisar rapidamente a dependência. Para isso, apertamos a tecla "Command" e clicamos em SearchStoreViewModel na declaração do var sut. Isso abre a classe de produção.

Na linha 18, temos já a referência dessa dependência:

SearchStoreViewModel.swift:

class SearchStoreViewModel: ObservableObject {

    // MARK: - Attributes

    let service = SearchService()

    // código omitido…
}

Será que não conseguimos, de alguma forma, usar algo que imite essa dependência, um imitador, para que possamos trocar entre usar a classe de produção e algo que simule uma resposta para usar para testes?

Teste de classe com dependência - Aplicando injeção de dependências

Acabamos de criar o primeiro teste de unidade, o testfetchDataWithSuccess(). No entanto, estávamos refletindo sobre a unidade a ser testada. Estamos falando da classe SearchStoreViewModel, que é a classe de produção para a qual estamos criando os testes de unidade.

O primeiro ponto importante que descobrimos é que essa classe possui uma dependência. Qual é a dependência? A camada de serviço em let service = SearchService(). Estamos testando uma unidade que depende de outra para conseguir realizar a sua operação. No caso, buscar os dados na camada de serviço.

Estamos testando o método fetchData() e o que nos interessa é testar o comportamento desse método, e não da camada de serviço, que parece ser algo parecido.

Vamos entrar na camada de serviço, por exemplo. O SearchService também possui um método fetchData(), o que pode confundir um pouco no início, porque, na verdade, temos o ViewModel como ponte entre a View e a camada de serviço. Então, o ViewModel chama o Service, o qual devolve a lista de restaurantes.

SearchService.swift:

struct SearchService {

    func fetchData() async throws -> Result<[StoreType], RequestError> {
        guard let url = URL(string: "https://private-11274d-chefdeliveryapi.apiary-mock.com/search") else {
            return .failure(.invalidURL)
        }

        var request = URLRequest(url: url)
        request.httpMethod = "GET"

        let (data, _) = try await URLSession.shared.data(for: request)
        let storesObjects = try JSONDecoder().decode([StoreType].self, from: data)

        return .success(storesObjects)
    }

}

Essa é outra unidade que não vamos testar no momento - vamos focar apenas no SearchStoreViewModel.

Injetando dependências

Primeiro ponto importante é que criar uma classe e instanciá-la junto com a propriedade, como é o caso de let service = SearchService(), pode dificultar a criação de testes. Isso ocorre porque a classe se torna dependente dessa instância específica.

Para facilitar os testes, vamos aprender uma técnica que chamamos de injeção de dependências para injetar tanto essa classe SearchService, quanto alguma outra que possa imitar o comportamento dessa classe.

Assim, vamos poder passar por parâmetro a classe de verdade ou também passar por parâmetro uma classe que vai imitar o comportamento da classe de verdade.

Quando estamos codando o nosso projeto, devemos tomar cuidado com esse tipo de abordagem, onde declaramos uma propriedade e já a instanciamos na própria classe. Como podemos melhorar isso?

Primeiro, podemos colocar trocar o sinal de igual (=) por dois-pontos (:) após a propriedade service. Segundo, vamos retirar os parênteses de SearchService para não instanciá-la.

Ao invés disso, vamos receber essa classe por parâmetro no construtor. Isso vai melhorar a implementação. Em init(), receberemos service que é do tipo SearchService.

Dentro do construtor, pegaremos o self.service da classe e diremos que ele é igual ao service que estamos recebendo por parâmetro.

SearchStoreViewModel.swift:

class SearchStoreViewModel: ObservableObject {

    // MARK: - Attributes

    let service: SearchService
    @Published var storesType: [StoreType] = []
    @Published var searchText: String = ""

    init(service: SearchService) {
        self.service = service
        fetchData()
    }

    // código omitido…
}

Após fazer essa alteração, vamos precisar acessar o ContentView para passar o SearchService por parâmetro na linha 46. Afinal, se tentarmos buildar o projeto com "Command + B" nesse momento, ocorre um erro e o XCode nos informa que faltam argumentos para o parâmetro service.

Após passar o SearchService por parâmetro e instanciá-lo, podemos dar um "Command + B" novamente.

ContentView.swift:

// código omitido…

SearchStoreView(viewModel: SearchStoreViewModel(service: SearchService()))
        .tabItem {
                Image(systemName: "magnifyingglass")
                Text("Busca")
        }

// código omitido…

Criando um protocolo

Já melhoramos o código, porém ainda não resolvemos o problema em sua totalidade, porque, mais uma vez, estamos recebendo o tipo concreto da classe SearchService no construtor do SearchStoreViewModel.

O que podemos fazer para melhorar? Vamos abrir a camada de serviço, a SearchService, para criar um protocolo, que é o ponto-chave que queríamos mostrar para você nesse vídeo.

Como podemos deixar o nosso código mais genérico usando o protocolo?

Vamos declarar um protocol, que vai se chamar SearchServiceProtocol. Esse protocolo vai ter um método que será exatamente a assinatura do método fetchData() que já temos na classe de produção. Basta copiar toda a assinatura do método e colar dentro do protocolo.

E o que isso vai permitir? Ao invés de passar o SearchService por parâmetro, poderemos passar algo que implemente esse protocolo por parâmetro, que é o SearchServiceProtocol.

Na declaração da struct SearchService, vamos acrescentar dois-pontos e implementar o protocolo SearchServiceProtocol. A obrigação de quem se conforma a esse protocolo é implementar o método fetchData(). Nesse caso, esse método já está implementado na struct.

SearchService.swift:

protocol SearchServiceProtocol {
    func fetchData() async throws -> Result<[StoreType], RequestError>
}

struct SearchService: SearchServiceProtocol {
    func fetchData() async throws -> Result<[StoreType], RequestError> {
    // código omitido…
    }
}

Agora, na classe de produção do ViewModel, o SearchStoreViewModel, ao invés de receber uma classe concreta no construtor, vamos receber algo que implemente o protocolo SearchServiceProtocol.

E aí, vamos alterar também a declaração do let service que será também do tipo SearchServiceProtocol.

SearchStoreViewModel.swift:

class SearchStoreViewModel: ObservableObject {

    // MARK: - Attributes

    let service: SearchServiceProtocol
    @Published var storesType: [StoreType] = []
    @Published var searchText: String = ""

    init(service: SearchServiceProtocol) {
        self.service = service
        fetchData()
    }

    // código omitido…
}

O que ganhamos com isso? Agora, vamos poder passar tanto a classe produtiva quanto alguma struct ou classe criada na classe de teste que implementa esse protocolo.

Só que para teste, como havíamos comentado, não faz sentido fazer requisição para API de verdade. O ideal é simular, ou seja, imitar o comportamento da classe de produção e trazer esses objetos já pré-definidos.

Então, ao invés de fazer uma requisição para o servidor, já ensinamos à classe o que ela deve retornar em caso de teste para que consiga testar o comportamento da ViewModel.

A seguir, continuamos escrevendo o teste de unidade e usando todos esses ajustes que acabamos de fazer.

Sobre o curso iOS: aplicando testes de unidade com mocks, stubs e outros frameworks

O curso iOS: aplicando testes de unidade com mocks, stubs e outros frameworks possui 138 minutos de vídeos, em um total de 47 atividades. Gostou? Conheça nossos outros cursos de iOS em Mobile, ou leia nossos artigos de Mobile.

Matricule-se e comece a estudar com a gente hoje! Conheça outros tópicos abordados durante o curso:

Aprenda iOS acessando integralmente esse e outros cursos, comece hoje!

Conheça os Planos para Empresas