Swift Testing hallintaan: Kattava opas moderniin testaukseen iOS-kehityksessä

Opi käyttämään Applen Swift Testing -kehystä: @Test- ja @Suite-makrot, #expect- ja #require-väittämät, parametrisoidut testit, ominaisuusjärjestelmä ja TestScoping. Käytännön esimerkkejä ja XCTest-migraatio-ohjeet.

Johdanto: Testauksen uusi aikakausi on täällä

Apple pudotti WWDC 2024 -konferenssissa melkoisen pommin: Swift Testing -kehyksen, joka muuttaa tapaamme kirjoittaa testejä Swiftillä aivan perusteellisesti. Jos olet koskaan turhautunut XCTestin kanssa — ja rehellisesti sanottuna, kuka ei ole — niin tämä kehys on kuin raikas tuulahdus. Se on suunniteltu alusta alkaen hyödyntämään Swiftin moderneja ominaisuuksia: makroja, rinnakkaisuutta ja tyyppijärjestelmää. Parasta? Se tulee sisäänrakennettuna Xcode 16:een ja Swift 6 -työkaluketjuun, joten erillisiä asennuksia ei tarvita.

Tässä oppaassa käymme läpi Swift Testingin keskeiset ominaisuudet käytännön esimerkein. Opit käyttämään @Test- ja @Suite-makroja, hallitsemaan väittämät #expect- ja #require-makroilla, hyödyntämään parametrisoituja testejä ja paljon muuta.

Eli, sukellettaan suoraan asiaan.

Miksi Swift Testing korvaa XCTestin?

XCTest on palvellut iOS-kehittäjiä vuosien ajan, mutta sen juuret ovat syvällä Objective-C-maailmassa. Käytännössä tämä tarkoittaa, että joudut periyttämään testiluokat XCTestCase-luokasta, valitsemaan yli 40 erilaisesta XCTAssert-variantista oikean, ja rinnakkaistaminen onnistuu vain moniprosessimallin kautta. SwiftUI:n ja Swift Concurrencyn aikakaudella tämä tuntuu — no, vanhentuneelta.

Swift Testing ratkaisee nämä kipupisteet:

  • Makropohjainen syntaksi — testit määritellään @Test-attribuutilla missä tahansa tyypissä
  • Kaksi väittämämakroa#expect ja #require korvaavat kaikki XCTAssert-variantit (kyllä, kaikki!)
  • Natiivi rinnakkaisuustuki — testit ajetaan rinnakkain oletusarvoisesti Swift Concurrencyn avulla
  • Parametrisoidut testit — sama testi voidaan ajaa useilla syötteillä ilman toistoa
  • Ominaisuusjärjestelmä (traits) — testien mukauttaminen deklaratiivisesti
  • Rakenteet testityyppeinä — luokkien sijaan voidaan käyttää structeja ja actoreita

Swift Testing vs. XCTest — keskeiset erot

Tässä on tiivistetty vertailu, jotta näet erot yhdellä silmäyksellä:

  • Kielituki: XCTest tukee sekä Objective-C:tä että Swiftiä — Swift Testing ainoastaan Swiftiä
  • Rinnakkaisuus: XCTest käyttää moniprosessimallia — Swift Testing prosessin sisäistä rinnakkaisuutta
  • Väittämät: XCTest tarjoaa 40+ varianttia — Swift Testing kaksi makroa
  • Testien määrittely: XCTest vaatii XCTestCase-aliluokan — Swift Testing käyttää @Test-makroa
  • Alustus/purkaminen: XCTest käyttää setUp()/tearDown() — Swift Testing tavallista init/deinit
  • UI-testaus: XCTest tukee — Swift Testing ei vielä
  • Suorituskykytestit: XCTest tukee — Swift Testing ei vielä

Perusteet: @Test ja @Suite

Swift Testingin sydän on @Test-makro. Se merkitsee funktion testifunktioksi, ja toisin kuin XCTestissä, funktion nimen ei tarvitse alkaa sanalla "test" eikä sen tarvitse sijaita tietyssä luokassa. Tämä yksinkertainen muutos tekee testeistä huomattavasti luettavampia.

import Testing

@Test func yhteenlaskuToimiiOikein() {
    let tulos = laske(2, plus: 3)
    #expect(tulos == 5)
}

