Swift Testing: Kompletni vodič za moderno testiranje u Swiftu

Naučite kako koristiti Swift Testing — Appleov moderni framework za testiranje. Pokrivamo @Test i @Suite makroe, #expect asertacije, parametrizirane testove, traits, oznake i migraciju s XCTest-a uz praktične primjere koda.

Uvod u Swift Testing

Ruku na srce — ako ste ikada pisali testove u Swiftu koristeći XCTest, znate koliko je taj proces mogao biti... glomazan. Imenovanje metoda s prefiksom test, nasljeđivanje od XCTestCase, ograničena ekspresivnost asertacija — sve je to funkcioniralo, ali nikada nije djelovalo kao da je dizajnirano za moderni Swift. I iskreno? Bio je krajnje vrijeme da se to promijeni. I kad sam konačno dobio priliku isprobati zamjenu — odmah sam osjetio razliku.

Apple je na WWDC 2024 predstavio Swift Testing — potpuno novi framework za testiranje koji je od temelja građen za Swift. Ne radi se o kozmetičkim promjenama nad XCTest-om, već o fundamentalno drugačijem pristupu testiranju koji koristi najnovije značajke jezika: makroe, Swift Concurrency, vrijednosne tipove i mnogo toga više.

Zašto je Apple uopće stvorio novi framework? Nekoliko ključnih razloga:

  • XCTest datira iz Objective-C ere — dizajniran je za potpuno drugačiji programski model
  • Swift makroi omogućuju puno elegantniju sintaksu za deklariranje testova i asertacija
  • Concurrency podrška — Swift Testing je od samog početka dizajniran za async/await
  • Open source — framework je razvijen kao dio Swift open-source projekta, dostupan je i izvan Apple ekosustava
  • Parametrizirani testovi — značajka koja je bolno nedostajala u XCTest-u

Prije nego što krenemo dublje, dobro je znati da Swift Testing koegzistira s XCTest-om. Ne morate migrirati sve odjednom — oba frameworka mogu živjeti u istom projektu, pa čak i u istom test bundle-u. Ali jednom kada osjetite eleganciju novog pristupa, teško ćete se htjeti vratiti.

Ako ste već čitali naše članke o Swift Concurrency i Observation frameworku, primijetit ćete kako se Swift Testing savršeno uklapa u taj moderni ekosustav. Testiranje asinkronog koda nikada nije bilo jednostavnije.

Osnove Swift Testing frameworka

Započnimo s najosnovnijim — kako napisati test. Zaboravite na nasljeđivanje klasa i test prefikse. U Swift Testingu, sve što trebate je @Test makro:

import Testing

@Test func zbrajanjeFunkcioniraIspravno() {
    let rezultat = 2 + 2
    #expect(rezultat == 4)
}

To je to. Nema klase, nema nasljeđivanja, nema prefiksa. Samo funkcija označena s @Test i #expect makro za provjeru uvjeta. Primijetite koliko je ovo čitljivije od ekvivalentnog XCTest koda gdje biste morali napisati XCTAssertEqual(rezultat, 4) unutar klase koja nasljeđuje XCTestCase.

Makro #expect

#expect je primarni makro za asertacije. Prihvaća bilo koji Boolean izraz i — evo što je genijalno — kada test padne, prikazuje detaljnu dijagnostiku s konkretnim vrijednostima. Ne morate koristiti specijalizirane funkcije poput XCTAssertEqual, XCTAssertGreaterThan ili XCTAssertNil.

import Testing

struct Korisnik {
    let ime: String
    let email: String
    let godine: Int
}

@Test func korisnikImaIspravnePodatke() {
    let korisnik = Korisnik(ime: "Ana", email: "[email protected]", godine: 28)

    #expect(korisnik.ime == "Ana")
    #expect(korisnik.email.contains("@"))
    #expect(korisnik.godine >= 18)
    #expect(korisnik.godine < 150)
}

Ako, primjerice, korisnik.godine bude 15, poruka o grešci će jasno pokazati: "Expectation failed: korisnik.godine >= 18, where korisnik.godine = 15". To je ogromna prednost — nema više generičkih poruka "XCTAssertTrue failed".

Makro #require

