Swift Testing no Xcode 26: Guia Completo para Migrar do XCTest

Guia prático para 2026 do framework Swift Testing no Xcode 26: macros @Test, #expect e #require, testes parametrizados, traits, tags, confirmation, exit tests e migração incremental do XCTest sem quebrar o CI.

Swift Testing Xcode 26: Migrar do XCTest 2026

Confesso: durante anos eu olhei para o XCTest e pensei "tá bom, funciona". Aí veio o Swift Testing, o novo framework oficial da Apple, e eu finalmente entendi o que estava perdendo. Apresentado na WWDC 2024 e amadurecido com o Swift 6.2 e o Xcode 26, ele troca décadas de herança do Objective-C por uma API moderna baseada em macros — com suporte nativo a Swift Concurrency, testes parametrizados e traits reutilizáveis. Neste guia, vamos do zero: o que mudou, por que importa, e como migrar suítes existentes sem detonar seu pipeline de CI.

Por que o Swift Testing existe

O XCTest nasceu na era do Objective-C. Sua arquitetura depende de herança obrigatória de XCTestCase, detecção de testes pelo prefixo test no nome do método e dezenas de variações de XCTAssertEqual, XCTAssertTrue, XCTAssertNil e companhia. Funciona? Funciona. Mas combina pouco com o jeito que escrevemos Swift hoje: structs, actors, async/await, macros.

O Swift Testing resolve esses pontos com cinco decisões de design bem deliberadas:

  • Macros em vez de prefixos: qualquer função marcada com @Test é um teste, independentemente do nome.
  • Suítes flexíveis: você pode usar struct, class ou actor — a herança obrigatória sumiu.
  • Uma única forma de asserção: #expect e #require substituem dezenas de funções XCTAssert*.
  • Concorrência nativa: testes async são suportados de forma natural, e o framework executa em paralelo dentro do mesmo processo.
  • Traits componíveis: tags, condições, limites de tempo, ordem de execução — tudo aplicado como modificadores declarativos.

E uma boa notícia logo de cara: o XCTest não está obsoleto. Os dois frameworks rodam lado a lado no mesmo target de testes, o que torna a migração incremental e (importante) segura.

Configurando o ambiente no Xcode 26

O Swift Testing já vem incluído no Xcode 16 e nas versões posteriores. No Xcode 26, ao criar um novo target de testes (File > New > Target > Unit Testing Bundle), o template padrão já gera um arquivo usando @Test. Para projetos existentes, basta importar:

import Testing
@testable import MeuApp

Em pacotes Package.swift com swift-tools-version:6.0 ou superior, nenhuma dependência adicional é necessária — o módulo Testing faz parte do toolchain. É plug-and-play, basicamente.

Seu primeiro teste com @Test

O modelo mental mais simples possível: escreva uma função, anote-a com @Test e use #expect para verificar resultados. Só isso.

import Testing
@testable import Calculadora

@Test func somaDoisNumerosPositivos() {
    let resultado = Calculadora.somar(2, 3)
    #expect(resultado == 5)
}

Quando o teste falha, o Swift Testing exibe os valores reais envolvidos na expressão graças à expansão de macros. Na prática, isso elimina aquela dor antiga de escrever mensagens manuais como XCTAssertEqual(a, b, "esperava \(a) igual a \(b)") — o framework já mostra tudo pra você.

Nomeando testes com clareza

O argumento opcional do macro permite títulos legíveis no Test Navigator (e seus colegas vão te agradecer):

@Test("Soma de dois inteiros positivos retorna o valor esperado")
func somaDoisNumerosPositivos() {
    #expect(Calculadora.somar(2, 3) == 5)
}

Agrupando testes em Suites

Uma suite é um tipo (struct, class ou actor) anotado com @Suite. A Apple recomenda começar sempre com struct, exceto quando você precisa de deinit para limpeza:

@Suite("Calculadora — operações básicas")
struct CalculadoraTests {

    let calc = Calculadora()

    @Test func somaFunciona() {
        #expect(calc.somar(2, 3) == 5)
    }

    @Test func subtracaoFunciona() {
        #expect(calc.subtrair(10, 4) == 6)
    }
}

Detalhe importante: cada teste recebe uma nova instância da suite. Ou seja, let calc = Calculadora() roda uma vez por teste, garantindo isolamento sem código de boilerplate. É lindo de ver.

