Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Generate UI tests for FOSMVVM SwiftUI views using XCTest and FOSTestingUI. Covers accessibility identifiers, ViewModelOperations, and test data transport.
.claude/skills/fosmvvm-ui-tests-generator/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-17 | ✗→✓ | ▲ Improved | — | — |
| case-10 | ✗→✓ | ▲ Improved | — | — |
| case-01 | ✗→✓ | ▲ Improved | — | — |
| case-18 | ✗→✓ | ▲ Improved | — | — |
| case-02 | ✗→✓ | ▲ Improved | — | — |
Generate comprehensive UI tests for ViewModelViews in FOSMVVM applications.
> For full architecture context, see FOSMVVMArchitecture.md | OpenClaw reference
UI testing in FOSMVVM follows a specific pattern that leverages:
┌─────────────────────────────────────────────────────────────┐
│ UI Test Architecture │
├─────────────────────────────────────────────────────────────┤
│ │
│ Test File (XCTest) App Under Test │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ MyViewUITests │ │ MyView │ │
│ │ │ │ │ │
│ │ presentView() ───┼─────────────►│ Show view with │ │
│ │ with stub VM │ │ stubbed data │ │
│ │ │ │ │ │
│ │ Interact via ────┼─────────────►│ UI elements with │ │
│ │ identifiers │ │ .uiTestingId │ │
│ │ │ │ │ │
│ │ Assert on UI │ │ .testData────────┼──┐ │
│ │ state │ │ Transporter │ │ │
│ │ │ └──────────────────┘ │ │
│ │ viewModelOps() ◄─┼─────────────────────────────────────┘ │
│ │ verify calls │ Stub Operations │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘Every project should have a base test case that inherits from ViewModelViewTestCase:
swiftclass MyAppViewModelViewTestCase<VM: ViewModel, VMO: ViewModelOperations>: ViewModelViewTestCase<VM, VMO>, @unchecked Sendable { @MainActor func presentView( configuration: TestConfiguration, viewModel: VM = .stub(), timeout: TimeInterval = 3 ) throws -> XCUIApplication { try presentView( testConfiguration: configuration.toJSON(), viewModel: viewModel, timeout: timeout ) } override func setUp() async throws { try await super.setUp( bundle: Bundle.main, resourceDirectoryName: "", appBundleIdentifier: "com.example.MyApp" ) continueAfterFailure = false } }
Key points:
ViewModel and ViewModelOperationspresentView() with project-specific configurationcontinueAfterFailure = false stops tests immediately on failureEach ViewModelView gets a corresponding UI test file.
For views WITH operations:
swiftfinal class MyViewUITests: MyAppViewModelViewTestCase<MyViewModel, MyViewOps> { // UI Tests - verify UI state func testButtonEnabled() async throws { let app = try presentView(viewModel: .stub(enabled: true)) XCTAssertTrue(app.myButton.isEnabled) } // Operation Tests - verify operations were called func testButtonTap() async throws { let app = try presentView(configuration: .requireSomeState()) app.myButton.tap() let stubOps = try viewModelOperations() XCTAssertTrue(stubOps.myOperationCalled) } } private extension XCUIApplication { var myButton: XCUIElement { buttons.element(matching: .button, identifier: "myButtonIdentifier") } }
For views WITHOUT operations (display-only):
Use an empty stub operations protocol:
swift// In your test file protocol MyViewStubOps: ViewModelOperations {} struct MyViewStubOpsImpl: MyViewStubOps {} final class MyViewUITests: MyAppViewModelViewTestCase<MyViewModel, MyViewStubOpsImpl> { // UI Tests only - no operation verification func testDisplaysCorrectly() async throws { let app = try presentView(viewModel: .stub(title: "Test")) XCTAssertTrue(app.titleLabel.exists) } }
When to use each:
Common helpers for interacting with UI elements:
swiftextension XCUIElement { var text: String? { value as? String } func typeTextAndWait(_ string: String, timeout: TimeInterval = 2) { typeText(string) _ = wait(for: \.text, toEqual: string, timeout: timeout) } func tapMenu() { if isHittable { tap() } else { coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() } } }
For views WITH operations:
swiftpublic struct MyView: ViewModelView { #if DEBUG @State private var repaintToggle = false #endif private let viewModel: MyViewModel private let operations: MyViewModelOperations public var body: some View { Button(action: doSomething) { Text(viewModel.buttonLabel) } .uiTestingIdentifier("myButtonIdentifier") #if DEBUG .testDataTransporter(viewModelOps: operations, repaintToggle: $repaintToggle) #endif } public init(viewModel: MyViewModel) { self.viewModel = viewModel self.operations = viewModel.operations } private func doSomething() { operations.doSomething() toggleRepaint() } private func toggleRepaint() { #if DEBUG repaintToggle.toggle() #endif } }
For views WITHOUT operations (display-only):
swiftpublic struct MyView: ViewModelView { private let viewModel: MyViewModel public var body: some View { VStack { Text(viewModel.title) Text(viewModel.description) } .uiTestingIdentifier("mainContent") } public init(viewModel: MyViewModel) { self.viewModel = viewModel } }
Critical patterns (for views WITH operations):
@State private var repaintToggle = false for triggering test data transport.testDataTransporter(viewModelOps:repaintToggle:) modifier in DEBUGtoggleRepaint() called after every operation invocationoperations stored as property from viewModel.operationsDisplay-only views:
repaintToggle needed.testDataTransporter() modifier needed.uiTestingIdentifier() to elements you want to testNot all views need ViewModelOperations:
Views that NEED operations:
Views that DON'T NEED operations:
For views without operations:
Create an empty operations file alongside your ViewModel:
swift// MyDisplayViewModelOperations.swift import FOSMVVM import Foundation public protocol MyDisplayViewModelOperations: ViewModelOperations {} #if canImport(SwiftUI) public final class MyDisplayViewStubOps: MyDisplayViewModelOperations, @unchecked Sendable { public init() {} } #endif
Then use it in tests:
swiftfinal class MyDisplayViewUITests: MyAppViewModelViewTestCase< MyDisplayViewModel, MyDisplayViewStubOps > { // Only test UI state, no operation verification }
The view itself doesn't need:
repaintToggle state.testDataTransporter() modifieroperations propertytoggleRepaint() functionJust add .uiTestingIdentifier() to elements you want to verify.
Verify that the UI displays correctly based on ViewModel state:
swiftfunc testButtonDisabledWhenNotReady() async throws { let app = try presentView(viewModel: .stub(ready: false)) XCTAssertFalse(app.submitButton.isEnabled) } func testButtonEnabledWhenReady() async throws { let app = try presentView(viewModel: .stub(ready: true)) XCTAssertTrue(app.submitButton.isEnabled) }
Verify that user interactions invoke the correct operations:
swiftfunc testSubmitButtonInvokesOperation() async throws { let app = try presentView(configuration: .requireAuth()) app.submitButton.tap() let stubOps = try viewModelOperations() XCTAssertTrue(stubOps.submitCalled) XCTAssertFalse(stubOps.cancelCalled) }
Verify navigation flows work correctly:
swiftfunc testNavigationToDetailView() async throws { let app = try presentView() app.itemRow.tap() XCTAssertTrue(app.detailView.exists) }
| File | Location | Purpose | |------|----------|---------| | {ProjectName}ViewModelViewTestCase.swift | Tests/UITests/Support/ | Base test case for all UI tests | | XCUIElement.swift | Tests/UITests/Support/ | Helper extensions for XCUIElement |
| File | Location | Purpose | |------|----------|---------| | {ViewName}ViewModelOperations.swift | Sources/{ViewModelsTarget}/{Feature}/ | Operations protocol and stub (if view has interactions) | | {ViewName}UITests.swift | Tests/UITests/Views/{Feature}/ | UI tests for the view |
Note: Views without user interactions use an empty operations file with just the protocol and minimal stub.
| Placeholder | Description | Example | |-------------|-------------|---------| | {ProjectName} | Your project/app name | MyApp, TaskManager | | {ViewName} | The ViewModelView name (without "View" suffix) | TaskList, Dashboard | | {Feature} | Feature/module grouping | Tasks, Settings |
Invocation: /fosmvvm-ui-tests-generator
Prerequisites:
Workflow integration: This skill is typically used after implementing ViewModelViews. The skill references conversation context automatically—no file paths or Q&A needed. Often follows fosmvvm-swiftui-view-generator or fosmvvm-react-view-generator.
This skill references conversation context to determine test structure:
From conversation context, the skill identifies:
From requirements already in context:
Based on project state:
For the specific view:
Ensure test identifiers and data transport:
.uiTestingIdentifier() on all interactive elements@State private var repaintToggle (if has operations).testDataTransporter() modifier (if has operations)toggleRepaint() calls after operations (if has operations)Skill references information from:
Use TestConfiguration for tests that need specific app state:
swiftfunc testWithSpecificState() async throws { let app = try presentView( configuration: .requireAuth(userId: "123") ) // Test with authenticated state }
Define element accessors in a private extension:
swiftprivate extension XCUIApplication { var submitButton: XCUIElement { buttons.element(matching: .button, identifier: "submitButton") } var cancelButton: XCUIElement { buttons.element(matching: .button, identifier: "cancelButton") } var firstItem: XCUIElement { buttons.element(matching: .button, identifier: "itemButton").firstMatch } }
After user interactions, verify operations were called:
swiftfunc testDecrementButton() async throws { let app = try presentView(configuration: .requireDevice()) app.decrementButton.tap() let stubOps = try viewModelOperations() XCTAssertTrue(stubOps.decrementCalled) XCTAssertFalse(stubOps.incrementCalled) }
Set device orientation in setUp() if needed:
swiftoverride func setUp() async throws { try await super.setUp() #if os(iOS) XCUIDevice.shared.orientation = .portrait #endif }
All views:
.uiTestingIdentifier() on all elements you want to testViews WITH operations (interactive views):
@State private var repaintToggle = false property.testDataTransporter(viewModelOps:repaintToggle:) modifiertoggleRepaint() helper functiontoggleRepaint() called after every operation invocationoperations stored from viewModel.operations in initViews WITHOUT operations (display-only):
repaintToggle needed.testDataTransporter() neededoperations property neededoperations stored from viewModel.operations in initswiftfunc testAsyncOperation() async throws { let app = try presentView() app.loadButton.tap() // Wait for UI to update _ = app.waitForExistence(timeout: 3) let stubOps = try viewModelOperations() XCTAssertTrue(stubOps.loadCalled) }
swiftfunc testFormInput() async throws { let app = try presentView() let emailField = app.emailTextField emailField.tap() emailField.typeTextAndWait("user@example.com") app.submitButton.tap() let stubOps = try viewModelOperations() XCTAssertTrue(stubOps.submitCalled) }
swiftfunc testErrorDisplay() async throws { let app = try presentView(viewModel: .stub(hasError: true)) XCTAssertTrue(app.errorAlert.exists) XCTAssertEqual(app.errorMessage.text, "An error occurred") }
See reference.md for complete file templates.
| Concept | Convention | Example | |---------|------------|---------| | Base test case | {ProjectName}ViewModelViewTestCase | MyAppViewModelViewTestCase | | UI test file | {ViewName}UITests | TaskListViewUITests | | Test method (UI state) | test{Condition} | testButtonEnabled | | Test method (operation) | test{Action} | testSubmitButton | | Element accessor | {elementName} | submitButton, emailTextField | | UI testing identifier | {elementName}Identifier or {elementName} | "submitButton", "emailTextField" |
| Version | Date | Changes | |---------|------|---------| | 1.0 | 2026-01-23 | Initial skill for UI tests | | 1.1 | 2026-01-24 | Update to context-aware approach (remove file-parsing/Q&A). Skill references conversation context instead of asking questions or accepting file paths. |
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-11 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-03 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +55 percentage points is the difference between those two pass rates over the 22 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.