Ponekad padanje jednog uvjeta znači da nema smisla nastaviti test. Za to postoji #require — on baca grešku ako uvjet nije ispunjen, čime se test odmah prekida:

@Test func dohvatiPrvogKorisnika() throws {
    let korisnici = dohvatiKorisnike()

    // Ako je lista prazna, nema smisla nastaviti
    let prvi = try #require(korisnici.first)

    // Ovdje je 'prvi' već unwrappan — nije Optional!
    #expect(prvi.ime.isEmpty == false)
    #expect(prvi.email.contains("@"))
}

#require je posebno koristan za unwrapping optionala. Umjesto XCTUnwrap, jednostavno koristite try #require(optionalVrijednost) i dobivate non-optional vrijednost ili se test prekida s jasnom porukom.

Jedna od onih malih stvari koje vam uštede toliko vremena da se zapitate — zašto ovo nismo imali prije?

Imenovanje testova s displayName

Želite li čitljivija imena testova u izvještajima, možete dodati display name:

@Test("Registracija korisnika s ispravnim podacima uspijeva")
func registracijaSIspravnimPodacima() {
    let rezultat = AuthService.registriraj(email: "[email protected]", lozinka: "Sigurna123!")
    #expect(rezultat.jeUspjesno)
}

Test Suite-ovi s @Suite makroom

U XCTest-u ste organizirali testove u klase. U Swift Testingu koristite @Suite makro, a nositelji mogu biti strukture, klase ili čak aktori. Strukture su preferirani izbor jer su vrijednosni tipovi — svaki test dobiva svoju kopiju stanja, čime se eliminiraju problemi s dijeljenim mutable stanjem između testova.

import Testing

@Suite("Autentifikacijski servis")
struct AuthServiceTests {
    let service = AuthService()

    @Test("Prijava s ispravnim podacima uspijeva")
    func prijavaUspjesna() async throws {
        let rezultat = try await service.prijava(
            email: "[email protected]",
            lozinka: "ispravnaLozinka123"
        )
        #expect(rezultat.token.isEmpty == false)
        #expect(rezultat.korisnik.email == "[email protected]")
    }

    @Test("Prijava s krivom lozinkom baca grešku")
    func prijavaNeuspjesna() async {
        await #expect(throws: AuthError.neispravniPodaci) {
            try await service.prijava(
                email: "[email protected]",
                lozinka: "krivaLozinka"
            )
        }
    }
}

Uočite nešto ključno — za svaki test kreira se nova instanca strukture AuthServiceTests, što znači da service uvijek počinje u čistom stanju. U XCTest-u biste morali paziti na setUp i tearDown metode; ovdje to dobivate besplatno.

Ugniježđeni Suite-ovi

Jedna od moćnijih značajki je mogućnost ugniježđivanja suite-ova za bolju organizaciju:

@Suite("Korisnički modul")
struct KorisnickiModulTests {

    @Suite("Validacija")
    struct ValidacijaTests {
        @Test func emailMoraImatiMalpu() {
            #expect(Validator.email("[email protected]"))
            #expect(!Validator.email("testbezmalpe.com"))
        }

        @Test func lozinkaMoraImatiMinimalno8Znakova() {
            #expect(!Validator.lozinka("Ab1!"))
            #expect(Validator.lozinka("Sigurna123!"))
        }
    }

    @Suite("Profil")
    struct ProfilTests {
        @Test func azuriranjeImenaMijenjaProfil() {
            var profil = KorisnickiProfil(ime: "Ana", prezime: "Horvat")
            profil.azurirajIme("Ivana")
            #expect(profil.ime == "Ivana")
        }
    }
}

U Xcode Test Navigatoru ovo se prikazuje kao lijepa hijerarhija — "Korisnički modul" > "Validacija" > pojedinačni testovi. Mnogo čitljivije nego ravna lista test metoda.

Parametrizirani testovi

Ovo je, po mom mišljenju, jedna od najkorisnijih značajki Swift Testinga. Koliko ste puta u XCTest-u imali for-in petlju unutar testa da biste testirali više ulaznih vrijednosti? (Budimo iskreni — previše puta.) Problem s tim pristupom: kada jedna iteracija padne, ne vidite odmah koja je vrijednost uzrokovala pad, a ostale se ne testiraju.

Parametrizirani testovi to rješavaju elegantno:

@Test("Validacija email adresa", arguments: [
    ("[email protected]", true),
    ("[email protected]", true),
    ("bez-malpe.com", false),
    ("@samo-domena.com", false),
    ("prazan", false),
    ("ime@", false),
    ("[email protected]", true)
])
func validacijaEmaila(email: String, ocekivanoIspravno: Bool) {
    let rezultat = Validator.jeIspravanEmail(email)
    #expect(rezultat == ocekivanoIspravno,
            "Email '\(email)' bi trebao biti \(ocekivanoIspravno ? "ispravan" : "neispravan")")
}

Svaka kombinacija parametara izvršava se kao zasebni test u izvještaju. Ako treći slučaj padne, ostali se i dalje izvršavaju, a u Xcode-u vidite točno koji parametar je uzrokovao pad. Osim toga, parametrizirani testovi se mogu izvršavati paralelno, što značajno ubrzava test suite.

Možete koristiti bilo koji tip koji je Sendable — uključujući enumove, strukture, pa čak i kombinacije više kolekcija:

enum Valuta: Sendable, CaseIterable {
    case eur, usd, gbp, hrk
}

@Test("Konverzija podržava sve valutne parove",
      arguments: Valuta.allCases, Valuta.allCases)
func konverzija(iz izvornaValuta: Valuta, u ciljnaValuta: Valuta) throws {
    let konverter = ValutniKonverter()
    let iznos = try konverter.konvertiraj(100, iz: izvornaValuta, u: ciljnaValuta)
    #expect(iznos > 0)
}

Ovaj test generira 16 kombinacija (4 x 4) — svaka se izvršava neovisno. U XCTest-u biste za ovo trebali ili napisati 16 zasebnih metoda ili koristiti petlju koja maskira koji testovi prolaze, a koji padaju.

Sama ova značajka bi, iskreno, bila dovoljan razlog za prelazak na Swift Testing.

Sustav osobina (Traits)

Swift Testing uvodi sustav traits — osobina koje možete dodati testovima i suite-ovima za kontrolu njihovog ponašanja. Evo najvažnijih:

.disabled() — Privremeno onemogućavanje testova

Umjesto komentiranja koda ili brisanja testova, možete ih elegantno onemogućiti s razlogom:

@Test("Integracija s vanjskim API-jem",
      .disabled("API endpoint je privremeno nedostupan — čekamo fix od backend tima"))
func integracijskiTest() async throws {
    // ...
}

Puno bolje od komentiranja koda s nekim TODO-om koji ionako nikad ne pogledate, zar ne?

.enabled(if:) — Uvjetno pokretanje

@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil))
func samoNaCIServeru() {
    // Ovaj test se pokreće samo na CI-ju
}

.timeLimit() — Vremensko ograničenje

@Test(.timeLimit(.minutes(2)))
func dohvatVelikogDataSeta() async throws {
    let podaci = try await NetworkService.shared.dohvatiSvePodatke()
    #expect(podaci.count > 0)
}

.serialized — Serijsko izvršavanje

Swift Testing po defaultu izvršava testove paralelno. Ako imate suite u kojem testovi moraju ići redom (npr. dijele bazu podataka), koristite .serialized:

@Suite("Testovi baze podataka", .serialized)
struct BazaPodatakaTests {
    @Test func unosPodataka() async throws { /* ... */ }
    @Test func citanjePodataka() async throws { /* ... */ }
    @Test func brisanjePodataka() async throws { /* ... */ }
}

.bug() — Povezivanje s bug trackerom

@Test(.bug("https://github.com/projekt/issues/42", "Fix za memory leak pri učitavanju slika"))
func ucitavanjeSlikeNeCuriMemoriju() async throws {
    // Test koji verificira fix za poznati bug
}

Oznake (Tags)

Oznake su moćan mehanizam za kategorizaciju testova koji nadilazi hijerarhiju datoteka i suite-ova. Možete definirati prilagođene oznake i koristiti ih za filtriranje pri pokretanju:

extension Tag {
    @Tag static var networking: Self
    @Tag static var persistence: Self
    @Tag static var ui: Self
    @Tag static var integration: Self
    @Tag static var smoke: Self
}

Zatim ih dodjeljujete testovima:

@Test("Dohvaćanje korisničkog profila", .tags(.networking, .integration))
func dohvatiProfil() async throws {
    let profil = try await APIClient.shared.dohvatiProfil(id: "123")
    #expect(profil.ime.isEmpty == false)
}

@Test("Spremanje postavki u UserDefaults", .tags(.persistence))
func spremanjePostavki() {
    let postavke = Postavke()
    postavke.tema = .tamna
    postavke.spremi()

    let ucitane = Postavke.ucitaj()
    #expect(ucitane.tema == .tamna)
}

@Suite("Smoke testovi", .tags(.smoke))
struct SmokeTests {
    // Svi testovi u ovom suite-u automatski dobivaju .smoke tag
    @Test func aplikacijaSePokrece() { /* ... */ }
    @Test func glavniEkranSePrikazuje() { /* ... */ }
}

U Xcode-u možete filtrirati i pokretati testove po oznakama — npr. pokrenite samo .smoke testove prije svakog pusha, a cijeli suite na CI-ju. Oznake se također mogu koristiti u Test Plans za fino podešavanje koji testovi se pokreću u kojim konfiguracijama.

Potvrđivanje asinkronih događaja

Testiranje asinkronog koda — posebno koda koji koristi callbacke, NotificationCenter ili Combine publishere — oduvijek je bilo problematično. XCTest je imao XCTestExpectation s wait(for:timeout:), ali to je bilo nezgrapno i podložno vremenskim problemima.

Swift Testing uvodi confirmation() API koji je integriran sa Swift Concurrency:

@Test("NotificationCenter šalje obavijest pri promjeni teme")
func obavijestPriPromjeniTeme() async {
    await confirmation { potvrda in
        let promatrac = NotificationCenter.default.addObserver(
            forName: .temaPromijenjena,
            object: nil,
            queue: .main
        ) { _ in
            potvrda()   // Potvrđujemo da je obavijest primljena
        }

        // Okidamo promjenu
        TemaManager.shared.promijeniTemu(.tamna)

        // Čistimo
        NotificationCenter.default.removeObserver(promatrac)
    }
}

Ono što je ključno: confirmation() mora biti pozvana točno jednom (po defaultu). Ako se pozove nula puta ili više od jednom, test pada. Ovo možete prilagoditi za scenarije gdje očekujete višestruke pozive:

@Test("Delegate prima točno 3 obavijesti o napretku")
func delegatPrimaObavijesti() async {
    await confirmation(expectedCount: 3) { potvrda in
        let downloader = FileDownloader()
        downloader.onProgress = { _ in
            potvrda()
        }
        await downloader.preuzmi(url: testniURL)
    }
}

Ako ste pratili naš članak o Swift Concurrency, primijetit ćete kako se confirmation() prirodno uklapa u async/await model — nema više ručnog upravljanja s fulfill() i waitForExpectations().

Testiranje grešaka

Testiranje da kod baca ispravne greške je jednako važno kao testiranje uspješnih scenarija. Swift Testing pruža elegantan #expect(throws:) makro:

enum ValidationError: Error, Equatable {
    case praznoPolje(String)
    case predugackoPolje(String, maksimalno: Int)
    case neisparanFormat(String)
}

@Test("Validacija baca grešku za prazno ime")
func validacijaPraznogImena() {
    #expect(throws: ValidationError.praznoPolje("ime")) {
        try Validator.validirajIme("")
    }
}

Možete i samo provjeriti tip greške bez specifične vrijednosti:

@Test("Parsiranje neispravnog JSON-a baca DecodingError")
func parsiranjeLosegJSONa() {
    #expect(throws: DecodingError.self) {
        try JSONDecoder().decode(Korisnik.self, from: Data("{ neispravan }".utf8))
    }
}

Za naprednije scenarije gdje želite inspicirati detalje greške, možete koristiti closure s validacijom:

@Test("API greška sadrži ispravan HTTP status kod")
func apiGreskaImaStatusKod() throws {
    #expect {
        try APIClient.shared.sinhroniZahtjev(endpoint: "/nepostojeci")
    } throws: { error in
        guard let apiError = error as? APIError else { return false }
        return apiError.statusKod == 404 && apiError.poruka.contains("not found")
    }
}

Novosti u Swift 6.2 i Xcode 26