Testejä ryhmitellään @Suite-makrolla merkittyihin tyyppeihin. Apple suosittelee structien käyttöä luokkien sijaan, koska ne estävät tilan jakamisen testien välillä — mikä on itse asiassa yksi yleisimmistä testausbugeista:

import Testing

@Suite("Laskutoiminnot")
struct LaskuTestit {
    let laskin = Laskin()
    
    @Test("Yhteenlasku positiivisilla luvuilla")
    func yhteenlasku() {
        #expect(laskin.laske(2, plus: 3) == 5)
    }
    
    @Test("Vähennyslasku negatiivisella tuloksella")
    func vahennyslasku() {
        #expect(laskin.laske(2, miinus: 5) == -3)
    }
}

Huomaa miten @Test- ja @Suite-makroille voidaan antaa kuvaava merkkijono. Tämä näkyy suoraan Xcoden testinavigaattorissa ja helpottaa testien tunnistamista ihan huomattavasti.

Alustaminen ja purkaminen

Muistatko XCTestin setUp()- ja tearDown()-metodit? Swift Testingissä ne korvataan Swiftin natiiveilla init- ja deinit-mekanismeilla:

@Suite("Tietokantayhteys")
struct TietokantaTestit {
    let tietokanta: TestiTietokanta
    
    init() async throws {
        tietokanta = try await TestiTietokanta.luo()
    }
    
    @Test func kayttajanLisaaminen() async throws {
        try await tietokanta.lisaaKayttaja(nimi: "Matti")
        let kayttajat = try await tietokanta.haeKaikki()
        #expect(kayttajat.count == 1)
    }
}

Jokaiselle testille luodaan uusi instanssi testityypistä, joten tila ei koskaan vuoda testien välillä. Tämä on merkittävä parannus verrattuna XCTestin jaettuun tilaan, jossa on helppo vahingossa sotkea testejä keskenään.

Väittämät: #expect ja #require

Tässä mennään mielestäni asian ytimeen. Swift Testing tiivistää väittämät kahteen makroon — ja ne korvaavat kaikki XCTestin XCTAssert-variantit. Kaksi makroa neljänkymmenen sijaan. Ei hassumpaa.

#expect — pehmeä väittämä

#expect on niin sanottu pehmeä väittämä: testin suoritus jatkuu vaikka se epäonnistuisi. Se hyväksyy minkä tahansa Swift-lausekkeen, joka palauttaa Bool-arvon:

@Test func merkkijonoOperaatiot() {
    let nimi = "Helsinki"
    #expect(nimi.count == 8)
    #expect(nimi.hasPrefix("Hel"))
    #expect(nimi.lowercased() == "helsinki")
}

Kun #expect epäonnistuu, se näyttää automaattisesti sekä odotetun että todellisen arvon. Jos esimerkiksi nimi.count olisi 7, näkisit virheilmoituksen: Expectation failed: (nimi.count → 8) == 7. Tämä on iso parannus XCTestin epämääräisiin virheilmoituksiin verrattuna.

Virheitä heittävien funktioiden testaaminen

#expect-makrolla voidaan myös varmistaa, että funktio heittää odotetun virheen:

enum ValidointiVirhe: Error {
    case tyhjaKentta
    case liianLyhyt
}

@Test func validointiHeittaaVirheen() {
    #expect(throws: ValidointiVirhe.tyhjaKentta) {
        try validoiSyote("")
    }
}

@Test func validointiHeittaaTietynVirheen() {
    #expect(throws: ValidointiVirhe.self) {
        try validoiSyote("ab")
    }
}

#require — kova väittämä

#require on kovempi versio: testin suoritus pysähtyy välittömästi epäonnistumisen yhteydessä. Se on erityisen kätevä optionaalisten arvojen purkamisessa — ja korvaa XCTestin XCTUnwrap-funktion siististi:

@Test func kayttajanHakeminen() throws {
    let kayttaja = try #require(haeKayttaja(id: 42))
    // Jos kayttaja on nil, testi pysähtyy tähän
    #expect(kayttaja.nimi == "Liisa")
    #expect(kayttaja.ika > 0)
}

#require vaatii aina try-avainsanan, koska se heittää virheen pysäyttääkseen testin. Tämä pätee riippumatta siitä, heittääkö sisällä oleva lauseke itse virhettä vai ei.

Milloin käyttää kumpaakin?