Setup e teardown

O setup acontece no init da suite. O teardown, quando necessário, vai no deinit de uma class ou actor:

@Suite final class BancoDeDadosTests {

    let db: BancoDeDados

    init() async throws {
        db = try await BancoDeDados.emMemoria()
        try await db.popularComDadosDeTeste()
    }

    deinit {
        try? db.fechar()
    }

    @Test func consultaUsuarioPorId() async throws {
        let usuario = try await db.usuario(id: 1)
        #expect(usuario.nome == "Ana")
    }
}

#expect e #require: a nova era das asserções

Em vez de memorizar dezenas de variantes de XCTAssert, você usa apenas duas macros. Sério, duas:

  • #expect(condição) — registra a falha mas continua executando o teste.
  • #require(condição) — falha e interrompe o teste imediatamente. Útil para pré-condições críticas.
@Test func processaPedidoValido() throws {
    let pedido = try #require(repositorio.pedido(id: 42))
    #expect(pedido.total > 0)
    #expect(pedido.itens.count == 3)
    #expect(pedido.cliente.email.contains("@"))
}

Repare que #require com um Optional faz unwrap seguro: se for nil, o teste para; caso contrário, o valor é retornado já desembrulhado. Dois pássaros, uma macro.

Verificando erros lançados

@Test func divisaoPorZeroLancaErro() {
    #expect(throws: Calculadora.Erro.divisaoPorZero) {
        try Calculadora.dividir(10, por: 0)
    }
}

Testes parametrizados: menos código, mais cobertura

Honestamente, esse talvez seja o maior salto produtivo em relação ao XCTest. Você passa uma sequência de valores e o framework executa o teste para cada um — em paralelo:

@Test(arguments: [
    (2, 3, 5),
    (0, 0, 0),
    (-1, 1, 0),
    (100, 200, 300)
])
func somaProduzResultadoCorreto(a: Int, b: Int, esperado: Int) {
    #expect(Calculadora.somar(a, b) == esperado)
}

O Test Navigator do Xcode 26 mostra cada caso individualmente, com a possibilidade de re-executar somente os que falharam (uma mão na roda quando você tem 200 casos e só 2 quebraram). Para combinar duas dimensões — produto cartesiano:

@Test(arguments: ["BRL", "USD", "EUR"], [10.0, 100.0, 1000.0])
func formatadorMoedaProduzStringValida(moeda: String, valor: Double) {
    let resultado = Formatador.formatar(valor, moeda: moeda)
    #expect(!resultado.isEmpty)
}

Traits: customizando o comportamento dos testes

Traits são modificadores declarativos aplicados ao @Test ou @Suite. Os mais úteis no dia a dia:

Desabilitando ou ignorando temporariamente

@Test(.disabled("Aguardando correção do bug #1247"))
func recursoQuebrado() { /* ... */ }

Limitando por sistema operacional

@Test(.enabled(if: ProcessInfo.processInfo.isOperatingSystemAtLeast(
    .init(majorVersion: 26, minorVersion: 0, patchVersion: 0)
)))
func recursoExclusivoIOS26() { /* ... */ }

Definindo limite de tempo

@Test(.timeLimit(.minutes(1)))
func operacaoLonga() async throws { /* ... */ }

Forçando execução serial

Por padrão, todos os testes rodam em paralelo. Se uma suite acessa um recurso compartilhado (arquivo, banco em disco, mock global), aplique .serialized:

@Suite(.serialized)
struct TestesQueAcessamArquivoCompartilhado {
    @Test func escreveELe() throws { /* ... */ }
    @Test func sobrescreveEverifica() throws { /* ... */ }
}

Tags: organizando testes por categoria

Tags são uma forma de agrupar testes que cruzam fronteiras de suites. Defina-as em uma extensão de Tag:

extension Tag {
    @Tag static var rede: Self
    @Tag static var lento: Self
    @Tag static var critico: Self
}

Depois é só aplicar como trait:

@Test(.tags(.rede, .lento))
func sincronizaComServidorRemoto() async throws { /* ... */ }

@Test(.tags(.critico))
func calculoFinanceiroExato() { /* ... */ }

