Choose test tools by the layer under test. Pure model behavior and UI automation have different lifecycles; migrating names alone does not improve the assertions.
- 1Behavior contract
- 2Test layer
- 3Controlled dependency
- 4Assertion
Work through the example
Keep existing XCTest coverage while considering Swift Testing for suitable tests. Do not replace UI automation with a model-level assertion.
Start with a disposable branch and synthetic data. Write the expected outcome before changing the implementation, then keep the first failing result. This prevents a later repair from quietly redefining the task. The procedure below is grounded in the repository reference; its examples must still be checked against your project and installed toolchain.
Implementation reference
The following focused section is adapted from the maintained project guide. It preserves the source’s examples and limitations.
Unit Testing ViewModels with Mocks
- [ ] Define protocols for all dependencies (repository, service, API client)
- [ ] Create mock implementations that allow stubbing return values and tracking calls
- [ ] Test each ViewModel method in isolation
- [ ] Verify state changes (loading, error, data) after each action
- [ ] Test edge cases: empty data, nil values, concurrent calls
// MARK: - Mock Repository
final class MockArticleRepository: ArticleRepositoryProtocol, @unchecked Sendable {
var stubbedArticles: [Article] = []
var shouldFail = false
var createCallCount = 0
var lastCreatedArticle: Article?
func getAll() async throws -> [Article] {
if shouldFail { throw RepositoryError.offline }
return stubbedArticles
}
func getById(_ id: String) async throws -> Article? {
if shouldFail { throw RepositoryError.notFound }
return stubbedArticles.first { $0.id == id }
}
func create(_ entity: Article) async throws -> Article {
if shouldFail { throw RepositoryError.invalidResponse }
createCallCount += 1
lastCreatedArticle = entity
stubbedArticles.append(entity)
return entity
}
func update(_ entity: Article) async throws -> Article {
if shouldFail { throw RepositoryError.invalidResponse }
if let index = stubbedArticles.firstIndex(where: { $0.id == entity.id }) {
stubbedArticles[index] = entity
}
return entity
}
func delete(_ id: String) async throws {
if shouldFail { throw RepositoryError.notFound }
stubbedArticles.removeAll { $0.id == id }
}
}
// MARK: - ViewModel Tests
@Suite("Create Article Flow")
struct CreateArticleViewModelTests {
@Test("creates article and resets form")
func createSuccess() async {
let mock = MockArticleRepository()
let vm = CreateArticleViewModel(repository: mock)
vm.title = "New Article"
vm.body = "Content here"
await vm.save()
#expect(mock.createCallCount == 1)
#expect(mock.lastCreatedArticle?.title == "New Article")
#expect(vm.title.isEmpty) // form reset
#expect(vm.isSaved)
}
@Test("shows validation error when title is empty")
func validationError() async {
let mock = MockArticleRepository()
let vm = CreateArticleViewModel(repository: mock)
vm.title = ""
vm.body = "Content"
await vm.save()
#expect(mock.createCallCount == 0)
#expect(vm.titleError == .emptyField(fieldName: "Title"))
}
}
Acceptance and failure review
| Checkpoint | What to inspect | If it does not match |
|---|---|---|
| Behavior contract | Confirm the input and environment | Preserve the failure and return to this step |
| Test layer | Inspect the intermediate artifact | Preserve the failure and return to this step |
| Controlled dependency | Run the focused check | Preserve the failure and return to this step |
| Assertion | Record the observed result | Preserve the failure and return to this step |
Ask the agent to explain the smallest change that resolves the observed mismatch. Keep unrelated refactors out of the repair. A change that makes a warning disappear is not enough if the behavior or ownership contract has changed. Re-run the same acceptance check so the before and after results are comparable.
Choose a test layer before a framework
Apple explains that Swift Testing and XCTest can coexist in a project in its Swift Testing overview. Keep UI automation separate from pure model assertions. A storage round-trip can be tested without tapping a screen; a save button’s interaction with the form needs a UI-level check.
The example mock above is source guidance, not a production concurrency guarantee. Its mutable @unchecked Sendable state requires externally controlled access; do not use that annotation to bypass races in parallel tests. Prefer isolation that matches the dependency contract and create independent fixtures per test.
Evidence and limits
Apple documents coexistence; no complete project migration is claimed here.
This is an educational guide. Its presence in the series does not certify a completed client-specific lab. The series evidence record separates executed checks from exercises and blocked environments.
Inspect the source used in this lesson.
Related reading
- Review AI-generated Swift before you trust it
A focused review, a small patch and a real test beat a confident completion message.
- How can Claude Code hooks protect generated files and verify a project before stopping?
A tested generated-file guard and Stop check, with configuration, reproducible exit codes, and enforcement limits.