EthicsComputerCourses
Join training +91 90532 10052

Swift Testing & XCTest

Swift · Lesson 29 of 30 Advanced 7 min read Updated

Every time you change an app, you risk breaking something that used to work. Checking every screen by hand after every change is slow and easy to forget. Automated tests are small pieces of code that check your code for you, in seconds, every time you ask.

Apple offers two testing frameworks. Swift Testing (since Xcode 16 and Swift 6) is the modern one, built around macros like @Test and #expect. XCTest is the older framework, still used for UI automation and performance tests and found in almost every existing project. In this lesson you will write tests with Swift Testing, then learn to read XCTest.

The code we will test

Good tests start with code that has clear inputs and outputs. Keep logic like this in plain structs and functions, not inside views, so it is easy to test.

Swift
struct FeeCalculator {
    let monthlyFee: Int

    func total(months: Int, siblingDiscount: Bool = false) -> Int {
        let base = monthlyFee * months
        return siblingDiscount ? base * 90 / 100 : base
    }
}

let calculator = FeeCalculator(monthlyFee: 1500)
print(calculator.total(months: 12))
print(calculator.total(months: 12, siblingDiscount: true))
Output
18000
16200

Printing results and checking them by eye works once. A test checks them automatically, forever.

Your first Swift Testing tests

In Xcode, a project created with a test target already has a test file. Otherwise add one with File > New > Target > Unit Testing Bundle. A test is any function marked @Test; inside it, #expect checks that a condition is true.

Swift
import Testing
@testable import SchoolApp

@Test func fullYearFee() {
    let calculator = FeeCalculator(monthlyFee: 1500)
    #expect(calculator.total(months: 12) == 18000)
}

@Test("Sibling discount takes 10 percent off")
func siblingDiscount() {
    let calculator = FeeCalculator(monthlyFee: 1500)
    #expect(calculator.total(months: 12, siblingDiscount: true) == 16200)
}

@testable import SchoolApp gives the tests access to the app's internal types (replace SchoolApp with your app's module name). Press Cmd+U to run all tests, or click the diamond next to a test. Passing tests get a green tick in the Test navigator. If you change 16200 to 16000, that test fails: Xcode marks the line in red and shows the expression with its actual value, so you can see at once that the code produced 16200. The text in @Test("...") is a readable display name.

#require, errors and async tests

#expect records a failure and lets the test continue. #require stops the test when it fails, which is useful when the rest of the test makes no sense without a value. Used with try, it also unwraps optionals. You can also check that code throws the error you expect, and test async code by marking the test async.

Swift
enum BookingError: Error, Equatable {
    case invalidCount
}

func bookSeats(_ count: Int) throws -> Int {
    guard (1...6).contains(count) else { throw BookingError.invalidCount }
    return count * 450
}

@Test func parsesTypedMarks() throws {
    let marks = try #require(Int("85"))
    #expect(marks >= 33)
}

@Test func rejectsZeroSeats() {
    #expect(throws: BookingError.invalidCount) {
        try bookSeats(0)
    }
}

@Test func bookingCostsForTwo() async throws {
    let cost = try bookSeats(2)
    try await Task.sleep(for: .milliseconds(10))   // stands in for real async work
    #expect(cost == 900)
}

All three pass. If Int("85") ever returned nil, #require would end the first test immediately instead of crashing. The second test passes only if the closure throws exactly BookingError.invalidCount; the error type must be Equatable for this form.

Parameterized tests and suites

A parameterized test runs the same check with many inputs, and each input is reported separately. Pass the inputs with arguments:; use zip to pair inputs with expected results. Group related tests in a suite, a type marked @Suite (or any type containing @Test functions). Swift Testing creates a fresh instance of the suite for every test, so tests cannot affect each other.

Swift
@Suite("Fee rules")
struct FeeRuleTests {
    let calculator = FeeCalculator(monthlyFee: 1500)

    @Test(arguments: zip([1, 6, 12], [1500, 9000, 18000]))
    func feeForMonths(months: Int, expected: Int) {
        #expect(calculator.total(months: months) == expected)
    }

    @Test func noMonthsNoFee() {
        #expect(calculator.total(months: 0) == 0)
    }