Swift Testing se kontinuirano razvija. Evo ključnih novosti koje donose Swift 6.2 i Xcode 26 — značajke koje testiranje podižu na još višu razinu.

Rasponske potvrde (Ranged Confirmations)

U ranijim verzijama, confirmation() je zahtijevala točan broj poziva. Sada možete specificirati raspon:

@Test("Cache emitira između 1 i 5 eviction događaja")
func cacheEviction() async {
    await confirmation(expectedCount: 1...5) { potvrda in
        let cache = InMemoryCache(maxVelicina: 100)

        cache.onEviction = { _ in
            potvrda()
        }

        // Punimo cache preko kapaciteta
        for i in 0..<200 {
            await cache.spremi(kljuc: "item_\(i)", vrijednost: TestniPodatak(velicina: 1))
        }
    }
}

Ovo je izuzetno korisno za testove gdje točan broj događaja ovisi o implementacijskim detaljima (npr. strategija eviction-a cache-a), ali znate da mora biti u određenom rasponu.

Detekcija runtime problema

Swift Testing sada može detektirati i prijaviti runtime probleme — poput data race uvjeta, neočekivanih force unwrapa ili problema s memorijom — kao dio rezultata testa. Ovo radi u suradnji s Thread Sanitizerom i Address Sanitizerom, ali je dublje integrirano u test izvještaje. Ako se runtime problem dogodi tijekom izvršavanja testa, test pada s jasnom dijagnostikom umjesto misterioznog crasha.

Privici slika (Image Attachments) — Xcode 26.4

Xcode 26.4 donosi mogućnost prilaganja slika kao priloga testnim rezultatima. Ovo je posebno korisno za vizualno testiranje UI komponenti:

import Testing

@Test("Korisnička avatar komponenta se ispravno renderira")
func avatarRenderiranje() async throws {
    let avatar = AvatarView(inicijali: "AH", boja: .plava)
    let renderiranaSlika = avatar.renderUSliku()

    // Priložite sliku rezultatima testa za vizualnu inspekciju
    Attachment(renderiranaSlika, named: "avatar_AH_plava.png")
        .attach()
}

Ovi privici su vidljivi u Xcode Test Report navigatoru, što znači da možete vizualno pregledati izlaz — iznimno korisno za debugging UI testova bez pokretanja cijele aplikacije.

Razine ozbiljnosti (Severity Levels)

Nova mogućnost kategoriziranja važnosti pojedinih provjera. Možete označiti neke provjere kao upozorenja umjesto grešaka, što je korisno kada uvodite nove standarde koda postupno:

@Test func provjeriPerformanse() async throws {
    let vrijeme = await izmjeriVrijemeIzvrsavanja {
        await TeskiProracun.izvrsi()
    }

    // Ovo je strogi zahtjev — test pada ako nije ispunjen
    #expect(vrijeme < .seconds(10))

    // Ovo je meki zahtjev — prikazuje upozorenje, ali test i dalje prolazi
    withKnownIssue("Optimizacija performansi u tijeku") {
        #expect(vrijeme < .seconds(2))
    }
}

Migracija s XCTest-a

Dobra vijest: ne morate migrirati sve odjednom. Swift Testing i XCTest mogu koegzistirati u istom projektu bez ikakvih problema. Evo preporučene strategije:

Inkrementalna migracija

  1. Novi testovi — pišite u Swift Testingu. Nema razloga da novi kod koristi stari framework.
  2. Jednostavni postojeći testovi — migrirajte ih kad ih ionako dotičete. Zamjena XCTAssert* s #expect je uglavnom mehanička.
  3. Testovi s setUp/tearDown — prebacite na init/deinit u strukturi, ili koristite @Suite s klasom ako vam treba deinit.
  4. Testovi s expectation-ima — migrirajte na confirmation().

Što zadržati u XCTest-u

Nisu svi testovi kandidati za migraciju. Evo što ostaje u XCTest-u:

  • UI testoviXCUITest nema ekvivalent u Swift Testingu. UI testiranje i dalje zahtijeva XCTest infrastrukturu.
  • Performance testovimeasure { } blokovi su specifični za XCTest. Swift Testing zasad nema ugrađenu podršku za mjerenje performansi.
  • Testovi koji zahtijevaju specifično Objective-C ponašanje — rijetki, ali postoje.

