Testes e qualidade de código

Como testar código assíncrono em Swift com async/await

No Swift 5.5 ou posterior, declare as funções de teste como async, aguarde o resultado com await e depois faça a validação. Este guia também aborda validação de erros, testes de APIs antigas baseadas em callback e os casos em que ainda é necessário usar expectation.

3 min de leitura
Imagem de capa de Como testar código assíncrono em Swift com async/await

Você já se acostumou a escrever código com async/await, mas já ficou sem saber por onde começar quando chegou a hora de escrever os testes?

Isso acontece principalmente quando ainda resta o hábito de testar o inferno de callbacks adicionando XCTestExpectation a wait(for:).

Vamos direto ao ponto. Com Swift 5.5 ou posterior (Xcode 13+), declare a própria função de teste ** como async, aguarde o resultado com await e faça a validação com **. A abordagem antiga com expectations e timeouts deixou de ser necessária na maioria dos casos.

Hoje vou explicar, do início ao fim, as formas de testar async/await que organizei na prática profissional.

Resumo dos pontos principais

Para quem está com pressa, estes são os pontos essenciais do artigo.

  1. Adicione async throws ao método de teste e chame await dentro dele
  2. Para validar erros, use do-catch ou o padrão da versão async de XCTAssertThrowsError
  3. Use expectation apenas quando um timeout for realmente necessário
  4. Envolva APIs antigas de callback com withCheckedThrowingContinuation para testá-las

Como testar async/await no Swift?

Vamos começar pelo formato mais básico.

Antes, criávamos uma expectation para aguardar o resultado assíncrono e chamávamos fulfill dentro de uma closure. O código ficava longo e difícil de ler.

Agora ficou muito mais conciso.

// Basta adicionar  async throws à função de teste
func test_Carregar usuário_() async throws {
    let service = UserService()
    let user = try await service.fetchUser(id: 1)
    XCTAssertEqual(user.name, "Lee Seok-woo")
}

Há exatamente dois pontos para observar no código acima.

Primeiro, async throws foi adicionado à declaração da função. Segundo, depois de aguardar o resultado com try await, fazemos a validação normalmente com XCTAssertEqual.

Ao tornar a própria função de teste async, o código assíncrono passa a ser lido de cima para baixo como código síncrono.

Depois que mudei para essa abordagem, reduzi quase pela metade o número de linhas do código de teste.

Função de teste async do Swift com a palavra-chave await destacada e uma marca verde
Se a função async também for testada como async, está resolvido

Como validar os casos em que ocorre um erro?

Também é comum testar situações em que uma função assíncrona lança um erro.

A forma mais intuitiva é do-catch.

func test_Usuário_ inexistente_ erro() async {
    let service = UserService()
    do {
        _ = try await service.fetchUser(id: -1)
        XCTFail("O correto é ocorrer um erro")
    } catch {
        XCTAssertTrue(error is UserError)
    }
}

O ponto principal é adicionar XCTFail ao caso de sucesso.

Se nenhum erro ocorrer e o teste simplesmente passar, pode parecer que ele foi bem-sucedido silenciosamente. Pense nisso como uma proteção contra esse problema.

A propósito, XCTAssertThrowsError usado no código síncrono não consegue receber uma função async diretamente por padrão. Por isso, costumo escrevê-lo explicitamente com do-catch, como acima.


Como testar APIs antigas baseadas em callback?

Seria ótimo se todo o código já tivesse migrado para async, mas a realidade é outra.

O projeto provavelmente ainda tem APIs antigas que retornam resultados por meio de completion handlers.

Nesse caso, envolva-as com withCheckedThrowingContinuation e leve-as para o mundo async.

func fetchLegacy() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        oldAPI { data, error in
            if let error { continuation.resume(throwing: error) }
            else { continuation.resume(returning: data!) }
        }
    }
}

Depois de envolvê-las, no teste basta chamar com try await fetchLegacy(), exatamente como na abordagem anterior.

Um cuidado importante: uma continuation deve resume exatamente uma vez. Chamá-la duas vezes causa um crash; não chamá-la faz o teste travar para sempre.


Quando usar expectations e quando usar testes async

Comparei as duas abordagens. (Xcode 13 ou posterior, em 2026)

Item Abordagem com expectation Abordagem de teste async
Tamanho do código Longo Curto
Legibilidade Callbacks aninhados Sequencial, de cima para baixo
Definição de timeout Fácil Requer tratamento separado
Situação recomendada Aguardar notificações ou timers A maioria das operações assíncronas

Para testes comuns de funções async/await, a abordagem async é muito mais conveniente.

Porém, quando um timeout explícito é importante—por exemplo, para verificar se uma notificação chega dentro de um período específico ou se um timer funciona no momento certo—continua sendo útil usar expectation em conjunto.

Tela do navegador de testes do Xcode mostrando uma marca verde de Test Passed
O momento em que a luz verde aparece é sempre o mais gratificante

Resumo

Mesmo que pareça estranho no início, basta adicionar async throws uma vez à função de teste para você se perguntar por que não mudou antes.

Depois de dominar os quatro padrões de hoje, você conseguirá cobrir a maioria dos testes assíncronos sem dificuldade. Aplique um de cada vez; estou torcendo por você!