Peukalosääntö on onneksi yksinkertainen:

  • Käytä #require kun myöhemmät väittämät riippuvat tuloksesta (esim. optionaalin purkaminen)
  • Käytä #expect kun haluat nähdä kaikki epäonnistumiset kerralla
@Test func tilaustenKasittely() throws {
    let tilaus = try #require(haeTilaus(id: "ABC-123"))
    // require — pysähtyy jos tilaus on nil
    
    #expect(tilaus.tuotteet.count == 3)
    // expect — jatkuu vaikka epäonnistuu
    
    #expect(tilaus.kokonaissumma > 0)
    // expect — näemme molemmat epäonnistumiset
    
    #expect(tilaus.tila == .vahvistettu)
}

Mukautetut virheilmoitukset

Molemmat makrot tukevat valinnaista virheilmoitusta toisena parametrina, josta on hyötyä erityisesti monimutkaisemmissa testeissä:

@Test func ikarajoitus() {
    let ika = 15
    #expect(ika >= 18, "Käyttäjän iän tulisi olla vähintään 18, saatiin \(ika)")
}

Parametrisoidut testit

Tämä on henkilökohtaisesti yksi suosikeistani Swift Testingissä. Sen sijaan, että kirjoittaisit erillisen testin jokaiselle syötteelle (tai turvautuisit rumiin silmukoihin), voit määritellä syötteet suoraan @Test-makrossa:

@Test("Valuuttamuunnos", arguments: [
    (euro: 1.0, dollari: 1.08),
    (euro: 10.0, dollari: 10.80),
    (euro: 100.0, dollari: 108.00),
    (euro: 0.0, dollari: 0.0)
])
func valuuttamuunnos(euro: Double, dollari: Double) {
    let muunnin = ValuuttaMuunnin(kurssi: 1.08)
    let tulos = muunnin.eurostaaDollareihin(euro)
    #expect(abs(tulos - dollari) < 0.01)
}

Xcode luo automaattisesti erillisen testirivin jokaiselle parametrille testinavigaattoriin. Käytännössä tämä tarkoittaa, että voit ajaa yksittäisen epäonnistuneen tapauksen uudelleen debugataksesi ongelmaa. XCTestin silmukoissa tämä ei ollut mahdollista, ja se aiheutti todella paljon turhaa päänvaivaa.

Enum-arvojen testaaminen

Parametrisoidut testit sopivat erinomaisesti enum-arvojen läpikäymiseen:

enum Viikonpaiva: String, CaseIterable {
    case maanantai, tiistai, keskiviikko, torstai, perjantai, lauantai, sunnuntai
}

@Test("Viikonpäivien lokalisointi", arguments: Viikonpaiva.allCases)
func viikonpaivaLokalisointi(paiva: Viikonpaiva) {
    let lokalisoitu = paiva.lokalisoituNimi(kieli: "fi")
    #expect(!lokalisoitu.isEmpty)
    #expect(lokalisoitu.first?.isUppercase == true)
}

Kahden kokoelman yhdistelmät

Swift Testing tukee myös kahden argumenttikokoelman yhdistelmiä zip-tyylisesti:

@Test("HTTP-vastauksen käsittely", arguments:
    zip([200, 201, 204, 301, 404, 500],
        [true, true, true, false, false, false])
)
func httpVastaus(statusKoodi: Int, onnistui: Bool) {
    let vastaus = HTTPVastaus(statusKoodi: statusKoodi)
    #expect(vastaus.onnistui == onnistui)
}

Ominaisuusjärjestelmä (Traits)

Ominaisuudet ovat Swift Testingin tapa mukauttaa ja annotoida testejä deklaratiivisesti. Ajattele niitä testien "metatietona", joka vaikuttaa suoritukseen. Ne välitetään @Test- tai @Suite-makron parametreina.

Sisäänrakennetut ominaisuudet

Swift Testing tarjoaa useita käteviä sisäänrakennettuja ominaisuuksia:

// Testin poistaminen käytöstä
@Test(.disabled("Ominaisuus on rikki, korjataan versiossa 2.1"))
func rikkinainenOminaisuus() { }

// Testin merkitseminen tunnettuun bugiin
@Test(.bug("IOS-1234", "Kaatuu käynnistyksessä"))
func bugRaportti() { }

// Ehdollinen suoritus
@Test(.enabled(if: Palvelin.onVerkossa))
func verkkotesti() async { }