No Xcode 26, a aba Tags do Test Navigator permite filtrar e executar testes por tag. Em CI, você pode rodar apenas tags específicas com xcodebuild test -only-testing-tag — útil para criar pipelines rápidos (sem a tag lento) e pipelines completos (todas as tags). No último projeto que trabalhei, isso cortou o tempo de feedback do PR de 14 para 3 minutos.

Confirmation: testando eventos assíncronos

O tipo Confirmation substitui XCTestExpectation de forma muito mais fluida. Ele confirma que um evento ocorre um número específico de vezes durante a execução de um bloco:

@Test func notificaObservadoresQuandoEstadoMuda() async {
    let store = Store()

    await confirmation("Observador foi chamado", expectedCount: 1) { confirmar in
        store.observar { _ in
            confirmar()
        }
        store.atualizarEstado(.novo)
    }
}

No Swift 6.2, o expectedCount aceita RangeExpression, o que permite verificações como "entre 2 e 5 chamadas":

await confirmation("Retry executado", expectedCount: 2...5) { confirmar in
    await servico.executarComRetry { confirmar() }
}

Exit tests: validando crashes esperados

Novidade do Swift 6.2 que eu queria há anos: exit tests. Eles permitem verificar que um determinado código encerra o processo (por fatalError, precondition, etc.). Antes, era praticamente impossível testar isso sem hacks bizarros.

@Test func acessoForaDosLimitesProvocaCrash() async {
    await #expect(processExitsWith: .failure) {
        var array: [Int] = []
        _ = array[10]
    }
}

Migrando uma suite XCTest para Swift Testing

O processo é mecânico para a maioria dos casos. Considere este teste XCTest típico:

import XCTest
@testable import Carrinho

final class CarrinhoTests: XCTestCase {

    var carrinho: Carrinho!

    override func setUp() {
        super.setUp()
        carrinho = Carrinho()
    }

    override func tearDown() {
        carrinho = nil
        super.tearDown()
    }

    func testAdicionarItemAumentaTotal() {
        carrinho.adicionar(Item(preco: 10))
        XCTAssertEqual(carrinho.total, 10)
    }

    func testRemoverItemInexistenteFalha() {
        XCTAssertThrowsError(try carrinho.remover(id: "nao-existe"))
    }
}

E aqui a versão equivalente em Swift Testing — repare como o código simplesmente encolhe:

import Testing
@testable import Carrinho

@Suite struct CarrinhoTests {

    let carrinho = Carrinho()

    @Test func adicionarItemAumentaTotal() {
        carrinho.adicionar(Item(preco: 10))
        #expect(carrinho.total == 10)
    }

    @Test func removerItemInexistenteFalha() {
        #expect(throws: (any Error).self) {
            try carrinho.remover(id: "nao-existe")
        }
    }
}

Checklist de migração

  1. Troque import XCTest por import Testing.
  2. Remova a herança de XCTestCase e prefira struct.
  3. Mova o conteúdo de setUp() para o init; remova tearDown() ou mova para deinit.
  4. Anote cada método com @Test e remova o prefixo test do nome.
  5. Substitua XCTAssert* por #expect ou #require.
  6. Substitua XCTestExpectation por confirmation.
  7. Avalie consolidar testes repetitivos com @Test(arguments:).

O que não migrar (ainda)

Algumas categorias permanecem exclusivas do XCTest no Xcode 26:

  • Testes de UI: XCUIApplication, XCUIElement e gravações de UI continuam sendo XCTest. Mantenha esses testes em arquivos separados.
  • Testes de performance: XCTMetric, XCTMemoryMetric e measure { } ainda não têm equivalente em Swift Testing.

Regra de ouro (e levei um tombo até aprender): nunca misture XCTAssert com #expect dentro de uma mesma função de teste. Os dois frameworks coexistem em targets, mas não dentro de um mesmo método.

Integração com CI/CD no Xcode 26

Para rodar Swift Testing via linha de comando:

# Pacote SwiftPM
swift test

# Projeto Xcode
xcodebuild test \
  -scheme MeuApp \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro'

Para filtrar por tag em CI:

xcodebuild test \
  -scheme MeuApp \
  -only-testing-tag critico \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro'

O Swift Testing roda em paralelo dentro do mesmo processo via Swift Concurrency, ao contrário do XCTest, que abre múltiplas instâncias de Simulator. O resultado prático: builds 2x a 5x mais rápidos em projetos com muitos testes, e ainda dá pra paralelizar em dispositivos físicos.