Primjer migracije

Pogledajmo konkretan primjer. Evo XCTest koda:

// PRIJE: XCTest
class KosariceTests: XCTestCase {
    var kosarica: Kosarica!

    override func setUp() {
        super.setUp()
        kosarica = Kosarica()
    }

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

    func testDodavanjeProizvoda() {
        let proizvod = Proizvod(naziv: "Majica", cijena: 29.99)
        kosarica.dodaj(proizvod)
        XCTAssertEqual(kosarica.stavke.count, 1)
        XCTAssertEqual(kosarica.ukupnaCijena, 29.99, accuracy: 0.01)
    }

    func testPraznaKosarica() {
        XCTAssertTrue(kosarica.stavke.isEmpty)
        XCTAssertEqual(kosarica.ukupnaCijena, 0)
    }
}

A evo ekvivalenta u Swift Testingu:

// POSLIJE: Swift Testing
import Testing

@Suite("Košarica")
struct KosariceTests {
    let kosarica = Kosarica()  // Svježa instanca za svaki test

    @Test("Dodavanje proizvoda u košaricu")
    func dodavanjeProizvoda() {
        var kosarica = self.kosarica
        let proizvod = Proizvod(naziv: "Majica", cijena: 29.99)
        kosarica.dodaj(proizvod)
        #expect(kosarica.stavke.count == 1)
        #expect(kosarica.ukupnaCijena.isApproximatelyEqual(to: 29.99))
    }

    @Test("Prazna košarica nema stavki")
    func praznaKosarica() {
        #expect(kosarica.stavke.isEmpty)
        #expect(kosarica.ukupnaCijena == 0)
    }
}

Čišće, kraće i ekspresivnije. Nema setUp/tearDown ceremonije, nema ! force unwrapa na svojstvima, nema nasljeđivanja.

Praktični primjeri

Teorija je korisna, ali pogledajmo kako Swift Testing izgleda u stvarnim scenarijima — testiranje ViewModela, asinkronog koda i SwiftData modela.

Testiranje ViewModela

Ako ste čitali naš članak o Observation frameworku, znate da moderni ViewModeli koriste @Observable makro. Evo kako ih testiramo:

import Testing
@testable import MojaAplikacija

@Suite("TodoListViewModel testovi")
struct TodoListViewModelTests {
    let viewModel: TodoListViewModel

    init() {
        // Koristimo mock repozitorij za izolaciju od prave baze
        let mockRepo = MockTodoRepository()
        viewModel = TodoListViewModel(repository: mockRepo)
    }

    @Test("Inicijalno stanje je prazna lista")
    func inicijalnoStanje() {
        #expect(viewModel.todos.isEmpty)
        #expect(viewModel.isLoading == false)
        #expect(viewModel.errorMessage == nil)
    }

    @Test("Dohvaćanje todo-ova popunjava listu")
    func dohvacanjeTodova() async throws {
        await viewModel.dohvatiTodove()

        #expect(viewModel.todos.count == 3)
        #expect(viewModel.isLoading == false)
        #expect(viewModel.errorMessage == nil)
    }

    @Test("Dodavanje todo-a ga stavlja na vrh liste")
    func dodavanjeToda() async throws {
        await viewModel.dohvatiTodove()
        let pocetniBroj = viewModel.todos.count

        await viewModel.dodajTodo(naslov: "Novi zadatak", prioritet: .visoki)

        #expect(viewModel.todos.count == pocetniBroj + 1)

        let prvi = try #require(viewModel.todos.first)
        #expect(prvi.naslov == "Novi zadatak")
        #expect(prvi.prioritet == .visoki)
        #expect(prvi.zavrsen == false)
    }

    @Test("Brisanje todo-a ga uklanja iz liste")
    func brisanjeToda() async throws {
        await viewModel.dohvatiTodove()
        let todoZaBrisanje = try #require(viewModel.todos.first)

        await viewModel.obrisiTodo(todoZaBrisanje)

        #expect(!viewModel.todos.contains(where: { $0.id == todoZaBrisanje.id }))
    }

