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.

Behavior contract → Test layer → Controlled dependency → Assertion
  1. 1Behavior contract
  2. 2Test layer
  3. 3Controlled dependency
  4. 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.

What to do next

Next: UI tests an agent can write and maintain