// Aikaraja
@Test(.timeLimit(.minutes(2)))
func pitkakestoinenTesti() async { }

// Tagit testien kategorisointiin
@Test(.tags(.verkko, .integraatio))
func apiKutsu() async { }

Tagien käyttö

Tagit ovat mielestäni yksi aliarvostetuimmista ominaisuuksista. Ne auttavat testien organisoinnissa ja suodattamisessa aivan valtavasti. Omat tagit määritellään laajennuksella:

extension Tag {
    @Tag static var verkko: Self
    @Tag static var tietokanta: Self
    @Tag static var integraatio: Self
    @Tag static var suorituskyky: Self
}

@Suite(.tags(.tietokanta))
struct TietokantaTestit {
    @Test(.tags(.integraatio))
    func monimutkaineKysely() async throws {
        // Tällä testillä on sekä .tietokanta että .integraatio tagit
    }
}

Xcodessa voit suodattaa ja ajaa testejä tagien perusteella, mikä on todella kätevää kun haluat ajaa vaikka pelkästään verkko-testit erikseen.

.serialized — sarjallistettu suoritus

Koska Swift Testing ajaa testit rinnakkain oletusarvoisesti, joskus tarvitaan sarjallistettua suoritusta. Tämä on tyypillistä tilanteissa, joissa testit jakavat jonkin resurssin. .serialized-ominaisuus hoitaa asian:

@Suite("Jaettu tila -testit", .serialized)
struct JaettuTilaTestit {
    @Test func ekaVaihe() async {
        await JaettuPalvelu.shared.alusta()
    }
    
    @Test func tokaVaihe() async {
        let tila = await JaettuPalvelu.shared.tila
        #expect(tila == .valmis)
    }
}

Parametrisoiduissa testeissä .serialized estää saman testin rinnakkaisen suorituksen eri parametreilla:

@Test("Pistemäärä on aina välillä 0...100", .serialized, arguments: [0, 50, 100, 200, -1])
func pisteidenLisays(maara: Int) async {
    var pelaaja = Pelaaja()
    await pelaaja.lisaaPisteita(maara)
    #expect(pelaaja.pisteet >= 0 && pelaaja.pisteet <= 100)
}

Asynkroninen testaus ja confirmation

Swift Testing integroituu saumattomasti Swift Concurrencyn kanssa. Testifunktiot voivat olla async ja throws, ja ne ajetaan rinnakkain oletusarvoisesti. Tämä on iso etu verrattuna XCTestiin, jossa asynkroninen testaus vaati enemmän boilerplate-koodia.

Async-testien kirjoittaminen

@Test func verkkopalvelunKutsu() async throws {
    let palvelu = APIClient()
    let kayttajat = try await palvelu.haeKayttajat()
    #expect(!kayttajat.isEmpty)
    #expect(kayttajat.count <= 100)
}

MainActor-eristys

Jos testi tarvitsee suoritusta pääsäikeessä (esimerkiksi UI-komponenttien kanssa työskennellessä), merkitse se @MainActor-attribuutilla:

@Test @MainActor
func uiKomponentinPaivitys() {
    let viewModel = ProfiiliViewModel()
    viewModel.paivitaNimi("Eero")
    #expect(viewModel.naytettavaNimi == "Eero")
}

confirmation — tapahtumien vahvistaminen

confirmation-funktio on tehokas työkalu callback-pohjaisten tapahtumien testaamiseen. Se varmistaa, että tietty tapahtuma tapahtuu odotetun määrän kertoja. Tämä korvaa XCTestin XCTestExpectation-mallin paljon elegantimmin:

@Test func ilmoituksenLahettaminen() async {
    let ilmoitusKeskus = IlmoitusKeskus()
    
    await confirmation(expectedCount: 1) { ilmoitusSaapui in
        ilmoitusKeskus.kuuntele(.uusiViesti) { _ in
            ilmoitusSaapui()
        }
        await ilmoitusKeskus.laheta(.uusiViesti)
    }
}

Voit myös varmistaa, ettei tapahtumaa tapahdu lainkaan asettamalla expectedCount: 0:

@Test func eiTurhiaIlmoituksia() async {
    let ilmoitusKeskus = IlmoitusKeskus()
    
    await confirmation(expectedCount: 0) { ilmoitusSaapui in
        ilmoitusKeskus.kuuntele(.uusiViesti) { _ in
            ilmoitusSaapui()
        }
        // Ei lähetetä mitään — testin pitäisi onnistua
    }
}

