A test is a small program that runs your code and checks the result. You write it once, and it checks again every time you change something. Tests do not remove all bugs. They catch the ones that would come back, and they let you change code without fear. Employers value them because they show you can work on code other people depend on.
What kinds of tests there are
- Unit tests check a small piece of logic, such as a validation rule or a view model, with no screen.
- UI tests drive the real app like a user: launch, tap, type and look at what appears. They are slower, so write fewer of them for the main flows.
- Manual testing still matters, on real phones, with different text sizes and with no network.
In Xcode, tick Include Tests when you create a project, or add a Unit Testing Bundle target to an existing one. You run tests with Command and U, or with the small diamond next to a test. A green mark means it passed.
Swift Testing
Swift Testing is Apple's newer test framework, available from Xcode 16. A test is a function with @Test. You check values with #expect. If an expectation is false, the test fails and the message shows both sides.
import Testing
@testable import ExpenseTracker
// These two already live in your app from lesson 5. They are repeated here so the tests make sense.
// Make one change there: add Equatable to ExpenseError, as below. Do not define them twice.
enum ExpenseError: Error, Equatable {
case emptyTitle
case invalidAmount(Double)
}
func makeExpense(title: String, amount: Double) throws -> Expense {
guard !title.trimmingCharacters(in: .whitespaces).isEmpty else { throw ExpenseError.emptyTitle }
guard amount > 0 else { throw ExpenseError.invalidAmount(amount) }
return Expense(title: title, amount: amount)
}
struct ExpenseValidationTests {
@Test func acceptsValidInput() throws {
let expense = try makeExpense(title: "Lunch", amount: 8.5)
#expect(expense.title == "Lunch")
#expect(expense.amount == 8.5)
}
@Test func rejectsEmptyTitle() {
#expect(throws: ExpenseError.emptyTitle) {
try makeExpense(title: " ", amount: 5)
}
}
@Test(arguments: [0.0, -1, -50.5])
func rejectsBadAmounts(amount: Double) {
#expect(throws: ExpenseError.invalidAmount(amount)) {
try makeExpense(title: "Tea", amount: amount)
}
}
}
@testable importlets tests see your app's internal code. Use your own app's module name.#expect(throws:)checks that a call throws the error you expect.@Test(arguments:)runs the same test once for each value, which keeps the list of cases in one place.- A test that throws is a failed test, so mark such tests
throws.
Testing a view model with a fake
This is where the work of lesson 13 pays off. We give the view model a fake service that fails, and check that the state becomes failed.
import Testing
@testable import ExpenseTracker
struct FailingWeatherService: WeatherServing {
struct Boom: Error {}
func fetch(latitude: Double, longitude: Double) async throws -> Weather {
throw Boom()
}
}
@MainActor
struct WeatherViewModelTests {
@Test func showsAFriendlyErrorWhenTheServiceFails() async {
let viewModel = WeatherViewModel(service: FailingWeatherService())
await viewModel.load(latitude: 0, longitude: 0)
guard case .failed(let message) = viewModel.state else {
Issue.record("Expected the failed state")
return
}
#expect(message.contains("Could not load"))
}
}
The test never touches the internet, so it is fast and always gives the same result. That is what you want from a unit test. Issue.record fails the test with a message of your choice.
XCTest
XCTest is the older framework, and it is still everywhere. Test classes inherit from XCTestCase, and every method whose name starts with test runs. You check values with functions such as XCTAssertEqual. UI tests use XCTest too. Expect to read both styles in jobs, and the same ideas apply.
import XCTest
@testable import ExpenseTracker
final class TotalTests: XCTestCase {
func testTotalOfAmounts() {
let amounts = [12.5, 40, 7.25]
XCTAssertEqual(amounts.reduce(0, +), 59.75, accuracy: 0.001)
}
func testMakeExpenseRejectsZero() {
XCTAssertThrowsError(try makeExpense(title: "Tea", amount: 0))
}
}
XCUITest: testing like a user
Add a UI Testing Bundle target. A UI test launches your app in a separate process and finds controls by their label or by an accessibility identifier, a name you give to a view so tests, and assistive tools, can find it.
// in the view
Button("Add", systemImage: "plus") { showAdd = true }
.accessibilityIdentifier("addExpenseButton")
// in the UI test target
import XCTest
final class ExpenseUITests: XCTestCase {
func testAddingAnExpense() {
let app = XCUIApplication()
app.launch()
app.buttons["addExpenseButton"].tap()
let title = app.textFields["Title"]
title.tap()
title.typeText("Coffee")
app.buttons["Save"].tap()
XCTAssertTrue(app.staticTexts["Coffee"].waitForExistence(timeout: 3))
}
}
UI tests can be slow and a little fragile, so keep them for your most important paths, such as signing in or adding the main item. For these tests you often want a clean, known state, for example an in-memory database from lesson 12 and a fake service. A common trick is to pass a launch argument that tells the app to use them.
Quick and Nimble, and what to do first
Quick and Nimble are third-party libraries that offer a more descriptive style of test, describe and it, which some teams like. You do not need them. Learn Swift Testing and XCTest first, because they come from Apple and every project can use them.
A sensible testing habit
- Test logic that could go wrong: validation, calculations, view models, parsing JSON.
- Do not test that SwiftUI draws a
Text. That is Apple's job. - Name tests as sentences, such as
rejectsEmptyTitle. A failure then explains itself. - When you find a bug, write a failing test first, fix the code, and keep the test.
Your task
- Add a test target if your project has none. Write the three validation tests above.
- Write a test that checks the total of three expenses.
- Write the failing-service test for your view model.
- Write one UI test that adds an expense. Give the button and fields accessibility identifiers.
How to check it worked
- All tests pass with Command and U. Change an expected value on purpose and see one fail with a clear message.
- No unit test uses the real network.
- You can say when to write a unit test and when a UI test.