Testes básicos em iOS com SwiftUI: XCTest, testes de unidade e UI tests essenciais

Capítulo 13

Tempo estimado de leitura: 10 minutos

+ Exercício

Objetivo dos testes no iOS

Testes automatizados ajudam a detectar regressões cedo e dão segurança para refatorar. Em iOS, o Xcode oferece dois tipos principais: testes de unidade (validam funções, regras de negócio e ViewModels) e UI tests (validam fluxos críticos pela interface). A ideia é manter a maior parte da lógica coberta por testes de unidade (rápidos) e usar UI tests para poucos caminhos essenciais (mais lentos e frágeis).

Configurando testes no Xcode

Criando targets de teste

Ao criar um projeto, o Xcode normalmente já oferece as opções de incluir targets de teste. Se você precisar adicionar depois:

  • No Xcode, vá em File > New > Target...
  • Escolha iOS Unit Testing Bundle para testes de unidade
  • Escolha iOS UI Testing Bundle para UI tests
  • Confirme o app alvo (Target) que será testado

Onde os testes ficam e como são organizados

Você terá dois grupos/pastas no projeto: um para AppNameTests (unidade) e outro para AppNameUITests (UI). Dentro deles, crie arquivos por responsabilidade, por exemplo: ValidationTests, FormattingTests, ItemListViewModelTests, CriticalFlowUITests.

Importante: @testable import

Em testes de unidade, use @testable import SeuModulo para acessar tipos internos do módulo do app. Isso facilita testar ViewModels e regras sem expor tudo como public.

import XCTest @testable import MeuApp

XCTest: estrutura básica

Um teste de unidade é um método que começa com test dentro de uma classe que herda de XCTestCase. Use setUp() para preparar dependências e tearDown() para limpar.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

import XCTest @testable import MeuApp final class ValidationTests: XCTestCase {     override func setUp() {         super.setUp()         // preparar objetos comuns aos testes     }     override func tearDown() {         // limpar recursos         super.tearDown()     }     func testExample() {         XCTAssertTrue(true)     } }

Asserções mais usadas

AsserçãoUso
XCTAssertEqual(a, b)Comparar valores
XCTAssertTrue(cond)Condição deve ser verdadeira
XCTAssertFalse(cond)Condição deve ser falsa
XCTAssertNil(x)Deve ser nil
XCTAssertNotNil(x)Não deve ser nil
XCTFail("mensagem")Falha explícita

Testes de unidade para funções: validação, formatação e parsing

1) Validação (ex.: e-mail e senha)

Uma boa prática é colocar validações em tipos simples (struct/enum) para facilitar o teste. Exemplo de validador:

enum Validator {     static func isValidEmail(_ value: String) -> Bool {         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)         guard trimmed.contains("@"), trimmed.contains(".") else { return false }         return trimmed.count >= 5     }     static func isValidPassword(_ value: String) -> Bool {         value.count >= 8     } }

Testes:

final class ValidatorTests: XCTestCase {     func testEmailValidation_acceptsBasicValidEmail() {         XCTAssertTrue(Validator.isValidEmail("ana@exemplo.com"))     }     func testEmailValidation_rejectsMissingAt() {         XCTAssertFalse(Validator.isValidEmail("anaexemplo.com"))     }     func testPasswordValidation_requiresMinimumLength() {         XCTAssertFalse(Validator.isValidPassword("1234567"))         XCTAssertTrue(Validator.isValidPassword("12345678"))     } }

2) Formatação (ex.: moeda e datas)

Formatação costuma depender de locale/timezone. Para testes determinísticos, injete dependências (por exemplo, Locale e TimeZone) ou fixe valores no formatter.

struct CurrencyFormatter {     let locale: Locale     func formatBRL(_ value: Decimal) -> String {         let formatter = NumberFormatter()         formatter.numberStyle = .currency         formatter.currencyCode = "BRL"         formatter.locale = locale         return formatter.string(from: value as NSDecimalNumber) ?? ""     } }
final class CurrencyFormatterTests: XCTestCase {     func testFormatBRL_ptBR() {         let sut = CurrencyFormatter(locale: Locale(identifier: "pt_BR"))         let result = sut.formatBRL(Decimal(string: "10.5")!)         XCTAssertEqual(result, "R$ 10,50")     } }

Se seu ambiente variar e o separador/espaço mudar, compare padrões (ex.: contém R$ e 10) ou normalize espaços. O ideal é padronizar o formatter para o teste.

3) Parsing/decodificação de dados (JSON)

Para testar parsing, use um JSON pequeno e determinístico. Exemplo de modelo:

struct ItemDTO: Decodable, Equatable {     let id: Int     let title: String     let isDone: Bool }

Teste de decodificação:

final class ParsingTests: XCTestCase {     func testDecodeItemDTO() throws {         let json = """         { "id": 1, "title": "Comprar leite", "isDone": false }         """.data(using: .utf8)!         let decoded = try JSONDecoder().decode(ItemDTO.self, from: json)         XCTAssertEqual(decoded, ItemDTO(id: 1, title: "Comprar leite", isDone: false))     }     func testDecodeFailsWithMissingField() {         let json = """         { "id": 1, "isDone": false }         """.data(using: .utf8)!         XCTAssertThrowsError(try JSONDecoder().decode(ItemDTO.self, from: json))     } }