Swift 6.2: Ranged Confirmations

Swift 6.2 tuo mukanaan kiinnostavan uutuuden: ranged confirmations. Sen avulla voit määritellä tapahtumamäärän vaihteluvälillä, mikä on hyödyllistä tilanteissa joissa tarkka lukumäärä voi vaihdella:

@Test func tapahtumavirta() async {
    await confirmation(expectedCount: 2...5) { tapahtuma in
        let virta = TapahtumaVirta()
        virta.kuuntele { _ in
            tapahtuma()
        }
        await virta.kaynnista()
    }
}

Mukautetut ominaisuudet ja TestScoping (Swift 6.1+)

Swift 6.1 toi mukanaan jotain todella hienoa: mahdollisuuden luoda mukautettuja ominaisuuksia, jotka suorittavat logiikkaa ennen ja jälkeen testien. Käytännössä tämä korvaa tarpeen globaalille tilalle setup- ja teardown-operaatioissa.

TestScoping-protokolla

TestScoping-protokolla mahdollistaa oman kontekstin tarjoamisen testille. Tämä on erityisen hyödyllistä kun useissa testeissä tarvitaan samaa alustuslogiikkaa:

struct MockiAPITunnisteet: TestTrait, TestScoping {
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: @Sendable () async throws -> Void
    ) async throws {
        let mockTunnisteet = APITunnisteet(apiAvain: "testi-avain-123")
        try await APITunnisteet.$nykyinen.withValue(mockTunnisteet) {
            try await function()
        }
    }
}

extension TestTrait where Self == MockiAPITunnisteet {
    static var mockiAPItunnisteet: Self { MockiAPITunnisteet() }
}

Nyt voit käyttää tätä ominaisuutta missä tahansa testissä tai kokonaisessa suitessa:

@Test(.mockiAPItunnisteet)
func apiKutsuMockiTunnisteilla() async throws {
    let vastaus = try await apiClient.haeData()
    #expect(vastaus.tila == .ok)
}

@Suite(.mockiAPItunnisteet)
struct KokoAPITestiSarja {
    @Test func haeKayttajat() async throws { }
    @Test func haeTuotteet() async throws { }
}

Käytännön esimerkki: Testitietokannan hallinta

TestScoping loistaa erityisesti testitietokannan hallinnassa. Tässä on esimerkki, jota olen itse käyttänyt vastaavaan tarkoitukseen:

struct TestiTietokantaTrait: TestTrait, TestScoping {
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: @Sendable () async throws -> Void
    ) async throws {
        let db = try await TestiTietokanta.luoValiaikainen()
        try await TestiTietokanta.$nykyinen.withValue(db) {
            try await function()
        }
        // Tietokanta siivotaan automaattisesti scopesta poistuttaessa
    }
}

extension TestTrait where Self == TestiTietokantaTrait {
    static var testiTietokanta: Self { TestiTietokantaTrait() }
}

@Suite(.testiTietokanta)
struct KayttajaRepositorioTestit {
    @Test func lisaaJaHaeKayttaja() async throws {
        let repo = KayttajaRepositorio(db: TestiTietokanta.nykyinen)
        try await repo.lisaa(Kayttaja(nimi: "Anna"))
        let haettu = try await repo.hae(nimella: "Anna")
        let kayttaja = try #require(haettu)
        #expect(kayttaja.nimi == "Anna")
    }
}

Testihierarkiat ja organisointi

Swift Testing tukee sisäkkäisiä suite-rakenteita, joiden avulla testit voidaan järjestää loogisiin hierarkioihin. Tämä on erityisen hyödyllistä isommissa projekteissa, joissa testien määrä kasvaa nopeasti:

@Suite("Verkkokauppa")
struct VerkkokauppaTestit {
    
    @Suite("Ostoskori")
    struct OstoskoriTestit {
        @Test func tuotteenLisaaminen() {
            var kori = Ostoskori()
            kori.lisaa(Tuote(nimi: "Swift-kirja", hinta: 29.99))
            #expect(kori.tuotteet.count == 1)
        }
        
        @Test func kokonaissummanLaskenta() {
            var kori = Ostoskori()
            kori.lisaa(Tuote(nimi: "Swift-kirja", hinta: 29.99))
            kori.lisaa(Tuote(nimi: "Xcode-opas", hinta: 19.99))
            #expect(kori.kokonaissumma == 49.98)
        }
    }
    
