Swift Testing & XCTest
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.
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))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.
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.
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.
@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.
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.
| Task | Swift Testing | XCTest |
|---|---|---|
| Mark a test | @Test func name() | func testName() in an XCTestCase |
| Check a condition | #expect(a == b) | XCTAssertEqual(a, b) |
| Unwrap or stop | try #require(value) | try XCTUnwrap(value) |
| Same test, many inputs | @Test(arguments:) | Loop inside the test |
| UI automation, performance | Not supported | XCUIApplication, 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
#expectwhen a value must exist. Usetry #requireso 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
@Testfunctions and#expect/#requirechecks. #expect(throws:)checks errors, and tests can beasyncandthrows.@Test(arguments:)runs one test for many inputs;@Suitegroups tests, with a fresh instance per test.- Traits like
.disabled,.tagsand.timeLimitcustomise tests; tests run in parallel by default. - XCTest uses
XCTestCaseclasses andXCTAssert...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
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?
#expect records a failure and continues; #require stops the test.
2. How do you run the same Swift Testing test with several inputs?
Parameterized tests report each argument separately.
3. Which framework do you use for UI tests that tap buttons in the running app?
UI automation is provided by XCTest's XCUIApplication.
4. What does @testable import SchoolApp do?
@testable exposes internal declarations to the tests.
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
