Every time you change code, something that used to work might break. Tapping through the whole app after each change is slow and you'll miss things. Automated tests are small pieces of code that call your code and check the results, and they run in seconds, every time.
Swift has two testing frameworks. Swift Testing, introduced with Xcode 16, is the modern one: tests are functions marked @Test, and checks use the #expect macro, which shows exactly which values differed when something fails. XCTest is the older framework, still found in many existing projects and still used for UI tests. Both run in Xcode's Test navigator, from the command line with swift test, and in continuous integration.
Good tests also shape good code. Code that's easy to test usually takes its dependencies through protocols, so a test can pass in a fake network or database. In this lesson you'll write tests, check errors and async functions, run one test with many inputs, and use a fake to test a view model.
Your first tests
import Testing
@testable import Shop
@Test func discountAppliesToTotal() {
let basket = Basket(prices: [10, 20])
#expect(basket.total(discount: 0.1) == 27)
}
@Test("Empty basket costs nothing")
func emptyBasket() {
#expect(Basket(prices: []).total(discount: 0) == 0)
}@testable importgives tests access to your app's internal code.#expect(condition)records a failure if the condition is false, then carries on with the test.try #require(condition)stops the test immediately if it fails, and also unwraps optionals:let user = try #require(users.first).- A string in
@Test("...")gives the test a readable name in Xcode.
Errors, many inputs and suites
@Test func rejectsNegativeAmounts() {
#expect(throws: BankError.invalidAmount) {
try account.withdraw(-5)
}
}
@Test(arguments: ["a@b.com", "ada@example.org"])
func acceptsValidEmails(_ email: String) {
#expect(email.isEmail)
}#expect(throws:)passes only if the code throws that error.- Parameterised tests run once per argument, and Xcode reports each one separately, so one failing input is easy to spot.
- Group related tests in a
structmarked@Suite. Each test gets a fresh instance, soinit()works as set-up code.
Async tests and fakes
Test functions can be async and throws, so testing async code is just await:
@Test func loadsProducts() async throws {
let model = ProductsModel(api: FakeAPI())
try await model.load()
#expect(model.products.count == 2)
}FakeAPI conforms to the same protocol as the real network client but returns fixed data instantly. Tests stay fast, never fail because the Wi-Fi is down, and can simulate errors that are hard to cause for real.
Clear failure messages and combined arguments
#expect is a macro, so it can look inside the expression it checks. When a test fails, Swift Testing reports the values involved rather than just "false":
@Test func totalIsFour() {
let total = 3
#expect(total == 4)
// Expectation failed: (total → 3) == 4
}That's why you write ordinary comparisons with ==, < or contains instead of a different assertion function for each kind of check.
When a parameterised test takes two argument lists, Swift Testing runs every combination. Two prices and two discount rates give four runs. To pair inputs with their expected results instead, combine the lists with zip:
@Test(arguments: zip([10.0, 20.0], [5.0, 10.0]))
func halfPrice(price: Double, expected: Double) {
#expect(discounted(price, by: 0.5) == expected)
}To switch off a test temporarily without deleting it, use a trait: @Test(.disabled("Waiting for the new pricing rules")). The test is reported as skipped along with your reason, so it isn't forgotten.
Swift Testing and XCTest
Swift Testing arrived with Xcode 16 and Swift 6. Many existing projects use the older XCTest framework, and both can live in the same test target:
| Swift Testing | XCTest |
|---|---|
@Test func checksTotal() | func testChecksTotal() inside a class that inherits XCTestCase |
#expect(a == b) | XCTAssertEqual(a, b) |
try #require(value) | try XCTUnwrap(value) |
init() in a @Suite struct | setUp() |
@Test(arguments:) | A loop inside one test |
XCTest is still needed for UI tests and performance tests, so you'll meet both. For new unit tests, Swift Testing's single #expect and parameterised tests are usually simpler.
Swift Testing Syntax
Testing syntax
import Testing
@testable import MyApp
@Suite struct CartTests {
let cart = Cart()
@Test func startsEmpty() { #expect(cart.items.isEmpty) }
@Test(arguments: [1, 5, 10]) func adds(_ n: Int) { #expect(n > 0) }
@Test func failsOnBadInput() { #expect(throws: CartError.self) { try cart.add(-1) } }
@Test func loads() async throws { let x = try #require(await fetch()); #expect(x > 0) }
}
// XCTest
final class OldTests: XCTestCase {
func testTotal() { XCTAssertEqual(total([1, 2]), 3) }
}Swift Testing Summary
- Swift Testing marks tests with @Test and checks them with #expect.
- try #require stops the test on failure and unwraps optionals.
- #expect(throws:) checks for errors; arguments run one test with many inputs.
- Tests can be async throws; protocol-based fakes replace networks and databases.
- XCTest uses XCTestCase subclasses and XCTAssert functions in older projects.
Swift Testing Examples
Each example shows the code, the output it produces and what each part does.
Testing a price calculator
// Shop/PriceCalculator.swift
struct PriceCalculator {
func total(_ prices: [Double], discountPercent: Double = 0) -> Double {
let sum = prices.reduce(0, +)
return (sum * (100 - discountPercent) / 100 * 100).rounded() / 100
}
}
// ShopTests/PriceCalculatorTests.swift
import Testing
@testable import Shop
@Suite struct PriceCalculatorTests {
let calc = PriceCalculator()
@Test func addsPrices() {
#expect(calc.total([2.5, 7.5]) == 10)
}
@Test func appliesDiscount() {
#expect(calc.total([40], discountPercent: 25) == 30)
}
@Test(arguments: [0.0, 10.0, 50.0])
func emptyBasketIsAlwaysZero(_ discount: Double) {
#expect(calc.total([], discountPercent: discount) == 0)
}
}Test run started.
Test addsPrices() passed.
Test appliesDiscount() passed.
Test emptyBasketIsAlwaysZero(_:) passed after 3 arguments.
Suite PriceCalculatorTests passed.
Test run with 5 tests in 1 suite passed.Line-by-line explanation
- Line 5The total is rounded to two decimal places, which avoids tiny floating point differences.
- Line 14Each test in the suite gets a fresh calculator.
- Line 172.5 plus 7.5 is exactly 10.
- Line 2125% off 40 is 30.
- Lines 24 to 26One test runs three times, once per discount, and counts as three tests in the summary.
Testing errors with #expect(throws:)
enum SignUpError: Error, Equatable {
case tooShort
case missingNumber
}
func checkPassword(_ password: String) throws {
guard password.count >= 8 else { throw SignUpError.tooShort }
guard password.contains(where: \.isNumber) else { throw SignUpError.missingNumber }
}
import Testing
@Test func shortPasswordsAreRejected() {
#expect(throws: SignUpError.tooShort) {
try checkPassword("abc1")
}
}
@Test func passwordsNeedANumber() {
#expect(throws: SignUpError.missingNumber) {
try checkPassword("longenough")
}
}
@Test func goodPasswordPasses() throws {
try checkPassword("longenough9")
}Test shortPasswordsAreRejected() passed.
Test passwordsNeedANumber() passed.
Test goodPasswordPasses() passed.
Test run with 3 tests passed.Line-by-line explanation
- Line 1Making the error Equatable lets the test compare the exact case.
- Lines 13 to 17The test passes only if this specific error is thrown.
- Lines 25 to 27A throwing test fails automatically if an error escapes.
A view model with a fake API
protocol WeatherAPI {
func temperature(in city: String) async throws -> Double
}
@MainActor
final class WeatherModel {
private let api: WeatherAPI
var message = ""
init(api: WeatherAPI) { self.api = api }
func load(_ city: String) async {
do {
let t = try await api.temperature(in: city)
message = "\(city): \(Int(t))°C"
} catch {
message = "Couldn't load the weather"
}
}
}
import Testing
struct FakeAPI: WeatherAPI {
var result: Result<Double, Error>
func temperature(in city: String) async throws -> Double { try result.get() }
}
struct Offline: Error {}
@MainActor @Test func showsTemperature() async {
let model = WeatherModel(api: FakeAPI(result: .success(18.6)))
await model.load("Oslo")
#expect(model.message == "Oslo: 18°C")
}
@MainActor @Test func showsErrorMessage() async {
let model = WeatherModel(api: FakeAPI(result: .failure(Offline())))
await model.load("Oslo")
#expect(model.message == "Couldn't load the weather")
}Test showsTemperature() passed.
Test showsErrorMessage() passed.
Test run with 2 tests passed.Line-by-line explanation
- Lines 1 to 3The model depends on a protocol, not on a real network client.
- Lines 23 to 26The fake returns whatever Result the test gives it, instantly.
- Line 33Int(18.6) drops the fraction, giving 18.
- Lines 36 to 40Failure is easy to simulate, without turning off the network.
Common Swift Testing Mistakes
Tests that call the real network
@Test func loadsWeather() async throws {
let model = WeatherModel(api: LiveWeatherAPI()) // slow, and fails when offline
}@Test func loadsWeather() async throws {
let model = WeatherModel(api: FakeAPI(result: .success(20)))
}Comparing Doubles exactly after arithmetic
#expect(0.1 + 0.2 == 0.3) // fails: 0.30000000000000004#expect(abs((0.1 + 0.2) - 0.3) < 0.000001)One test checking everything
@Test func basketWorks() { /* 40 checks about adding, removing, discounts and tax */ }@Test func addingAnItemIncreasesCount() { ... }
@Test func discountReducesTotal() { ... }Swift Testing in Real Projects
Testing in iOS teams
Teams run unit tests on every pull request in CI services such as Xcode Cloud or GitHub Actions, and block merging when a test fails. View models and business logic get the most tests, because they hold the rules and run without a simulator. XCUITest drives the real app for a few key journeys, like signing in and checking out, and snapshot tests catch accidental visual changes.
Swift Testing FAQ
What is the difference between #expect and #require in Swift Testing?
#expect records a failure and lets the test carry on, so you can see several problems in one run. #require throws and stops the test straight away, so it's used with try for conditions the rest of the test depends on, such as unwrapping an optional with try #require(users.first).
What does @testable import do?
It lets your test target see declarations marked internal, the default access level, in the module under test, as if the tests were part of it. Without it, tests could only use public code. It only works when the module is built with testing enabled, which Xcode does for test builds.
How do I run Swift tests from the command line?
In a Swift package, run swift test from the package folder; it builds the code and runs both Swift Testing and XCTest tests. For an Xcode project, use xcodebuild test with your scheme and a destination, or press Command-U inside Xcode.
How do I test code that uses the network?
Hide the network behind a protocol, have the real client conform to it, and give tests a fake that returns fixed data or throws chosen errors. Tests then run instantly, give the same result every time and can check error handling without a real server.
How do I compare Doubles in a Swift test?
Don't use == on results of arithmetic, because tiny rounding errors make equal-looking values differ. Check the difference is small instead: #expect(abs(result - 0.3) < 0.0001). XCTest has XCTAssertEqual(result, 0.3, accuracy: 0.0001) for the same job.
Swift Testing Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the check inside the test.
@Test func doublesNumbers() {
(double(4) == 8)
}
Which attribute marks a function as a test in Swift Testing?
What's the difference between #expect and try #require?
How many test results does this produce?
@Test(arguments: [1, 2, 3, 4])
func isPositive(_ n: Int) { #expect(n > 0) }
Why should a view model take its API through a protocol?
Swift Testing Quiz
5 questions to check what you learned.
What does @testable import do?
- Runs the tests
- Gives the test target access to the app module's internal code
- Imports XCTest
- Marks code as finished
How do you check that code throws a specific error?
- #expect(error)
- #expect(throws: MyError.someCase) { try ... }
- try! inside the test
- XCTFail()
Can Swift Testing tests be async?
- No
- Only with XCTest
- Yes, mark them async (and throws) and use await
- Only on the main thread
What groups related tests in Swift Testing?
- A type marked @Suite
- A class that inherits from XCTestCase
- A protocol
- A folder named Tests
Which XCTest function compares two values?
- #expect
- assertSame
- XCTCompare
- XCTAssertEqual