    @Suite("Maksuprosessi")
    struct MaksuprosessiTestit {
        @Test func onnistunutMaksu() async throws {
            let maksu = try await Maksupalvelu.suorita(summa: 49.98)
            #expect(maksu.tila == .onnistunut)
        }
    }
}

Tämä luo Xcoden testinavigaattoriin selkeän puurakenteen. Kun testejä on satoja, tämä organisointitapa on kultaakin kalliimpi.

Siirtyminen XCTestistä Swift Testingiin

Hyvä uutinen: XCTest- ja Swift Testing -testit voivat elää rinnakkain samassa testikohteessa, jopa samassa tiedostossa. Tämä tarkoittaa, ettei sinun tarvitse tehdä massiivista migraatiota kerralla.

Vaiheittainen migraatiostrategia

Apple suosittelee seuraavaa lähestymistapaa (ja olen itse samaa mieltä):

  1. Kirjoita uudet testit Swift Testingillä — kaikki uudet ominaisuudet testataan uudella kehyksellä
  2. Migroi vanhat testit tilaisuuksien mukaan — kun korjaat bugia tai muutat vanhaa testiä, migroi se samalla
  3. Älä yritä massamigraatiota — se on riskialtista ja vie paljon aikaa ilman selvää hyötyä

Konkreettinen migraatioesimerkki

Katsotaan tyypillinen XCTest-testi ja sen Swift Testing -vastine vierekkäin:

XCTest (ennen):

import XCTest
@testable import MinunSovellus

final class LaskinTestit: XCTestCase {
    var laskin: Laskin!
    
    override func setUp() {
        super.setUp()
        laskin = Laskin()
    }
    
    override func tearDown() {
        laskin = nil
        super.tearDown()
    }
    
    func testYhteenlasku() {
        XCTAssertEqual(laskin.laske(2, plus: 3), 5)
    }
    
    func testNollallaJako() {
        XCTAssertThrowsError(try laskin.jaa(10, pisteella: 0)) { error in
            XCTAssertTrue(error is LaskinVirhe)
        }
    }
    
    func testOptionalTulos() throws {
        let tulos = try XCTUnwrap(laskin.valinnainen(42))
        XCTAssertGreaterThan(tulos, 0)
    }
}

Swift Testing (jälkeen):

import Testing
@testable import MinunSovellus

@Suite("Laskin")
struct LaskinTestit {
    let laskin = Laskin()
    
    @Test("Yhteenlasku positiivisilla luvuilla")
    func yhteenlasku() {
        #expect(laskin.laske(2, plus: 3) == 5)
    }
    
    @Test("Nollalla jako heittää virheen")
    func nollallaJako() {
        #expect(throws: LaskinVirhe.self) {
            try laskin.jaa(10, pisteella: 0)
        }
    }
    
    @Test("Valinnainen tulos puretaan oikein")
    func optionalTulos() throws {
        let tulos = try #require(laskin.valinnainen(42))
        #expect(tulos > 0)
    }
}

Huomaatko kuinka paljon siistimpi jälkimmäinen versio on? Ei perintää, ei setUp/tearDown-tanssia, ei force-unwrappeja.

Migraation keskeiset säännöt

  • Älä sekoita väittämiä — älä kutsu XCTAssert-funktioita Swift Testing -testistä tai #expect-makroa XCTest-testistä
  • Korvaa setUp/tearDown Swiftin init/deinit-mekanismilla
  • Korvaa XCTUnwrap käyttämällä try #require-makroa
  • Huomioi rinnakkaisuus — testit ajetaan nyt rinnakkain, joten jaettu tila voi aiheuttaa yllätyksiä. Käytä .serialized-ominaisuutta väliaikaisratkaisuna
  • Pidä UI- ja suorituskykytestit XCTestissä — Swift Testing ei tue näitä vielä

Xcode 26:n uudet testausominaisuudet

Xcode 26 tuo mukanaan muutaman kivan lisäyksen testaustyönkulkuun.

Runtime Issue Detection

Uusi Runtime Issue Detection -ominaisuus tunnistaa ajonaikaisia ongelmia testien aikana automaattisesti. Oletusarvoisesti ongelmat raportoidaan varoituksina — eivät virheinä — joten olemassa olevat testit eivät rikkoudu pelkästä Xcode-päivityksestä:

// Runtime-ongelmat näkyvät varoituksina testiraportissa
// Esimerkiksi: pääsäikeen ulkopuolelta tehty UI-päivitys
@Test func dataLataus() async throws {
    let data = try await lataaData()
    // Jos lataaData() päivittää UI:ta taustasäikeestä,
    // Xcode 26 varoittaa tästä automaattisesti
    #expect(!data.isEmpty)
}

Tekoälypohjainen testiavustaja

Xcode 26:n integroitu tekoälyavustaja osaa auttaa testien kirjoittamisessa, testikattavuuden tunnistamisessa ja virheiden analysoinnissa. Yhdistettynä Swift Testingin selkeään syntaksiin tämä tekee testauksesta entistä nopeampaa.

Parhaat käytännöt

Tässä on kokoelma käytäntöjä, jotka olen havainnut toimiviksi Swift Testingin kanssa.

1. Suosi structeja testisuiteina

Structit ovat oletusvalinta. Ne estävät tilan jakamisen testien välillä, mikä eliminoi kokonaisen kategorian bugeja. Käytä luokkaa vain jos tarvitset deinit-mekanismia resurssien vapauttamiseen.

2. Nimeä testit kuvaavasti

Hyödynnä @Test-makron merkkijonoparametria:

// Huono — ei kerro mitä testataan
@Test func testi1() { }

// Hyvä — kertoo tarkalleen mitä testataan
@Test("Ostoskoriin lisääminen päivittää kokonaissumman")
func ostoskorinKokonaissumma() { }

3. Hyödynnä parametrisoituja testejä ahkerasti

Aina kun huomaat kirjoittavasi useita samankaltaisia testejä eri arvoilla, se on merkki siitä, että parametrisoitu testi olisi parempi ratkaisu.

4. Käytä tageja johdonmukaisesti

Määrittele projektin laajuiset tagit ja käytä niitä järjestelmällisesti. Tämä mahdollistaa tiettyjen testikategorioiden ajamisen CI/CD-putkissa:

extension Tag {
    @Tag static var nopea: Self      // < 1 sekunti
    @Tag static var hidas: Self      // > 1 sekunti
    @Tag static var verkko: Self     // vaatii verkkoyhteyden
    @Tag static var integraatio: Self // integraatiotestit
}

5. Vältä testien välistä riippuvuutta

Jokaisen testin tulee olla itsenäinen. Jos testit riippuvat toisistaan, käytä TestScoping-ominaisuuksia yhteisen kontekstin tarjoamiseen — älä luota suoritusjärjestykseen.

6. Aseta aikarajat pitkäkestoisille testeille

.timeLimit-ominaisuus estää testejä jumiutumasta ja hidastamasta koko testisarjaa:

@Test(.timeLimit(.seconds(30)))
func verkkopalvelunVastaus() async throws {
    let vastaus = try await apiClient.haeData()
    #expect(vastaus.tila == .ok)
}

7. Dokumentoi poistossa olevat testit

Kun poistat testin käytöstä, kirjoita aina syy. Tulevaisuuden sinä kiittää:

@Test(.disabled("Odotetaan backend-tiimiä korjaamaan API-endpointti #IOS-456"))
func maksunKasittely() async throws { }

Kattava käytännön esimerkki

Kootaan kaikki yhteen kattavalla esimerkillä. Tässä testataan yksinkertaista tehtävänhallintajärjestelmää hyödyntäen lähes kaikkia aiemmin käsiteltyjä ominaisuuksia:

import Testing
@testable import TehtavaApp

// Tagien määrittely
extension Tag {
    @Tag static var malli: Self
    @Tag static var palvelu: Self
}

// Testitietokanta-ominaisuus
struct TestiYmparistoTrait: TestTrait, TestScoping {
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: @Sendable () async throws -> Void
    ) async throws {
        let ymparisto = try await TestiYmparisto.luo()
        try await TestiYmparisto.$nykyinen.withValue(ymparisto) {
            try await function()
        }
    }
}

extension TestTrait where Self == TestiYmparistoTrait {
    static var testiYmparisto: Self { TestiYmparistoTrait() }
}

// Testisuite
@Suite("Tehtävänhallinta", .testiYmparisto)
struct TehtavanhallintaTestit {
    
