cat swift/milestone-test-suite-professionale.md
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.