    @Test("Mrežna greška postavlja error message",
          .tags(.networking))
    func mreznaGreska() async {
        let failingRepo = MockTodoRepository(simulirajGresku: true)
        let vm = TodoListViewModel(repository: failingRepo)

        await vm.dohvatiTodove()

        #expect(vm.todos.isEmpty)
        #expect(vm.errorMessage != nil)
        #expect(vm.errorMessage?.contains("Greška") == true)
    }
}

Primijetite kako koristimo dependency injection za ubacivanje mock repozitorija. Svaki test dobiva svježu instancu ViewModela jer koristimo strukturu — nema dijeljenog stanja, nema nepredvidivih interakcija između testova.

Testiranje asinkronog koda sa Swift Concurrency

Swift Testing nativno podržava async/await, što testiranje concurrent koda čini prirodnim. Evo primjera testiranja servisa koji koristi TaskGroup:

@Suite("ImageLoader testovi")
struct ImageLoaderTests {
    let loader = ImageLoader(cache: MockImageCache(), session: MockURLSession())

    @Test("Paralelno učitavanje više slika", .timeLimit(.minutes(1)))
    func paralelnoUcitavanje() async throws {
        let urlovi = [
            URL(string: "https://example.com/slika1.jpg")!,
            URL(string: "https://example.com/slika2.jpg")!,
            URL(string: "https://example.com/slika3.jpg")!,
        ]

        let slike = try await loader.ucitajSve(urlovi: urlovi)

        #expect(slike.count == 3)
        for slika in slike {
            #expect(slika.size.width > 0)
            #expect(slika.size.height > 0)
        }
    }

    @Test("Otkazivanje učitavanja oslobađa resurse")
    func otkazivanje() async {
        let zadatak = Task {
            try await loader.ucitajVelikuSliku(url: testniURL)
        }

        // Dajemo vremena da učitavanje počne
        try? await Task.sleep(for: .milliseconds(100))
        zadatak.cancel()

        let rezultat = await zadatak.result
        #expect(throws: CancellationError.self) {
            try rezultat.get()
        }
    }

    @Test("Cache sprema učitane slike",
          arguments: ["slika1.jpg", "slika2.png", "slika3.webp"])
    func cachiranje(imeDatoteke: String) async throws {
        let url = URL(string: "https://example.com/\(imeDatoteke)")!

        // Prvo učitavanje — ide na "mrežu"
        let prvaSlika = try await loader.ucitaj(url: url)

        // Drugo učitavanje — trebalo bi doći iz cache-a
        let drugaSlika = try await loader.ucitaj(url: url)

        #expect(prvaSlika == drugaSlika)
        #expect(loader.cache.brojPogodaka(za: url) == 1)
    }
}

Testiranje SwiftData modela

Ako pratite naše članke o SwiftData, znate koliko je važno testirati perzistenciju. Evo kako to izgleda s Swift Testingom:

import Testing
import SwiftData
@testable import MojaAplikacija

@Suite("Projekt model testovi", .serialized)
struct ProjektModelTests {
    let container: ModelContainer
    let context: ModelContext

    init() throws {
        let konfiguracija = ModelConfiguration(isStoredInMemoryOnly: true)
        container = try ModelContainer(
            for: Projekt.self, Zadatak.self,
            configurations: konfiguracija
        )
        context = ModelContext(container)
    }

    @Test("Kreiranje projekta s zadacima")
    func kreiranjeProjekta() throws {
        let projekt = Projekt(naziv: "iOS Aplikacija", opis: "Nova mobilna aplikacija")
        projekt.zadaci = [
            Zadatak(naslov: "Dizajn", status: .todo),
            Zadatak(naslov: "Implementacija", status: .todo),
            Zadatak(naslov: "Testiranje", status: .todo)
        ]

        context.insert(projekt)
        try context.save()

        let descriptor = FetchDescriptor<Projekt>(
            predicate: #Predicate { $0.naziv == "iOS Aplikacija" }
        )
        let rezultati = try context.fetch(descriptor)

        #expect(rezultati.count == 1)
        let dohvacenProjekt = try #require(rezultati.first)
        #expect(dohvacenProjekt.zadaci.count == 3)
        #expect(dohvacenProjekt.opis == "Nova mobilna aplikacija")
    }

