Manual QA is slow, clumsy and error-prone. When you click through every flow in your app by hand, you lose precious time—and you still might wake up at 3 AM wondering if you forgot to test that one edge case. Automated unit testing solve this by verifying small “units” of behavior in isolation, in milliseconds, over and over again. Let’s walk through how to get real value from unit tests without overengineering.
We’ll walk through writing your first test using the new Swift Testing Framework (Swift 6+), which is a big improvement to XCTest.
⬇️ Project files: https://github.com/gahntpo/iOS-testing
A unit test is an automated test that
- Verifies a single unit of behavior,
- Does it quickly,
- And in isolation from other tests.
Vladimir Khorikov, Unit Testing: Principles, Practices, and Patterns
Setting Up Unit Tests in Xcode
✅ Creating a New Project With Tests
When starting a new project in Xcode:
- Select App template.
- Make sure you check “Include Tests” when configuring the project.
- This will add a test target automatically (e.g., YourAppTests).

➕ Adding Tests to an Existing Project
If you already have a SwiftUI project:
- Open File > New > Target…
- Select “Unit Testing Bundle”
- Choose your app under “Target to be tested”
- In your new test files, add:
@testable import YourAppModule
The Code We’re Testing
Let’s start with a simple ItemViewModel that powers a SwiftUI view. It displays a list of items and removes the last one when a button is tapped.
struct Item: Identifiable {
var name: String
let id: UUID
init(name: String, id: UUID = UUID()) {
self.name = name
self.id = id
}
static var examples: [Item] {
[Item(name: "first"), Item(name: "second"), Item(name: "third")]
}
}
final class ItemViewModel: ObservableObject {
@Published var items: [Item] = Item.examples
@Published var deleteDisabled = false
func removeLast() {
guard items.isEmpty == false else { return }
items.removeLast()
deleteDisabled = items.isEmpty
}
}
The main content view shows the items and a button to delete the last item:
struct ContentView: View {
@ObservedObject var viewModel: ItemViewModel
var body: some View {
VStack {
ScrollView {
ForEach(viewModel.items) { item in
Text(item.name)
.padding()
.frame(maxWidth: .infinity)
.background(Capsule().fill(Color.yellow))
}
}
Button("Remove Last") {
viewModel.deleteLastItem()
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.deleteDisabled)
}
}
}

Writing Unit Tests Using Swift Testing
Swift 6 introduced a new way to write tests—no need for XCTestCase or inheritance. Here’s what a clean Swift Testing setup looks like:
import Testing
@testable import ItemApp
struct ItemViewModelTests {
@Test func items_when_remove_button_then_items_count_lowered() {
let vm = ItemViewModel()
vm.removeLast()
#expect(vm.items.count == 2)
}
}
Add the @Test marco in front of the test function, otherwise you Xcode will not recognize it as a test function.
Running Your Tests in Xcode
You can run tests by any of the following methodes:
- Press ⌘ + U to run all tests.
- Use the Test Navigator (⌘ + 6) to see individual test results.
- run one test by pressing on the diamond next to the @Test macro

Failed tests will be shown with a red icon and passed tests will show with a green checkmark:

Testing Best practices
Writing tests is easy but writing tests that will help you catch and fix bugs in the future is hard. Here is an example of a test that is readable and clearly structured with a good assertion:
@Test("Remove Button lowers item count")
func items_when_remove_button_then_items_count_lowered() {
// --- GIVEN ---
let vm = ItemViewModel()
let itemCount = vm.items.count
let expectedItemCount = itemCount - 1
#expect(vm.items.count > 0) // precondition
// --- WHEN ---
vm.deleteLastItem()
// --- THEN ---
#expect(vm.items.count == expectedItemCount,
"Expected that the array has \(expectedItemCount) items")
}
Tests follow Given–When–Then or Arrange-Act-Assert structure.
The When section should only have one line of code (clearly indicate what behavior you are testing). Otherwise you might consider refactoring your code to encapsulate the multiline code into one single call.
Name Tests in Plain English
Avoid tying test names to implementation details that might change. Prefer:
func items_when_remove_button_then_items_count_lowered()
over
func itemViewModel_removeLast_success()
– if you rename removeLast()
, the first stays meaningful, the second forces you to update tests too.
Make Tests Resilient while Refactoring
Rather than asserting a hard-coded expected value (e.g. 2
):
#expect(vm.items.count == 2)
Instead derive it .Now if you change initial items (or add a new default), your test still passes without rewriting “2” everywhere.
let itemCount = vm.items.count
let expectedItemCount = itemCount - 1
#expect(vm.items.count == expectedItemCount)
I cannot stress enough how frustrating failed tests are that give you false alarms that means they fail but your app is working as expected. They are demotivating and will take addtional time away from you. Please try to write tests that are flexible enough in future refactorings.
You want to write tests that give me a clear failure notification. Spend a significant amount of your time finding good assertions. What is the expected behavior. In above example, I am not expecting that the item count i always a fixed number 2. I would expect that the itme count is lowered by 1.
Testing Edge Cases: Guard Clauses
Our removeLastItem()
returns early when the items array is early:
func deleteLastItem() {
guard items.isEmpty == false else { return }
items.removeLast()
deleteDisabled = items.isEmpty
}
Test this edge case too:
@Test func no_items_when_remove_button_then_nothing_happens() async throws {
// --- GIVEN ---
let vm = ItemViewModel()
vm.items = []
// --- WHEN ---
vm.deleteLastItem()
// --- THEN ---
#expect(vm.items.isEmpty)
}
This ensures your guard clause actually protects you, not just the happy path.
What to Test (and What Not)
Aim your tests at code that’s either:
- Complex (lots of branches, business logic)
- Business-critical (payment flows, data integrity)
Skip trivial models or boilerplate that you’ll rewrite constantly. If changing an initializer or model property breaks dozens of superficial tests, you’ll lose confidence fast and stop writing tests.
How to Test the UI in SwiftUI Apps
Unit tests are fast—but they don’t interact with the UI. XCUITests fill that gap for end-to-end flows (button taps, navigation), but they’re slow. Only automate the most important user journeys—don’t record a dozen tiny UI tests that take minutes to run every build. You can read more about the different types of tests in ios development in this blog post.
⬇️ Project files: https://github.com/gahntpo/iOS-testing
An alternative to XCUITest are unit tests for SwiftUI interfaces. You can use snapshot tests, ViewInspector or preference testing. These are much faster and reliable then XCUITests.
Key Takeaways
- Start small: test one function at a time (AAA/Given-When-Then).
- Name clearly in plain English to survive refactors.
- Derive expected values instead of hard-coding.
- Cover edge cases: guard clauses, empty data.
- Prioritize complexity and business significance over boilerplate.
- Error messages: write clear failure messages so you know what you meant months later.
- Write good assertions: focus on behavior, avoid brittle checks (e.g. fixed numbers).
- Don’t write bad tests: if a test isn’t valuable or maintainable, it’s better left unwritten.
With just a handful of focused, resilient tests, you’ll catch regressions instantly, code with confidence, and spend far less time repeating manual QA. Your future self (and your users) will thank you.