Testes de unidade para ViewModels (com async/await)

ViewModels geralmente coordenam validação, chamadas de serviço e atualização de estado. Para testá-los bem, evite dependências reais (rede) e injete um protocolo para o serviço.

1) Definindo um protocolo para o serviço

protocol ItemsService {     func fetchItems() async throws -> [ItemDTO]     func saveItem(title: String) async throws -> ItemDTO }

2) Criando um mock simples

Um mock pode retornar sucesso ou erro, e também registrar chamadas para você validar comportamento.

final class ItemsServiceMock: ItemsService {     enum MockError: Error { case forced }     var fetchItemsResult: Result<[ItemDTO], Error> = .success([])     var saveItemResult: Result<ItemDTO, Error> = .success(ItemDTO(id: 1, title: "X", isDone: false))     private(set) var fetchItemsCallCount = 0     private(set) var saveItemCallCount = 0     private(set) var lastSavedTitle: String?     func fetchItems() async throws -> [ItemDTO] {         fetchItemsCallCount += 1         return try fetchItemsResult.get()     }     func saveItem(title: String) async throws -> ItemDTO {         saveItemCallCount += 1         lastSavedTitle = title         return try saveItemResult.get()     } }

3) Um ViewModel exemplo (estado + validação)

@MainActor final class ItemsViewModel: ObservableObject {     @Published private(set) var items: [ItemDTO] = []     @Published var titleInput: String = ""     @Published private(set) var errorMessage: String?     private let service: ItemsService     init(service: ItemsService) {         self.service = service     }     func load() async {         do {             items = try await service.fetchItems()             errorMessage = nil         } catch {             errorMessage = "Falha ao carregar"         }     }     func canSave() -> Bool {         let trimmed = titleInput.trimmingCharacters(in: .whitespacesAndNewlines)         return trimmed.count >= 3     }     func save() async {         guard canSave() else {             errorMessage = "Título inválido"             return         }         do {             let created = try await service.saveItem(title: titleInput)             items.insert(created, at: 0)             errorMessage = nil         } catch {             errorMessage = "Falha ao salvar"         }     } }

4) Testando o ViewModel (sucesso, erro e validação)

Como os métodos são async, seus testes também podem ser async.

final class ItemsViewModelTests: XCTestCase {     func testLoad_success_updatesItemsAndClearsError() async {         let mock = ItemsServiceMock()         mock.fetchItemsResult = .success([ItemDTO(id: 1, title: "A", isDone: false)])         let sut = await ItemsViewModel(service: mock)         await sut.load()         let items = await sut.items         let error = await sut.errorMessage         XCTAssertEqual(mock.fetchItemsCallCount, 1)         XCTAssertEqual(items.count, 1)         XCTAssertNil(error)     }     func testLoad_failure_setsErrorMessage() async {         let mock = ItemsServiceMock()         mock.fetchItemsResult = .failure(ItemsServiceMock.MockError.forced)         let sut = await ItemsViewModel(service: mock)         await sut.load()         let error = await sut.errorMessage         XCTAssertEqual(mock.fetchItemsCallCount, 1)         XCTAssertEqual(error, "Falha ao carregar")     }     func testSave_invalidTitle_doesNotCallService() async {         let mock = ItemsServiceMock()         let sut = await ItemsViewModel(service: mock)         await MainActor.run { sut.titleInput = "  " }         await sut.save()         XCTAssertEqual(mock.saveItemCallCount, 0)         let error = await sut.errorMessage         XCTAssertEqual(error, "Título inválido")     }     func testSave_validTitle_callsServiceAndInsertsItem() async {         let mock = ItemsServiceMock()         mock.saveItemResult = .success(ItemDTO(id: 99, title: "Novo", isDone: false))         let sut = await ItemsViewModel(service: mock)         await MainActor.run { sut.titleInput = "Novo" }         await sut.save()         XCTAssertEqual(mock.saveItemCallCount, 1)         XCTAssertEqual(mock.lastSavedTitle, "Novo")         let items = await sut.items         XCTAssertEqual(items.first?.id, 99)     } }

Observação: em alguns casos, acessar propriedades @Published pode exigir estar no MainActor. Se o compilador reclamar, use await MainActor.run { ... } para ler/escrever estado do ViewModel.

Introdução a UI Tests: validando fluxos críticos

UI tests usam XCUITest para abrir o app, encontrar elementos e interagir como um usuário. Eles são ideais para validar caminhos como: abrir app, navegar, preencher formulário e salvar um item.

Pré-requisito: accessibilityIdentifier

Para que o UI test encontre elementos de forma estável, defina accessibilityIdentifier nos componentes importantes. Exemplo (em uma tela com lista e botão de adicionar):

// Exemplo em SwiftUI Button("Adicionar") { /* ... */ } .accessibilityIdentifier("items.addButton") TextField("Título", text: $viewModel.titleInput)     .accessibilityIdentifier("items.titleField") Button("Salvar") { /* ... */ }     .accessibilityIdentifier("items.saveButton") List(items) { item in     Text(item.title) } .accessibilityIdentifier("items.list")

