cat swift/milestone-test-suite-professionale.md

Lezione 7675 min

Milestone 4: test suite professionale

Costruire una suite di test proporzionata al progetto finale: dominio, mapping, async, errori, cache, contratti e regressioni.

Il progetto finale deve avere test che raccontano fiducia, non solo copertura numerica.

Una test suite professionale protegge:

  • invarianti del dominio;
  • use case;
  • mapping DTO;
  • errori;
  • cache;
  • retry;
  • cancellation;
  • interfaccia pubblica;
  • bug scoperti durante lo sviluppo.

Struttura test

Tests/
  ProjectDomainTests/
    SearchQueryTests.swift
    StoryTests.swift
  ProjectApplicationTests/
    SearchStoriesUseCaseTests.swift
  ProjectInfrastructureTests/
    RemoteStoryRepositoryTests.swift
    CachedStoryRepositoryTests.swift
    RetryExecutorTests.swift
  ProjectPresentationTests/
    StoryListViewModelTests.swift

Non tutti i progetti hanno bisogno di tutti questi target, ma ogni responsabilità importante deve avere un posto.

Test naming

Un buon nome descrive comportamento:

@Test
func queryRifiutaStringheTroppoCorte()

Meglio di:

@Test
func testInit()

Nei test il nome è documentazione viva.

Test del dominio

@Test(arguments: ["", " ", "s"])
func queryRifiutaInputNonValido(value: String) {
  #expect(throws: SearchError.queryTooShort) {
    try SearchQuery(value)
  }
}

@Test
func queryNormalizzaSpazi() throws {
  let query = try SearchQuery("  swift concurrency  ")

  #expect(query.value == "swift concurrency")
}

Questi test sono piccoli e veloci.

Test use case

@Test
func searchOrdinaStoriePerScore() async throws {
  let repository = InMemoryStoryRepository(stories: [.lowScore, .highScore])
  let useCase = SearchStoriesUseCase(repository: repository)

  let result = try await useCase.execute(query: try SearchQuery("swift"))

  #expect(result.map(\\.id) == [.highScoreID, .lowScoreID])
}

Qui testi comportamento applicativo, non rete.

Test mapping

@Test
func dtoConTitoloMancanteFallisce() {
  let dto = StoryDTO(objectID: "1", title: nil, url: nil, points: 10)

  #expect(throws: MappingError.missingTitle) {
    try dto.toDomain()
  }
}

Mapping è una zona critica: protegge il dominio dal mondo esterno.

Test repository remoto

@Test
func remoteRepositoryMappaStatusCode500() async {
  let repository = RemoteStoryRepository(
    client: StubHTTPClient(data: Data(), statusCode: 500),
    requestBuilder: RequestBuilder(baseURL: URL(string: "https://example.com")!)
  )

  await #expect(throws: NetworkError.statusCode(500)) {
    try await repository.search(query: try SearchQuery("swift"))
  }
}

Il test non chiama rete reale.

Test cache

@Test
func cachedRepositoryEvitaSecondaRichiesta() async throws {
  let remote = SpyStoryRepository(stories: [.sample])
  let repository = CachedStoryRepository(
    remote: remote,
    cache: StoryCache()
  )

  _ = try await repository.search(query: try SearchQuery("swift"))
  _ = try await repository.search(query: try SearchQuery("swift"))

  let count = await remote.searchCallCount
  #expect(count == 1)
}

Questo è un caso in cui verificare interazione ha senso: il comportamento è proprio evitare la seconda richiesta.

Test retry senza dormire

actor SpySleeper {
  private(set) var durations: [Duration] = []

  func sleep(for duration: Duration) async throws {
    durations.append(duration)
  }
}

Test:

@Test
func retryAspettaTraTentativi() async throws {
  let sleeper = SpySleeper()
  let executor = RetryExecutor(
    policy: .init(maxAttempts: 2, delay: .milliseconds(250)),
    sleep: { try await sleeper.sleep(for: $0) }
  )

  await #expect(throws: NetworkError.statusCode(500)) {
    try await executor.run {
      throw NetworkError.statusCode(500)
    }
  }

  let durations = await sleeper.durations
  #expect(durations == [.milliseconds(250)])
}

Non aspettare davvero 250 millisecondi se vuoi test veloci.

Test view model o CLI

SwiftUI view model:

@MainActor
@Test
func viewModelMostraErroreLeggibile() async {
  let viewModel = StoryListViewModel(
    search: SearchStoriesUseCase(repository: FailingStoryRepository(error: NetworkError.transport))
  )

  await viewModel.search("swift")

  #expect(viewModel.state == .failed("Impossibile caricare le storie."))
}

CLI:

@Test
func cliScriveRisultati() async throws {
  let output = SpyOutputWriter()
  let command = SearchCommand(search: .stub(stories: [.sample]), output: output)

  try await command.run(query: "swift")

  let lines = await output.lines
  #expect(lines.contains("Swift"))
}

Regression tests

Ogni bug importante scoperto durante il progetto deve diventare test.

Formato utile:

@Test
func regression_cacheNonRestituisceRisultatiDiQueryDiversa() async throws {
  ...
}

Il prefisso regression_ non è obbligatorio, ma rende chiara la storia.

Checklist minima

Per il progetto finale servono almeno:

  • 3 test dominio;
  • 2 test use case;
  • 2 test mapping;
  • 2 test repository;
  • 1 test cache;
  • 1 test errore;
  • 1 test async/cancellation o retry;
  • 1 test presentation;
  • nessuna rete reale nei test unitari.

Esercizio

Costruisci la suite minima.

Poi aggiungi una tabella nel README:

## Test coverage by behavior

| Area | Covered |
| --- | --- |
| Domain invariants | yes |
| Use cases | yes |
| Mapping | yes |
| Network errors | yes |
| Cache | yes |
| Cancellation | yes |

Obiettivo: chi valuta il progetto deve capire cosa è protetto senza leggere ogni test.