Aplicando testes unitários com XCTest em um projeto iOS numa arquitetura VIP
Testes unitários são essenciais para garantir que nosso código funcione conforme o esperado. Eles vão além de validar funcionalidades durante o desenvolvimento - previnem regressões futuras, pois alterações no código podem impactar comportamentos existentes. Os testes ajudam a identificar esses problemas antes que cheguem ao usuário final. Além disso, são uma forma de documentar o comportamento esperado do sistema, facilitar refatorações e reduzir tempo de debug. Dando continuidade ao projeto desenvolvido com a arquitetura VIP, foram implementadas classes que conformam com protocolos específicos para cada componente (Interactor, Presenter e View). Essa abordagem traz duas vantagens principais: Facilidade de Testes: A utilização de protocolos permite a criação de mocks ou spies, simplificando a escrita de testes unitários. Desacoplamento: Cada componente pode ser testado isoladamente, garantindo maior modularidade e manutenibilidade. Serão desenvolvidos testes unitários para os três principais componentes da arquitetura: Interactor (Lógica de negócios) Presenter (Transformação de dados para exibição) View (Exibição e interação do usuário) Uso de XCTest O XCTest é a solução oficial da Apple, mas há outras opções no mercado como Quick/Nimble, Cuckoo e OHHTTPStubs. Possibilidades de uso de XCTest e outras tecnologias Framework Vantagens Melhor Caso de Uso XCTest Integração nativa, performance Projetos Apple puros Quick/Nimble Sintaxe legível, BDD Times acostumados com Ruby/RSpec Cuckoo Geração automática de mocks Projetos com muitas dependências A fim de praticidade, simplicidade, e a integração ao Xcode sem dependências externas, usaremos o XCTest Para adicioná-lo ao seu projeto, crie um novo Target a partir de File E então selecione a opção Unit Testing Bundle A estrutura de arquivos ficará assim, separando Mocks, Responses, e uma pasta para cada parte do VIP Criando os Mocks Tipos de Test Doubles Spy: Registra chamadas para verificação posterior (como usado no artigo) Mock: Possui expectativas pré-definidas que falham o teste se não forem atendidas Stub: Retorna respostas pré-programadas sem verificação Fake: Implementação simplificada para testes (ex: banco de dados em memória) Nossos *Spy são na verdade combinações de Spy+Stub, pois: Registram chamadas (Spy) Permitem configurar respostas (Stub) Como temos protocolos no nosso projeto, podemos criar objetos do tipo Spy para conformar com esses protocolos, e então, ao inicializar um Interactor, Presenter, ou View, passaremos nosso objeto mockado à ele. Um Spy é um tipo de test double (objeto de teste) que: Registra informações sobre como foi chamado (métodos, argumentos, frequência). Permite verificação posterior no teste (ex: "esse método foi chamado 1 vez?"). Interactor Usado para instanciar a view controller, rastreará a quantidade de chamadas a fetchArticles e didSelectArticle, e também os parâmetros recebidos em cada requisição @testable import CareerAppUIKit final class ArticlesInteractorSpy: ArticlesBusinessLogic { // MARK: - Call Tracking private(set) var fetchArticlesCallCount = 0 private(set) var didSelectArticleCallCount = 0 private(set) var lastFetchRequest: Articles.FetchArticles.Request? private(set) var lastSelectRequest: Articles.DidSelectArticle.Request? // MARK: - ArticlesBusinessLogic func fetchArticles(request: Articles.FetchArticles.Request) { fetchArticlesCallCount += 1 lastFetchRequest = request } func didSelectArticle(request: Articles.DidSelectArticle.Request) { didSelectArticleCallCount += 1 lastSelectRequest = request } // MARK: - Test Helpers func reset() { fetchArticlesCallCount = 0 didSelectArticleCallCount = 0 lastFetchRequest = nil lastSelectRequest = nil } } Presenter Usado para instanciar o Interactor, rastreará as chamadas de formatação (presentArticles, presentError, presentLoading) e os dados recebidos e enviados para a View. @testable import CareerAppUIKit final class ArticlesPresenterSpy: ArticlesPresentationLogic { // MARK: - Call Tracking private(set) var presentArticlesCallCount = 0 private(set) var presentErrorCallCount = 0 private(set) var presentLoadingCallCount = 0 private(set) var lastArticlesResponse: Articles.FetchArticles.Response? private(set) var lastErrorResponse: Articles.PresentError.Response? private(set) var lastLoadingResponse: Articles.PresentLoading.Response? // MARK: - ArticlesPresentationLogic func presentArticles(response: Articles.FetchArticles.Response) { presentArticlesCallCount += 1 lastArticlesResponse = response } func presentError(response: Articles.PresentError.Response) { presentErrorCallCount += 1 lastErrorResponse = response } func presentLoading(response: Arti

Testes unitários são essenciais para garantir que nosso código funcione conforme o esperado. Eles vão além de validar funcionalidades durante o desenvolvimento - previnem regressões futuras, pois alterações no código podem impactar comportamentos existentes. Os testes ajudam a identificar esses problemas antes que cheguem ao usuário final.
Além disso, são uma forma de documentar o comportamento esperado do sistema, facilitar refatorações e reduzir tempo de debug.
Dando continuidade ao projeto desenvolvido com a arquitetura VIP, foram implementadas classes que conformam com protocolos específicos para cada componente (Interactor, Presenter e View). Essa abordagem traz duas vantagens principais:
Facilidade de Testes: A utilização de protocolos permite a criação de mocks ou spies, simplificando a escrita de testes unitários.
Desacoplamento: Cada componente pode ser testado isoladamente, garantindo maior modularidade e manutenibilidade.
Serão desenvolvidos testes unitários para os três principais componentes da arquitetura:
- Interactor (Lógica de negócios)
- Presenter (Transformação de dados para exibição)
- View (Exibição e interação do usuário)
Uso de XCTest
O XCTest é a solução oficial da Apple, mas há outras opções no mercado como Quick/Nimble, Cuckoo e OHHTTPStubs.
Possibilidades de uso de XCTest e outras tecnologias
Framework | Vantagens | Melhor Caso de Uso |
---|---|---|
XCTest | Integração nativa, performance | Projetos Apple puros |
Quick/Nimble | Sintaxe legível, BDD | Times acostumados com Ruby/RSpec |
Cuckoo | Geração automática de mocks | Projetos com muitas dependências |
A fim de praticidade, simplicidade, e a integração ao Xcode sem dependências externas, usaremos o XCTest
Para adicioná-lo ao seu projeto, crie um novo Target a partir de File
E então selecione a opção Unit Testing Bundle
A estrutura de arquivos ficará assim, separando Mocks, Responses, e uma pasta para cada parte do VIP
Criando os Mocks
Tipos de Test Doubles
- Spy: Registra chamadas para verificação posterior (como usado no artigo)
- Mock: Possui expectativas pré-definidas que falham o teste se não forem atendidas
- Stub: Retorna respostas pré-programadas sem verificação
- Fake: Implementação simplificada para testes (ex: banco de dados em memória)
Nossos *Spy
são na verdade combinações de Spy+Stub, pois:
- Registram chamadas (Spy)
- Permitem configurar respostas (Stub)
Como temos protocolos no nosso projeto, podemos criar objetos do tipo Spy para conformar com esses protocolos, e então, ao inicializar um Interactor, Presenter, ou View, passaremos nosso objeto mockado à ele.
Um Spy é um tipo de test double (objeto de teste) que:
- Registra informações sobre como foi chamado (métodos, argumentos, frequência).
- Permite verificação posterior no teste (ex: "esse método foi chamado 1 vez?").
Interactor
Usado para instanciar a view controller, rastreará a quantidade de chamadas a fetchArticles e didSelectArticle, e também os parâmetros recebidos em cada requisição
@testable import CareerAppUIKit
final class ArticlesInteractorSpy: ArticlesBusinessLogic {
// MARK: - Call Tracking
private(set) var fetchArticlesCallCount = 0
private(set) var didSelectArticleCallCount = 0
private(set) var lastFetchRequest: Articles.FetchArticles.Request?
private(set) var lastSelectRequest: Articles.DidSelectArticle.Request?
// MARK: - ArticlesBusinessLogic
func fetchArticles(request: Articles.FetchArticles.Request) {
fetchArticlesCallCount += 1
lastFetchRequest = request
}
func didSelectArticle(request: Articles.DidSelectArticle.Request) {
didSelectArticleCallCount += 1
lastSelectRequest = request
}
// MARK: - Test Helpers
func reset() {
fetchArticlesCallCount = 0
didSelectArticleCallCount = 0
lastFetchRequest = nil
lastSelectRequest = nil
}
}
Presenter
Usado para instanciar o Interactor, rastreará as chamadas de formatação (presentArticles, presentError, presentLoading) e os dados recebidos e enviados para a View.
@testable import CareerAppUIKit
final class ArticlesPresenterSpy: ArticlesPresentationLogic {
// MARK: - Call Tracking
private(set) var presentArticlesCallCount = 0
private(set) var presentErrorCallCount = 0
private(set) var presentLoadingCallCount = 0
private(set) var lastArticlesResponse: Articles.FetchArticles.Response?
private(set) var lastErrorResponse: Articles.PresentError.Response?
private(set) var lastLoadingResponse: Articles.PresentLoading.Response?
// MARK: - ArticlesPresentationLogic
func presentArticles(response: Articles.FetchArticles.Response) {
presentArticlesCallCount += 1
lastArticlesResponse = response
}
func presentError(response: Articles.PresentError.Response) {
presentErrorCallCount += 1
lastErrorResponse = response
}
func presentLoading(response: Articles.PresentLoading.Response) {
presentLoadingCallCount += 1
lastLoadingResponse = response
}
// MARK: - Test Helpers
func reset() {
presentArticlesCallCount = 0
presentErrorCallCount = 0
presentLoadingCallCount = 0
lastArticlesResponse = nil
lastErrorResponse = nil
lastLoadingResponse = nil
}
}
Router
Usado para instanciar o Interactor, rastreará as chamadas a routeToArticleDetail e os IDs de artigos passados durante a navegação.
@testable import CareerAppUIKit
final class ArticlesRouterSpy: ArticlesRoutingLogic {
// MARK: - Call Tracking
private(set) var routeToArticleDetailCallCount = 0
private(set) var routedArticleIds: [Int] = []
// MARK: - ArticlesRoutingLogic
func routeToArticleDetail(id: Int) {
routeToArticleDetailCallCount += 1
routedArticleIds.append(id)
}
// MARK: - Test Helpers
func reset() {
routeToArticleDetailCallCount = 0
routedArticleIds = []
}
}
View
Usada para instanciar o Presenter, rastreará as chamadas de atualização de UI (displayArticles, displayLoading, displayError) e os estados de loading e mensagens de erro.
@testable import CareerAppUIKit
final class ArticlesViewSpy: ArticlesDisplayLogic {
// MARK: - Call Tracking
private(set) var displayArticlesCallCount = 0
private(set) var displayLoadingCallCount = 0
private(set) var displayErrorCallCount = 0
private(set) var lastDisplayedArticles: [DisplayedArticle]?
private(set) var lastLoadingState: Bool?
private(set) var lastErrorMessage: String?
// MARK: - ArticlesDisplayLogic
func displayArticles(viewModel: Articles.FetchArticles.ViewModel) {
displayArticlesCallCount += 1
lastDisplayedArticles = viewModel.displayedArticles
}
func displayLoading(viewModel: Articles.PresentLoading.Response) {
displayLoadingCallCount += 1
lastLoadingState = viewModel.isLoading
}
func displayError(viewModel: Articles.PresentError.Response) {
displayErrorCallCount += 1
lastErrorMessage = viewModel.errorMessage
}
func displayArticleDetail(_ articleDetail: ArticleDetail) {
// Implementação se necessário
}
// MARK: - Test Helpers
func reset() {
displayArticlesCallCount = 0
displayLoadingCallCount = 0
displayErrorCallCount = 0
lastDisplayedArticles = nil
lastLoadingState = nil
lastErrorMessage = nil
}
}
Worker
Usado para instanciar o Interactor, permite simular respostas de sucesso/erro da API e define dados fixos para testes.
@testable import CareerAppUIKit
final class ArticlesWorkerSpy: ArticlesWorkerProtocol {
// MARK: - Properties
private var stubbedArticles: [Article] = []
private var stubbedError: Error?
// MARK: - Public Methods
func fetchArticles(completion: @escaping (Result<[Article], Error>) -> Void) {
if let error = stubbedError {
completion(.failure(error))
} else {
completion(.success(stubbedArticles))
}
}
// MARK: - Stub Methods
func stubSuccessResponse(with articles: [Article]) {
stubbedArticles = articles
stubbedError = nil
}
func stubFailureResponse(with error: Error) {
stubbedError = error
stubbedArticles = []
}
func reset() {
stubbedArticles = []
stubbedError = nil
}
}
Estrutura dos testes
-
Setup e Teardown:
- setUp(): Inicializa os componentes necessários antes de cada teste
- tearDown(): Limpa o estado após cada teste
Importantes para garantir isolamento entre testes e evita vazamento de estado
- Organização com MARK:
Facilita navegação e manutenção do código
Separação clara em seções (Properties, Lifecycle, Test Cases, Helpers)
- Padrão de Nomenclatura:
Nomearemos "contando uma história" do que o teste faz, para deixá-lo melhor documentado e autoexplicativo.
test_Condição_Ação_ResultadoEsperado
(ex: test_givenSuccessfulResponse_whenFetchingArticles_shouldPresentArticles)
-
Estrutura Given-When-Then:
- Given: Prepara o estado inicial
- When: Executa a ação sendo testada
- Then: Verifica os resultados
Para clareza em cada fluxo do teste, usaremos esse padrão de construção.
- XCTUnwrap para tratamento de opcionais
Solução para um tratamento seguro de desembrulhar opcionais, além de falhar imediatamente o teste se os valores essenciais forem nulos, sua integração mostra a linha exata da falha, com uma mensagem clara e elimina aninhamento de guard/else
func test_userProfile_shouldDisplayCorrectName() throws {
// Arrange
let user: User? = User(name: "Ana", age: 28)
// Act & Assert
let unwrappedUser = try XCTUnwrap(user, "O objeto user não deveria ser nil")
XCTAssertEqual(unwrappedUser.name, "Ana", "O nome deveria ser Ana")
}
Escrevendo os testes do Interactor
Os testes do Interactor serão para validar as regras de negócio, validar os casos de sucesso e erro vindos do Worker e se os encaminhamentos idos para o Presenter estão adequados.
import XCTest
@testable import CareerAppUIKit
final class ArticlesInteractorTests: XCTestCase {
// MARK: - Properties
private var interactor: ArticlesInteractor?
private var presenter: ArticlesPresenterSpy?
private var worker: ArticlesWorkerSpy?
private var router: ArticlesRouterSpy?
// MARK: - Test Lifecycle
override func setUp() {
super.setUp()
setupTestComponents()
}
override func tearDown() {
cleanUpTestComponents()
super.tearDown()
}
// MARK: - Test Cases
func test_fetchArticles_whenWorkerSucceeds_shouldCallPresentArticles() throws {
// Given
let worker = try XCTUnwrap(worker, "Worker should not be nil")
let presenter = try XCTUnwrap(presenter, "Presenter should not be nil")
let interactor = try XCTUnwrap(interactor, "Interactor should not be nil")
worker.stubSuccessResponse(with: [])
// When
interactor.fetchArticles(request: Articles.FetchArticles.Request())
// Then
XCTAssertEqual(presenter.presentArticlesCallCount, 1, "Should call present articles exactly once")
XCTAssertEqual(presenter.presentErrorCallCount, 0, "Should not call present error")
XCTAssertNotNil(presenter.lastArticlesResponse, "Should receive articles response")
}
func test_fetchArticles_whenWorkerFails_shouldCallPresentError() throws {
// Given
let worker = try XCTUnwrap(worker, "Worker should not be nil")
let presenter = try XCTUnwrap(presenter, "Presenter should not be nil")
let interactor = try XCTUnwrap(interactor, "Interactor should not be nil")
let expectedError = NetworkError.decodingError
worker.stubFailureResponse(with: expectedError)
// When
interactor.fetchArticles(request: Articles.FetchArticles.Request())
// Then
XCTAssertEqual(presenter.presentErrorCallCount, 1, "Should call present error exactly once")
XCTAssertEqual(presenter.presentArticlesCallCount, 0, "Should not call present articles")
XCTAssertEqual(presenter.lastErrorResponse?.errorMessage, expectedError.localizedDescription)
}
func test_didSelectArticle_shouldRouteWithCorrectID() throws {
// Given
let router = try XCTUnwrap(router, "Router should not be nil")
let interactor = try XCTUnwrap(interactor, "Interactor should not be nil")
let testId = 123
let request = Articles.DidSelectArticle.Request(id: testId)
// When
interactor.didSelectArticle(request: request)
// Then
XCTAssertEqual(router.routeToArticleDetailCallCount, 1, "Should call route to detail exactly once")
XCTAssertEqual(router.routedArticleIds.first, testId, "Should route with correct article ID")
}
// MARK: - Helper Methods
private func setupTestComponents() {
let newPresenter = ArticlesPresenterSpy()
let newWorker = ArticlesWorkerSpy()
let newRouter = ArticlesRouterSpy()
presenter = newPresenter
worker = newWorker
router = newRouter
interactor = ArticlesInteractor(
presenter: newPresenter,
worker: newWorker,
router: newRouter
)
}
private func cleanUpTestComponents() {
interactor = nil
presenter?.reset()
presenter = nil
worker?.reset()
worker = nil
router?.reset()
router = nil
}
}
Escrevendo os testes do Presenter
Os testes do Presenter serão para validar os estados de loading/error e a formatação correta dos modelos, vindos do Interactor.
import XCTest
@testable import CareerAppUIKit
final class ArticlesPresenterTests: XCTestCase {
// MARK: - Properties
private var sut: ArticlesPresenter?
private var viewSpy: ArticlesViewSpy?
// MARK: - Test Lifecycle
override func setUp() {
super.setUp()
sut = ArticlesPresenter()
viewSpy = ArticlesViewSpy()
sut?.viewController = viewSpy
}
override func tearDown() {
sut = nil
viewSpy?.reset()
viewSpy = nil
super.tearDown()
}
// MARK: - Test Cases
func test_presentArticles_shouldFormatAndDisplayData() throws {
// Given
let articles = try loadArticlesFromJSON()
let response = Articles.FetchArticles.Response(articles: articles)
let presenter = try XCTUnwrap(sut, "Presenter não deveria ser nil")
let view = try XCTUnwrap(viewSpy, "ViewSpy não deveria ser nil")
// When
presenter.presentArticles(response: response)
// Then
XCTAssertEqual(view.displayArticlesCallCount, 1, "Deveria chamar displayArticles uma vez")
XCTAssertEqual(view.lastDisplayedArticles?.count, articles.count, "Deveria exibir todos os artigos")
XCTAssertEqual(view.lastDisplayedArticles?.first?.title, articles.first?.title, "Títulos deveriam corresponder")
}
func test_presentLoading_shouldUpdateViewState() throws {
// Given
let isLoading = true
let presenter = try XCTUnwrap(sut, "Presenter não deveria ser nil")
let view = try XCTUnwrap(viewSpy, "ViewSpy não deveria ser nil")
// When
presenter.presentLoading(response: Articles.PresentLoading.Response(isLoading: isLoading))
// Then
XCTAssertEqual(view.displayLoadingCallCount, 1, "Deveria chamar displayLoading uma vez")
XCTAssertEqual(view.lastLoadingState, isLoading, "Estado de loading deveria ser true")
}
func test_presentError_shouldDisplayErrorMessage() throws {
// Given
let testError = NetworkError.noData
let presenter = try XCTUnwrap(sut, "Presenter não deveria ser nil")
let view = try XCTUnwrap(viewSpy, "ViewSpy não deveria ser nil")
// When
presenter.presentError(response: Articles.PresentError.Response(errorMessage: testError.localizedDescription))
// Then
XCTAssertEqual(view.displayErrorCallCount, 1, "Deveria chamar displayError uma vez")
XCTAssertEqual(view.lastErrorMessage, testError.localizedDescription, "Mensagem de erro deveria corresponder")
}
// MARK: - Helper Methods
private func loadArticlesFromJSON() throws -> [Article] {
let bundle = Bundle(for: type(of: self))
guard let url = bundle.url(forResource: "articles", withExtension: "json") else {
throw NSError(domain: "Tests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Arquivo JSON não encontrado"])
}
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
return try decoder.decode([Article].self, from: data)
}
}
Escrevendo os testes da View
Os testes da View serão para validar os comportamentos da UI, as interações do usuário e sua comunicação com o Interactor.
import XCTest
@testable import CareerAppUIKit
class ArticlesDataStoreSpy: ArticlesDataStoreProtocol {
var displayedArticles: [DisplayedArticle] = []
}
final class ArticlesViewControllerTests: XCTestCase {
// MARK: - Test Components
private var sut: ArticlesViewController?
private var interactorSpy: ArticlesInteractorSpy?
private var dataStoreSpy: ArticlesDataStoreSpy?
private let testCollectionView = UICollectionView(
frame: .zero,
collectionViewLayout: UICollectionViewFlowLayout()
)
// MARK: - Test Lifecycle
override func setUp() {
super.setUp()
do {
try setupTestComponents()
} catch {
XCTFail("Falha no setup: \(error.localizedDescription)")
}
}
override func tearDown() {
cleanUpTestComponents()
super.tearDown()
}
// MARK: - Test Cases
func test_viewDidLoad_shouldFetchArticles() throws {
// When
_ = try XCTUnwrap(sut).view
// Then
XCTAssertEqual(interactorSpy?.fetchArticlesCallCount, 1)
}
func test_didSelectArticle_shouldNotifyInteractor() throws {
// Given
let testArticle = DisplayedArticle(
id: 1, title: "Test", description: "Desc",
publishDate: "Jan 1", imageUrl: nil,
authorName: "Author", tags: "tag1,tag2"
)
dataStoreSpy?.displayedArticles = [testArticle]
let indexPath = IndexPath(item: 0, section: 0)
// When
try XCTUnwrap(sut).collectionView(
testCollectionView,
didSelectItemAt: indexPath
)
// Then
XCTAssertEqual(interactorSpy?.didSelectArticleCallCount, 1)
XCTAssertEqual(try XCTUnwrap(interactorSpy?.lastSelectRequest?.id), 1)
}
func test_emptyState_shouldShowEmptyCell() throws {
// Given
dataStoreSpy?.displayedArticles = []
let indexPath = IndexPath(item: 0, section: 0)
// When
let cell = try XCTUnwrap(sut).collectionView(
testCollectionView,
cellForItemAt: indexPath
)
// Then
XCTAssertTrue(cell is EmptyArticlesCell)
}
func test_collectionView_shouldReturnCorrectNumberOfItems() throws {
// Given
let testArticles = [
DisplayedArticle(id: 1, title: "Test 1", description: "Desc 1",
publishDate: "Jan 1", imageUrl: nil,
authorName: "Author 1", tags: "tag1"),
DisplayedArticle(id: 2, title: "Test 2", description: "Desc 2",
publishDate: "Jan 2", imageUrl: nil,
authorName: "Author 2", tags: "tag2")
]
dataStoreSpy?.displayedArticles = testArticles
// When
let numberOfItems = try XCTUnwrap(sut).collectionView(
testCollectionView,
numberOfItemsInSection: 0
)
// Then
XCTAssertEqual(numberOfItems, testArticles.count)
}
// MARK: - Test Setup
private func setupTestComponents() throws {
interactorSpy = ArticlesInteractorSpy()
dataStoreSpy = ArticlesDataStoreSpy()
sut = ArticlesViewController(interactor: try XCTUnwrap(interactorSpy))
sut?.dataStore = try XCTUnwrap(dataStoreSpy)
sut?.loadViewIfNeeded()
}
private func cleanUpTestComponents() {
sut = nil
interactorSpy?.reset()
interactorSpy = nil
dataStoreSpy = nil
}
}
A implementação de testes unitários em arquitetura VIP mostra toda sua potência quando:
- Cada componente tem responsabilidades bem definidas
- As dependências são invertidas via protocolos
- Os testes focam em comportamentos, não implementações