    @Suite("Tehtävämalli", .tags(.malli))
    struct MalliTestit {
        
        @Test("Tehtävän luominen oletusarvoilla")
        func luominen() {
            let tehtava = Tehtava(otsikko: "Opettele Swift Testing")
            #expect(tehtava.otsikko == "Opettele Swift Testing")
            #expect(tehtava.tila == .avoin)
            #expect(tehtava.prioriteetti == .normaali)
        }
        
        @Test("Tehtävän prioriteetit", arguments: Prioriteetti.allCases)
        func prioriteetit(prioriteetti: Prioriteetti) {
            let tehtava = Tehtava(otsikko: "Testi", prioriteetti: prioriteetti)
            #expect(tehtava.prioriteetti == prioriteetti)
            #expect(tehtava.prioriteetti.rawValue > 0)
        }
        
        @Test("Tyhjä otsikko ei ole sallittu")
        func tyhjaOtsikko() {
            #expect(throws: TehtavaVirhe.tyhjaOtsikko) {
                try Tehtava.validoitu(otsikko: "")
            }
        }
    }
    
    @Suite("Tehtäväpalvelu", .tags(.palvelu))
    struct PalveluTestit {
        
        @Test("Tehtävän tallentaminen ja hakeminen")
        func tallennusJaHaku() async throws {
            let palvelu = TehtavaPalvelu(ymparisto: .nykyinen)
            let luotu = try await palvelu.luo(otsikko: "Uusi tehtävä")
            let haettu = try #require(await palvelu.hae(id: luotu.id))
            #expect(haettu.otsikko == "Uusi tehtävä")
        }
        
        @Test("Tehtävän tilan päivitys", .serialized, arguments: [
            Tila.kesken, Tila.valmis, Tila.peruutettu
        ])
        func tilanPaivitys(uusiTila: Tila) async throws {
            let palvelu = TehtavaPalvelu(ymparisto: .nykyinen)
            let tehtava = try await palvelu.luo(otsikko: "Tilantesti")
            try await palvelu.paivitaTila(tehtava.id, tila: uusiTila)
            let paivitetty = try #require(await palvelu.hae(id: tehtava.id))
            #expect(paivitetty.tila == uusiTila)
        }
        
        @Test("Ilmoitus lähetetään tehtävän valmistuessa")
        func valmistumisilmoitus() async throws {
            let palvelu = TehtavaPalvelu(ymparisto: .nykyinen)
            let tehtava = try await palvelu.luo(otsikko: "Ilmoitustesti")
            
            await confirmation(expectedCount: 1) { ilmoitus in
                palvelu.kuunteleIlmoituksia { tyyppi in
                    if tyyppi == .tehtavaValmis {
                        ilmoitus()
                    }
                }
                try? await palvelu.paivitaTila(tehtava.id, tila: .valmis)
            }
        }
    }
}

Yhteenveto

Swift Testing on iso askel eteenpäin iOS-testauksessa. Se tuo mukanaan modernin syntaksin, joka hyödyntää Swiftin parhaita puolia — makroja, rinnakkaisuutta ja tyyppijärjestelmää. Ja mikä parasta, siirtymä ei tarvitse tapahtua kerralla.

Keskeiset muistettavat asiat:

  • @Test ja @Suite korvaavat XCTestCase-aliluokat
  • #expect ja #require korvaavat kaikki XCTAssert-variantit
  • Parametrisoidut testit vähentävät toistuvan koodin määrää merkittävästi
  • Ominaisuudet (traits) mahdollistavat testien mukauttamisen deklaratiivisesti
  • TestScoping (Swift 6.1+) tarjoaa modernin tavan hallita setup/teardown-logiikkaa
  • confirmation mahdollistaa callback-pohjaisten tapahtumien testaamisen elegantisti
  • Vaiheittainen migraatio XCTestistä on mahdollista ja suositeltavaa

Suosittelen aloittamaan uusien testien kirjoittamisen Swift Testingillä heti ja migroimaan vanhoja testejä pikkuhiljaa tilaisuuksien tullen. Kehys kehittyy jatkuvasti — Swift 6.2 tuo jo mukanaan ranged confirmations -ominaisuuden ja muita parannuksia. Testauksen tulevaisuus Swiftissä näyttää todella lupaavalta.

Tietoa Kirjoittajasta Editorial Team

Our team of expert writers and editors.