.claude/skills/fosmvvm-ui-tests-generator/SKILL.md
Generate UI tests for FOSMVVM SwiftUI views using XCTest and FOSTestingUI. Covers accessibility identifiers, ViewModelOperations, and test data transport.
npx skillsauth add foscomputerservices/FOSUtilities fosmvvm-ui-tests-generatorInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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 │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
UI tests must follow a strict hierarchy for finding and matching elements. Never use hardcoded display strings.
Use .uiTestingIdentifier() on the view and match via XCUIApplication extension accessors:
// View
Text(viewModel.title)
.uiTestingIdentifier("dashboardTitle")
// Test — accessor uses identifier
private extension XCUIApplication {
var dashboardTitle: XCUIElement {
staticTexts.element(matching: .staticText, identifier: "dashboardTitle")
}
}
// Test — usage
XCTAssertTrue(app.dashboardTitle.exists)
When you must match by display text (e.g., verifying a label's content, or an element that can't carry a unique identifier), use localizedViewModel() to resolve the text from the same source of truth as the UI:
let viewModel: PrimaryParametersViewModel = try localizedViewModel()
let app = try presentView(viewModel: viewModel)
// Match against ViewModel's resolved localized text — never a hardcoded string
XCTAssertTrue(try app.staticTexts[viewModel.amplitudeLabel.localizedString].exists)
XCTAssertEqual(app.stepperValueText.label, try viewModel.value.localizedString)
This keeps tests locale-correct and refactor-safe — if the YAML translation changes, the test still passes because it reads from the same source of truth.
// ❌ WRONG — breaks on locale change, copy change, or duplicate text
XCTAssertTrue(app.staticTexts["Settings"].exists)
XCTAssertEqual(app.label.text, "Welcome back!")
// ✅ RIGHT — Tier 1: identifier
XCTAssertTrue(app.settingsLabel.exists)
// ✅ RIGHT — Tier 2: localized ViewModel
XCTAssertTrue(try app.staticTexts[viewModel.settingsLabel.localizedString].exists)
Every project should have a base test case that inherits from ViewModelViewTestCase:
class 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:
final 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:
// 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:
extension 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:
public 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):
public 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:
// 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:
final 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:
func 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:
func 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:
func 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:
func testWithSpecificState() async throws {
let app = try presentView(
configuration: .requireAuth(userId: "123")
)
// Test with authenticated state
}
Define element accessors in a private extension:
private 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:
func 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:
override 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 initfunc 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)
}
func testFormInput() async throws {
let app = try presentView()
let emailField = app.emailTextField
emailField.tap()
emailField.typeTextAndWait("[email protected]")
app.submitButton.tap()
let stubOps = try viewModelOperations()
XCTAssertTrue(stubOps.submitCalled)
}
func testErrorDisplay() async throws {
let viewModel: MyViewModel = try localizedViewModel(.stub(hasError: true))
let app = try presentView(viewModel: viewModel)
XCTAssertTrue(app.errorAlert.exists)
XCTAssertEqual(app.errorMessage.text, try viewModel.errorMessage.localizedString)
}
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. | | 1.2 | 2026-03-30 | Add Element Matching Rules section (identifier > localizedViewModel > never hardcoded strings). Fix hardcoded string in error state example. |
development
Generate new Claude Code skills following the context-aware pattern. Scaffolds SKILL.md, reference docs, and frontmatter.
testing
Generate ViewModel tests with codable round-trip, versioning stability, and multi-locale translation verification.
data-ai
Generate FOSMVVM ViewModels for SwiftUI screens, pages, and components. Scaffolds RequestableViewModel, localization bindings, and stub factories.
data-ai
Generate SwiftUI views that render FOSMVVM ViewModels. Scaffolds ViewModelView pattern with binding, loading states, and previews.