Wprowadzenie — dlaczego Swift Testing zmienia zasady gry
Przez lata XCTest był jedynym oficjalnym frameworkiem do testowania kodu Swift. I działał — nie zrozumcie mnie źle — ale wymagał dziedziczenia po XCTestCase, nazywania metod prefiksem test, a lista asercji typu XCTAssertEqual, XCTAssertNil, XCTAssertThrowsError rosła i rosła. Do tego testowanie kodu asynchronicznego z XCTestExpectation i waitForExpectations potrafiło przyprawić o ból głowy.
Na WWDC 2024 Apple zaprezentowało Swift Testing — zupełnie nowy framework testowy. I uwaga: to nie jest „XCTest 2.0". To całkowicie nowa biblioteka zbudowana od podstaw z myślą o nowoczesnym Swifcie. Wykorzystuje makra, natywnie wspiera Swift Concurrency i działa na wszystkich platformach obsługiwanych przez Swift — macOS, iOS, Linux, a nawet Windows.
W tym artykule przejdziemy przez wszystkie kluczowe elementy Swift Testing: od podstawowych makr #expect i #require, przez testy parametryzowane i cechy (traits), aż po testowanie kodu asynchronicznego i migrację z XCTest. Bez lania wody — same konkrety z działającymi przykładami kodu.
Wymagania i konfiguracja
Swift Testing jest dołączony do Xcode 16 (i nowszych wersji, w tym Xcode 26) oraz toolchainu Swift 6. Dobra wiadomość? Nie musisz dodawać żadnych zewnętrznych zależności — framework jest dostępny od razu po instalacji Xcode.
Tworzenie nowego projektu z Swift Testing
Przy tworzeniu nowego projektu w Xcode możesz po prostu wybrać Swift Testing jako framework testowy. Jeśli masz już istniejący projekt, wystarczy dodać nowy plik do targetu testowego i zaimportować framework:
import Testing
@testable import TwojaAplikacja
Ważne: Swift Testing i XCTest mogą spokojnie współistnieć w tym samym targecie testowym. Możesz nawet mieszać oba frameworki w jednym pliku źródłowym — co jest świetne jeśli planujesz stopniową migrację.
Podstawy — makro @Test i pierwsze asercje
W Swift Testing testy definiujesz za pomocą makra @Test. Koniec z dziedziczeniem po klasie bazowej. Koniec z prefiksem test w nazwie funkcji.
Najprostszy test
import Testing
@Test func dodawanieDwochLiczb() {
let wynik = 2 + 3
#expect(wynik == 5)
}
I to wszystko. Żadnej klasy, żadnego dziedziczenia. Funkcja oznaczona makrem @Test jest automatycznie rozpoznawana jako test. Szczerze mówiąc, kiedy pierwszy raz to zobaczyłem, poczułem ulgę — tyle boilerplate'u mniej.
Czytelne nazwy testów
Makro @Test pozwala dodać opisową nazwę, która wyświetla się w nawigatorze testów:
@Test("Dodawanie dwóch liczb zwraca poprawną sumę")
func dodawanieDwochLiczb() {
#expect(2 + 3 == 5)
}
To drobnostka, ale czytelność w nawigatorze testów robi naprawdę dużą różnicę, gdy masz ich setki.
Makra #expect i #require — dwa filary asercji
Tu jest ciekawie. Swift Testing zastępuje kilkanaście funkcji XCTAssert* zaledwie dwoma makrami: #expect i #require. Oba przyjmują zwykłe wyrażenia boolowskie Swifta i automatycznie przechwytują wartości operandów w przypadku błędu.
#expect — asercja miękka
#expect to tak zwana „miękka asercja" (soft assertion). Gdy test nie przejdzie, błąd jest rejestrowany, ale wykonanie testu kontynuuje się dalej. Dzięki temu w jednym teście możesz złapać wiele problemów naraz:
@Test("Obiekt Person ma poprawne właściwości")
func wlasciwosciPerson() {
let person = Person(imie: "Jan", nazwisko: "Kowalski", wiek: 30)
#expect(person.imie == "Jan")
#expect(person.nazwisko == "Kowalski")
#expect(person.wiek == 30)
#expect(person.pelneImie == "Jan Kowalski")
}
Jeśli person.wiek wyniesie 29 zamiast 30, zobaczysz w raporcie dokładny komunikat z wartościami obu stron porównania. Dużo bardziej pomocne niż lakoniczne „XCTAssertEqual failed" z XCTest.
#require — asercja twarda
#require to z kolei „twarda asercja" (hard assertion). Gdy warunek nie jest spełniony, test natychmiast się kończy — rzuca błąd i przerywa dalsze wykonywanie. Idealny do warunków wstępnych, bez których dalsze testowanie nie ma sensu:
@Test("Ładowanie danych użytkownika")
func ladowanieDanychUzytkownika() throws {
let json = """
{"id": 1, "name": "Anna Nowak"}
"""
let data = try #require(json.data(using: .utf8))
let user = try #require(JSONDecoder().decode(User.self, from: data) as User?)
#expect(user.name == "Anna Nowak")
#expect(user.id == 1)
}
Warto zwrócić uwagę, że #require wymaga użycia try — niezależnie od tego, czy samo wyrażenie wewnątrz rzuca błąd. Mechanizm #require opiera się na rzucaniu wyjątku w celu przerwania testu, stąd ten wymóg.
#require jako zamiennik XCTUnwrap
Bezpieczne odpakowywanie optionali to chleb powszedni w testach. W XCTest mieliśmy do tego XCTUnwrap. W Swift Testing po prostu używamy #require:
// XCTest (stary sposób)
func testFindUser() throws {
let user = try XCTUnwrap(database.find(id: 42))
XCTAssertEqual(user.name, "Jan")
}
// Swift Testing (nowy sposób)
@Test func znajdzUzytkownika() throws {
let user = try #require(database.find(id: 42))
#expect(user.name == "Jan")
}
Kiedy używać #expect, a kiedy #require?
Prosta zasada:
| Scenariusz | Makro |
|---|---|
| Ogólne sprawdzenie wartości | #expect |
| Kilka niezależnych asercji w jednym teście | #expect |
| Warunek wstępny — bez niego dalszy test nie ma sensu | #require |
| Odpakowanie optionala | #require |
| Sprawdzenie, czy dekodowanie się powiodło | #require |
Organizacja testów — Suite i struktury
W XCTest testy musiały być metodami klasy dziedziczącej po XCTestCase. Swift Testing daje znacznie więcej swobody — możesz używać struktur, klas, a nawet aktorów. I szczerze? Struktury sprawdzają się tu najlepiej.
Grupowanie testów za pomocą @Suite
@Suite("Testy kalkulatora")
struct CalculatorTests {
let calculator = Calculator()
@Test("Dodawanie")
func dodawanie() {
#expect(calculator.add(2, 3) == 5)
}
@Test("Odejmowanie")
func odejmowanie() {
#expect(calculator.subtract(5, 3) == 2)
}
@Test("Mnożenie")
func mnozenie() {
#expect(calculator.multiply(4, 3) == 12)
}
}
Ale to nie koniec — suite mogą być zagnieżdżane, tworząc fajną hierarchiczną strukturę:
@Suite("Testy modelu danych")
struct DataModelTests {
@Suite("Operacje CRUD")
struct CRUDTests {
@Test func tworzenie() { /* ... */ }
@Test func odczyt() { /* ... */ }
@Test func aktualizacja() { /* ... */ }
@Test func usuwanie() { /* ... */ }
}
@Suite("Walidacja")
struct ValidationTests {
@Test func walidacjaEmail() { /* ... */ }
@Test func walidacjaHasla() { /* ... */ }
}
}
Inicjalizacja i sprzątanie (init/deinit)
Zamiast setUp() i tearDown() z XCTest, Swift Testing tworzy nową instancję suite dla każdego testu. Logikę przygotowującą umieszczasz w init(), a sprzątanie w deinit. Proste i naturalne:
@Suite("Testy z bazą danych")
struct DatabaseTests {
let database: TestDatabase
init() async throws {
database = try await TestDatabase.create()
try await database.seed(with: .sampleData)
}
@Test func wyszukiwaniePoId() async throws {
let user = try #require(await database.find(id: 1))
#expect(user.name == "Jan Kowalski")
}
}
Testy parametryzowane — jeden test, wiele wartości
To jest moim zdaniem jedna z najlepszych funkcji Swift Testing. Serio. Testy parametryzowane pozwalają uruchomić tę samą logikę testową z wieloma zestawami danych — bez kopiowania kodu w kółko.
Podstawowy test parametryzowany
@Test("Parzyste liczby", arguments: [2, 4, 6, 8, 10, 100])
func sprawdzParzyste(liczba: Int) {
#expect(liczba.isMultiple(of: 2))
}
Swift Testing automatycznie tworzy oddzielne przypadki testowe dla każdego argumentu. Co więcej — każdy przypadek jest niezależny i może działać równolegle. To ogromna przewaga nad tradycyjną pętlą for wewnątrz testu.
Test parametryzowany z enumem
enum StatusKodu: Int, CaseIterable {
case ok = 200
case created = 201
case badRequest = 400
case notFound = 404
case serverError = 500
}
@Test("Klasyfikacja kodów HTTP", arguments: StatusKodu.allCases)
func klasyfikacjaKodow(status: StatusKodu) {
switch status {
case .ok, .created:
#expect(status.rawValue < 400)
case .badRequest, .notFound:
#expect((400...499).contains(status.rawValue))
case .serverError:
#expect(status.rawValue >= 500)
}
}
Pułapka: wiele kolekcji i eksplozja kombinacji
Tu trzeba uważać. Gdy przekazujesz wiele kolekcji do testu parametryzowanego bez zip, Swift Testing tworzy iloczyn kartezjański — czyli testuje każdą możliwą kombinację. Przy 3 × 3 to jeszcze nie problem, ale przy większych zbiorach robi się ciekawie (czytaj: wolno).
// ⚠️ 3 × 3 = 9 przypadków testowych!
@Test(arguments: ["Jan", "Anna", "Piotr"], ["Kowalski", "Nowak", "Wiśniewski"])
func pelneImie(imie: String, nazwisko: String) {
let person = Person(imie: imie, nazwisko: nazwisko)
#expect(person.pelneImie == "\(imie) \(nazwisko)")
}
// ✅ Jeśli chcesz testować pary 1:1, użyj zip
@Test(arguments: zip(
["Jan", "Anna", "Piotr"],
["Kowalski", "Nowak", "Wiśniewski"]
))
func pelneImieParami(imie: String, nazwisko: String) {
let person = Person(imie: imie, nazwisko: nazwisko)
#expect(person.pelneImie == "\(imie) \(nazwisko)")
}
Cechy (Traits) — kontrola zachowania testów
Cechy to modyfikatory dołączane do testów lub suite, które pozwalają dostosować ich zachowanie. Jest ich sporo i są naprawdę przydatne.
Tagi — kategoryzacja testów
Tagi pozwalają grupować testy według dowolnych kryteriów, niezależnie od struktury plików. To coś, czego brakowało mi w XCTest:
extension Tag {
@Tag static var networking: Self
@Tag static var database: Self
@Tag static var ui: Self
}
@Test(.tags(.networking))
func pobranieDanych() async throws {
// test operacji sieciowej
}
@Test(.tags(.database, .networking))
func synchronizacjaDanych() async throws {
// test wymagający sieci i bazy danych
}
W nawigatorze testów w Xcode znajdziesz specjalną zakładkę wyświetlającą testy pogrupowane według tagów — super wygodne, gdy chcesz uruchomić np. wszystkie testy sieciowe jednym kliknięciem.
Warunkowe włączanie i wyłączanie testów
@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil))
func testTylkoNaCI() {
// ten test uruchomi się tylko na serwerze CI
}
@Test(.disabled("Czeka na fix buga #1234"))
func testZBugiem() {
// ten test jest tymczasowo wyłączony
}
Powiązanie z bug trackerem
@Test(.bug("https://github.com/myproject/issues/42", "Crash przy pustym JSON"))
func parsowaniePustegoJSON() throws {
let data = "{}".data(using: .utf8)!
#expect(throws: ParsingError.self) {
try Parser.parse(data)
}
}
Limit czasu i serializacja
@Test(.timeLimit(.minutes(2)))
func dlugaOperacja() async throws {
// test zakończy się niepowodzeniem po 2 minutach
}
@Suite(.serialized)
struct TestyZWspolnymStanem {
// testy w tej suite uruchomią się sekwencyjnie, nie równolegle
@Test func krok1() { /* ... */ }
@Test func krok2() { /* ... */ }
}
Testowanie kodu asynchronicznego
Swift Testing natywnie wspiera Swift Concurrency — i robi to naprawdę dobrze. Testy asynchroniczne definiujesz dokładnie tak, jak zwykłe funkcje async. Żadnych expectation, żadnych wait — czysta elegancja.
Prosty test async/await
@Test("Pobieranie listy użytkowników z API")
func pobieranieUzytkownikow() async throws {
let service = UserService()
let users = try await service.fetchUsers()
#expect(users.isEmpty == false)
#expect(users.count >= 1)
let first = try #require(users.first)
#expect(first.name.isEmpty == false)
}
Testowanie rzucanych błędów
Gdy chcesz sprawdzić, czy kod rzuca konkretny typ błędu, masz do dyspozycji wygodną składnię:
@Test("Nieprawidłowy URL rzuca błąd")
func nieprawidlowyURL() async {
let service = UserService()
await #expect(throws: NetworkError.self) {
try await service.fetch(from: "to-nie-jest-url")
}
}
// Bardziej szczegółowe sprawdzenie konkretnego case'u błędu
@Test("Brak połączenia zwraca odpowiedni błąd")
func brakPolaczenia() async {
await #expect {
try await service.fetchData()
} throws: { error in
guard let networkError = error as? NetworkError else { return false }
return networkError == .noConnection
}
}
API confirmation — odpowiednik XCTestExpectation
Dla API opartych na callback/closure Swift Testing oferuje confirmation(). To taki nowoczesny odpowiednik XCTestExpectation, ale o wiele prostszy w użyciu:
@Test("Powiadomienie o zakończeniu synchronizacji")
func powiadomienieSynchronizacji() async {
let syncer = DataSynchronizer()
await confirmation { confirm in
syncer.onComplete = {
confirm()
}
await syncer.synchronize()
}
}
A jeśli pracujesz z legacy API opartym na callbackach, najlepiej użyć continuation:
@Test("Callback zwraca wynik")
func callbackZwracaWynik() async throws {
let result: String = try await withCheckedThrowingContinuation { continuation in
legacyAPI.fetchData { result in
switch result {
case .success(let value):
continuation.resume(returning: value)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
#expect(result.isEmpty == false)
}
Nowości w Swift 6.2 — Exit Tests i Ranged Confirmations
Swift 6.2 (dostępny w Xcode 26) przynosi kolejne ciekawe dodatki do Swift Testing. Oto dwa najważniejsze.
Exit Tests
Wreszcie! Exit testy pozwalają testować kod, który celowo kończy działanie procesu — na przykład fatalError() czy preconditionFailure(). Wcześniej nie było żadnego oficjalnego sposobu na przetestowanie takich scenariuszy:
@Test("fatalError przy ujemnym indeksie")
func fatalErrorPrzyUjemnymIndeksie() async {
await #expect(processExitsWith: .failure) {
let array = MyArray([1, 2, 3])
_ = array[-1] // powinno wywołać fatalError
}
}
Ranged Confirmations
Rozszerzone potwierdzenia pozwalają określić zakres oczekiwanych wywołań — przydatne, gdy nie wiesz dokładnie ile razy coś zostanie wywołane, ale wiesz, że powinno się zmieścić w pewnym przedziale:
@Test("Delegat jest wywoływany co najmniej 2 razy")
func delegatWywolywanyWielokrotnie() async {
await confirmation(expectedCount: 2...5) { confirm in
let monitor = EventMonitor { _ in
confirm()
}
await monitor.startListening(for: .seconds(1))
}
}
Migracja z XCTest — krok po kroku
OK, więc masz istniejące testy w XCTest i zastanawiasz się, jak podejść do migracji. Nie musisz migrować wszystkiego naraz — wręcz lepiej tego nie robić. Oto sprawdzona strategia.
Krok 1: Nowe testy pisz w Swift Testing
Każdy nowy test pisz już z użyciem @Test i #expect. Oba frameworki spokojnie współistnieją w jednym targecie.
Krok 2: Stopniowa migracja istniejących testów
Kiedy modyfikujesz istniejący test XCTest (np. z powodu buga lub zmiany logiki), przy okazji zmigruj go do Swift Testing. To naturalne podejście, które nie wymaga dedykowanego „dnia migracji".
Krok 3: Tabela odpowiedników
Miej tę tabelę pod ręką — przyda się podczas migracji:
| XCTest | Swift Testing |
|---|---|
import XCTest | import Testing |
class MyTests: XCTestCase | @Suite struct MyTests |
func testSomething() | @Test func something() |
XCTAssertEqual(a, b) | #expect(a == b) |
XCTAssertTrue(x) | #expect(x) |
XCTAssertNil(x) | #expect(x == nil) |
XCTAssertThrowsError | #expect(throws:) { } |
try XCTUnwrap(x) | try #require(x) |
XCTFail("powód") | Issue.record("powód") |
setUp() / tearDown() | init() / deinit |
Czego NIE da się zmigrować
Trzeba to jasno powiedzieć — Swift Testing nie obsługuje kilku rzeczy:
- Testów UI (UI Automation) — nadal wymagają XCTest i
XCUIApplication - Testów wydajności (
XCTMetric,measure { }) — brak odpowiednika w Swift Testing - Testów w Objective-C — Swift Testing wspiera wyłącznie Swift
Więc XCTest na pewno nie zniknie z Twojego projektu całkowicie — przynajmniej nie w najbliższym czasie.
Dobre praktyki
Na koniec kilka sprawdzonych zasad, które warto stosować od samego początku:
- Używaj struktur zamiast klas — Apple rekomenduje
structjako domyślny typ suite, chyba że potrzebujeszdeinitdo sprzątania zasobów - Nadawaj opisowe nazwy — korzystaj z parametru nazwy w
@Test("opis"), aby nawigator testów był czytelny (Twoje przyszłe „ja" będzie wdzięczne) - Stosuj tagi do kategoryzacji — zamiast polegać wyłącznie na strukturze folderów, taguj testy według funkcjonalności
- Preferuj #expect dla wielu asercji — dzięki temu zobaczysz wszystkie błędy naraz, zamiast naprawiać je jeden po drugim
- Używaj #require dla warunków wstępnych — nie ma sensu kontynuować testu, jeśli kluczowe dane nie istnieją
- Testuj parametryzowanie zamiast duplikacji — jeden test z argumentami zamiast dziesięciu niemal identycznych testów
- Nie mieszaj frameworków w jednym teście — nie wywołuj
XCTAssertz testu Swift Testing ani#expectz testu XCTest
FAQ — najczęściej zadawane pytania
Czy XCTest jest przestarzały (deprecated)?
Nie. Apple nie oznaczyło XCTest jako deprecated i raczej nie planuje go wycofywać. Oba frameworki będą współistnieć. Jednak Swift Testing jest rekomendowanym wyborem dla nowych testów jednostkowych w Swifcie.
Czy mogę używać Swift Testing z UIKit?
Jak najbardziej. Swift Testing jest niezależny od frameworka UI. Możesz testować nim dowolny kod Swift — modele danych, logikę biznesową, warstwy sieciowe — niezależnie od tego, czy Twoja aplikacja używa SwiftUI, UIKit, czy obu. Jedyny wyjątek to testy UI (XCUITest), które nadal wymagają XCTest.
Czy Swift Testing działa na Linuxie?
Tak! Swift Testing jest wieloplatformowy i działa na macOS, iOS, Linux i Windows. To spora przewaga nad XCTest, który na Linuxie miał spore ograniczenia (pamiętacie allTests?).
Jak uruchamiać testy równolegle?
Nie musisz nic konfigurować — testy w Swift Testing działają równolegle domyślnie. Zarówno synchroniczne, jak i asynchroniczne. Jeśli natomiast potrzebujesz sekwencyjnego wykonania (np. ze względu na współdzielony stan), dodaj cechę .serialized do suite.
Czy warto migrować istniejące testy XCTest?
Rekomendowane podejście to strategia stopniowa: nowe testy pisz w Swift Testing, a istniejące migruj przy okazji modyfikacji. Nie ma potrzeby masowej migracji. Z czasem Twoja baza testów stanie się bardziej spójna i czytelna dzięki nowoczesnemu API — ale nie spiesz się z tym na siłę.