Boas práticas adotadas pela comunidade em 2026

  • Centralize tags em um único arquivo (tipo TestTags.swift) para facilitar descoberta e evitar duplicação.
  • Extraia traits reutilizáveis em um diretório próprio. Custom traits (Swift 6.1+) permitem encapsular setup/teardown que antes vivia em hierarquias de classes.
  • Mocks e fixtures em arquivos separados dos testes, para que cada arquivo de teste fique focado em comportamento.
  • Prefira parametrização a múltiplos testes copiados: reduz código e melhora os relatórios de falha.
  • Use .serialized apenas quando estritamente necessário — o paralelismo é uma das maiores vantagens do framework.
  • Não migre tudo de uma vez: XCTest e Swift Testing convivem. Comece pelos novos testes e migre módulos antigos quando tocar neles. Quem tentou o "big bang" se arrependeu.

Perguntas frequentes

O Swift Testing substitui completamente o XCTest?

Não. O XCTest continua suportado e é o único caminho oficial para testes de UI (XCUIApplication) e medições de performance (XCTMetric). Para testes unitários e de integração em código Swift, o Swift Testing é a recomendação atual da Apple, mas a transição pode ser gradual — os dois frameworks rodam no mesmo target.

Preciso do Xcode 26 para usar Swift Testing?

Não. O Swift Testing está disponível desde o Xcode 16 e Swift 6.0. O Xcode 26 traz melhorias como exit tests, ranged confirmations e novos scoping traits, mas o conjunto principal de funcionalidades já existia em versões anteriores. Para projetos open-source, ele também funciona em Linux e Windows via swift-testing.

Como rodar apenas alguns testes específicos?

Você pode usar tags (xcodebuild test -only-testing-tag minhaTag), filtrar por nome qualificado (xcodebuild test -only-testing:MeuTarget/CarrinhoTests/adicionarItem) ou usar a interface gráfica do Test Navigator no Xcode, que ganhou no Xcode 26 uma aba dedicada para tags.

Qual a diferença entre #expect e #require?

#expect registra a falha e continua executando o restante do teste — útil quando você quer ver todas as falhas de uma vez. #require falha e interrompe imediatamente o teste, sendo a escolha correta para pré-condições. Quando aplicado a um Optional, #require também faz unwrap e retorna o valor desembrulhado.

Posso testar código com async/await sem usar XCTestExpectation?

Sim, e é muito mais limpo. Funções de teste podem ser async diretamente: @Test func minhaFunc() async throws { ... }. Para verificar que um callback é chamado N vezes durante uma operação assíncrona, use a API confirmation, que substitui XCTestExpectation com sintaxe declarativa e suporte a range expressions no Swift 6.2.

Conclusão

O Swift Testing não é só uma "versão moderna" do XCTest — é uma reformulação completa de como testes deveriam ser escritos em Swift desde o início. Macros eliminam boilerplate, parametrização reduz duplicação, traits oferecem composição flexível, e o suporte nativo a concurrency torna testes assíncronos triviais.

Com Xcode 26 e Swift 6.2, o framework está maduro o suficiente para ser a escolha padrão em qualquer projeto novo. Para projetos existentes, a migração incremental é segura: adicione import Testing, escreva os próximos testes na nova sintaxe e migre o código legado quando tocar nele. Em 2026, ainda escrever XCTAssertEqual em código novo é, no mínimo, um sinal de que vale a pena revisitar suas práticas de teste. Bons testes!

Sobre o Autor Daniel Okafor

Daniel is a former Spotify iOS engineer (2019-2024) who worked on the Now Playing surface and the in-app podcast player. He shipped the SwiftUI rewrite of the lyrics view to over 600 million users and contributed several fixes upstream to swift-collections. His writing tends toward the unglamorous corners of iOS work: build-time regressions in Xcode 16, why SwiftData still isn't ready for production sync scenarios, and how to instrument a real app with os_signpost without drowning in noise. He spent two years before Spotify at a fintech startup in Berlin building a banking app on top of Solaris API. Daniel now freelances out of Lisbon and maintains a small open-source library for type-safe deep links in SwiftUI. He has 9 years of native iOS experience.