    @Test(.disabled("Waiting for the new hostel fee policy"))
    func hostelFee() {
        #expect(calculator.total(months: 1) == 2500)
    }
}

The Test navigator shows the suite "Fee rules" with three passing cases for feeForMonths (one per argument pair), a passing noMonthsNoFee, and hostelFee marked as skipped with its reason. Swift Testing runs tests in parallel by default, which is fast but means tests must not share changing global state. Traits such as .disabled, .enabled(if:), .tags(...) and .timeLimit(...) customise tests. In a Swift package, run swift test in the terminal instead of Cmd+U.

Reading XCTest

Older test files use XCTest: test methods are named test... inside a class that inherits from XCTestCase, and checks use XCTAssert functions.

Swift
import XCTest
@testable import SchoolApp

final class FeeCalculatorTests: XCTestCase {
    func testFullYearFee() {
        let calculator = FeeCalculator(monthlyFee: 1500)
        XCTAssertEqual(calculator.total(months: 12), 18000)
    }

    func testTypedMarks() throws {
        let marks = try XCTUnwrap(Int("85"))
        XCTAssertGreaterThanOrEqual(marks, 33)
    }
}

final class LoginUITests: XCTestCase {
    @MainActor
    func testSignInButtonExists() {
        let app = XCUIApplication()
        app.launch()
        XCTAssertTrue(app.buttons["Sign in"].exists)
    }
}

The first class is a unit test, equivalent to the Swift Testing tests above. The second is a UI test: it launches the real app in the simulator and looks for a button labelled "Sign in", the way a user would. UI tests and performance tests (measure { }) still use XCTest. Both frameworks can live in the same project, so you can write new tests with Swift Testing and migrate old ones gradually.

TaskSwift TestingXCTest
Mark a test@Test func name()func testName() in an XCTestCase
Check a condition#expect(a == b)XCTAssertEqual(a, b)
Unwrap or stoptry #require(value)try XCTUnwrap(value)
Same test, many inputs@Test(arguments:)Loop inside the test
UI automation, performanceNot supportedXCUIApplication, measure

Common mistakes

  • Testing views instead of logic. Move calculations into structs and functions and test those.
  • Tests that depend on each other. Each test should set up its own data; Swift Testing runs them in parallel.
  • Using #expect when a value must exist. Use try #require so the test stops cleanly.
  • Isolation errors in tests. If your app target uses main-actor default isolation, its types live on the main actor; mark the test suite or test functions @MainActor.
  • Never running the tests. Run them with Cmd+U before every commit, and in continuous integration if your team has it.

Key points

  • Swift Testing (Xcode 16 and later) uses @Test functions and #expect/#require checks.
  • #expect(throws:) checks errors, and tests can be async and throws.
  • @Test(arguments:) runs one test for many inputs; @Suite groups tests, with a fresh instance per test.
  • Traits like .disabled, .tags and .timeLimit customise tests; tests run in parallel by default.
  • XCTest uses XCTestCase classes and XCTAssert... functions, and remains the tool for UI and performance tests.

Exercise

Write a function grade(for marks: Int) -> String that returns "A" for 85 and above, "B" for 60 to 84, "C" for 33 to 59 and "Fail" below 33. Then write one parameterized Swift Testing test that checks the pairs (95, "A"), (85, "A"), (70, "B"), (33, "C") and (12, "Fail").

Show solution
Swift
import Testing

func grade(for marks: Int) -> String {
    switch marks {
    case 85...: "A"
    case 60..<85: "B"
    case 33..<60: "C"
    default: "Fail"
    }
}

@Test(arguments: zip([95, 85, 70, 33, 12], ["A", "A", "B", "C", "Fail"]))
func gradeBoundaries(marks: Int, expected: String) {
    #expect(grade(for: marks) == expected)
}

Running the tests shows five passing cases under gradeBoundaries, one per pair. Testing the exact boundaries (85 and 33) catches the most common bug in grading code: using > where >= was needed.

Quick Quiz

1. Which macro checks a condition and lets the test continue if it fails?

2. How do you run the same Swift Testing test with several inputs?

3. Which framework do you use for UI tests that tap buttons in the running app?

4. What does @testable import SchoolApp do?

Learn Swift with a mentor

Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call