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

Apr 30, 2025 - 23:44
 0
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:

  1. Registram chamadas (Spy)
  2. 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