    @Test("Brisanje projekta briše i zadatke (cascade)")
    func cascadeBrisanje() throws {
        let projekt = Projekt(naziv: "Test projekt", opis: "Za brisanje")
        projekt.zadaci = [
            Zadatak(naslov: "Zadatak 1", status: .todo),
            Zadatak(naslov: "Zadatak 2", status: .done)
        ]
        context.insert(projekt)
        try context.save()

        context.delete(projekt)
        try context.save()

        let projekti = try context.fetch(FetchDescriptor<Projekt>())
        let zadaci = try context.fetch(FetchDescriptor<Zadatak>())

        #expect(projekti.isEmpty)
        #expect(zadaci.isEmpty, "Cascade delete bi trebao obrisati i zadatke")
    }

    @Test("Filtriranje projekata po statusu",
          arguments: [Status.aktivan, Status.zavrsen, Status.pauziran])
    func filtriranjePoStatusu(status: Status) throws {
        // Kreiramo projekte s različitim statusima
        for s in Status.allCases {
            let projekt = Projekt(naziv: "Projekt \(s)", opis: "Opis")
            projekt.status = s
            context.insert(projekt)
        }
        try context.save()

        let descriptor = FetchDescriptor<Projekt>(
            predicate: #Predicate { $0.status == status }
        )
        let rezultati = try context.fetch(descriptor)

        #expect(rezultati.allSatisfy { $0.status == status })
        #expect(rezultati.isEmpty == false)
    }
}

Primijetite korištenje .serialized traita na suite-u — jer svi testovi dijele in-memory ModelContainer, želimo osigurati da se ne izvršavaju paralelno. Također, korištenje isStoredInMemoryOnly: true osigurava da testovi ne ostavljaju tragove na disku i da su brzi.

Zaključak

Swift Testing nije samo novi framework — to je fundamentalna promjena u načinu na koji pristupamo testiranju u Swift ekosustavu. Rezimirajmo ključne prednosti:

  • Deklarativna sintaksa@Test i @Suite makroi čine testove čitljivijima i lakšima za pisanje
  • Ekspresivne asertacije#expect i #require zamjenjuju desetke XCTAssert* funkcija s boljom dijagnostikom
  • Parametrizirani testovi — eliminiraju dupliciranje koda i pružaju granularnu vidljivost u rezultate
  • Native async podrška — testiranje concurrent koda je konačno prirodno
  • Traits sustav — fleksibilna kontrola ponašanja testova bez nasljeđivanja i overridea
  • Oznake — organizacija i filtriranje testova neovisno o hijerarhiji datoteka
  • Vrijednosni tipovi — strukture kao test suite-ovi eliminiraju probleme s dijeljenim stanjem

Preporuke za best practices

  1. Koristite strukture za suite-ove — osim ako vam eksplicitno treba deinit za čišćenje resursa
  2. Imenujte testove s display name-om — čitljiviji izvještaji pomažu cijelom timu
  3. Koristite parametrizirane testove umjesto petlji — bolja dijagnostika, paralelno izvršavanje
  4. Dodajte oznake od početka — organizacija se isplati kada suite naraste
  5. Preferirajte #require za unwrapping — jasnije je od #expect s force unwrapom
  6. Postavite vremenska ograničenja — spriječite da spori testovi blokiraju CI pipeline
  7. Migrirajte inkrementalno — ne pokušavajte prebaciti sve odjednom, krenite s novim testovima
  8. Koristite .serialized samo kada je nužno — paralelno izvršavanje je velika prednost za brzinu

Swift Testing se savršeno uklapa uz druge moderne Swift značajke koje smo pokrivali na Swift Crafted — od Swift Concurrency za asinhrono programiranje, preko Observation frameworka za reaktivno upravljanje stanjem, do SwiftData za perzistenciju. Zajedno, ovi alati čine Swift ekosustav zrelijim i produktivnijim nego ikada.

I za kraj — najvažniji savjet: počnite pisati testove. Bez obzira koristite li Swift Testing ili XCTest, testirani kod je bolji kod. Ali ako već počinjete iznova ili želite osvježiti pristup, Swift Testing je definitivno pravi put naprijed. Framework je dizajniran da pisanje testova učini ugodnim — i to se stvarno osjeti od prvog @Test makroa koji napišete. Probajte, pa nam javite kako vam ide.

O Autoru Editorial Team

Our team of expert writers and editors.