Dica: use um padrão de nomes consistente, como tela.elemento (ex.: items.saveButton).

Estrutura básica de um UI test

import XCTest final class CriticalFlowUITests: XCTestCase {     func testCreateItemFlow() {         let app = XCUIApplication()         app.launch()         // interações e asserts aqui     } }

Passo a passo: abrir app, navegar, preencher e salvar

Exemplo de fluxo: tocar em “Adicionar”, digitar título, salvar e verificar que o item aparece na lista.

final class CriticalFlowUITests: XCTestCase {     func testCreateItemFlow() {         let app = XCUIApplication()         app.launch()         let addButton = app.buttons["items.addButton"]         XCTAssertTrue(addButton.waitForExistence(timeout: 2))         addButton.tap()         let titleField = app.textFields["items.titleField"]         XCTAssertTrue(titleField.waitForExistence(timeout: 2))         titleField.tap()         titleField.typeText("Comprar pão")         let saveButton = app.buttons["items.saveButton"]         XCTAssertTrue(saveButton.waitForExistence(timeout: 2))         saveButton.tap()         let list = app.otherElements["items.list"]         XCTAssertTrue(list.waitForExistence(timeout: 2))         XCTAssertTrue(app.staticTexts["Comprar pão"].waitForExistence(timeout: 2))     } }

Notas práticas:

  • waitForExistence(timeout:) reduz flakiness (testes que falham por timing).
  • Se o elemento estiver dentro de uma célula de lista, às vezes é melhor buscar por staticTexts com o texto esperado (como no exemplo).
  • Se houver navegação entre telas, identifique também o título da tela ou um elemento âncora com accessibilityIdentifier.

Controlando dados no UI test (modo de teste)

UI tests ficam mais confiáveis quando o app inicia em um estado conhecido. Uma abordagem comum é passar argumentos de launch e, no app, detectar isso para usar um repositório em memória ou limpar persistência.

No UI test:

let app = XCUIApplication() app.launchArguments = ["--ui-testing", "--reset-data"] app.launch()

No app (em um ponto central de inicialização), você pode ler:

let isUITesting = ProcessInfo.processInfo.arguments.contains("--ui-testing")

Com isso, você consegue evitar que dados antigos quebrem o teste (por exemplo, itens já existentes na lista).

Rodando testes no simulador e interpretando falhas

Como rodar testes

  • Rodar todos os testes: Product > Test
  • Rodar um arquivo/classe: clique no losango ao lado do nome da classe de teste
  • Rodar um teste específico: clique no losango ao lado do método test...
  • Escolha um simulador no topo do Xcode (UI tests rodam no simulador/dispositivo)

Entendendo falhas (unit tests)

Quando um XCTAssert... falha, o Xcode mostra:

  • Qual asserção falhou e os valores comparados
  • Arquivo e linha exata
  • Stack trace no painel de debug

Boas práticas para facilitar diagnóstico:

  • Use mensagens em asserts quando fizer sentido: XCTAssertEqual(a, b, "Mensagem")
  • Teste uma coisa por vez (arrange/act/assert) para a falha ser óbvia
  • Evite dependências externas (rede, relógio, locale variável) sem controle

Entendendo falhas (UI tests)

Falhas comuns em UI tests:

  • Elemento não encontrado: geralmente falta accessibilityIdentifier, o elemento está em outra tela, ou o teste não esperou o carregamento. Use waitForExistence e valide a navegação.
  • Teclado/Focus: o campo não recebeu foco. Garanta tap() antes de typeText.
  • Estado inicial inconsistente: dados antigos. Use launch arguments para resetar dados.

O Xcode também pode gerar artefatos de UI test (como capturas em falhas) dependendo da configuração. Ao investigar, observe a árvore de elementos no debug de UI test e confirme se o identificador está correto.

Relatórios e navegação no Test Navigator

No Test Navigator (ícone de losango), você vê:

  • Lista de suites, classes e métodos
  • Status (pass/fail) e tempo de execução
  • Histórico recente de execuções

Ao clicar em uma falha, o Xcode leva direto à linha do teste e mostra detalhes do erro. Em UI tests, se a falha for por inexistência de elemento, revise primeiro o identificador e a tela atual no momento da asserção.

Agora responda o exercício sobre o conteúdo:

Ao planejar a estratégia de testes em um app iOS com SwiftUI, qual abordagem tende a ser mais adequada para equilibrar velocidade e confiabilidade?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Testes de unidade são mais rápidos e ideais para regras de negócio e ViewModels. UI tests tendem a ser mais lentos e frágeis, então devem focar em poucos fluxos essenciais, com accessibilityIdentifier e esperas como waitForExistence.

Próximo capitúlo

Preparação para App Store: assinatura, build, TestFlight e submissão do app iOS

Arrow Right Icon
Capa do Ebook gratuito iOS para Iniciantes com SwiftUI: do zero ao primeiro app na App Store
93%

iOS para Iniciantes com SwiftUI: do zero ao primeiro app na App Store

Novo curso

14 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.