| name | fosmvvm-viewmodel-test-generator |
| description | Generate ViewModel tests with codable round-trip, versioning stability, and multi-locale translation verification. |
| homepage | https://github.com/foscomputerservices/FOSUtilities |
| metadata | {"clawdbot":{"emoji":"🔬","os":["darwin","linux"]}} |
FOSMVVM ViewModel Test Generator
Generate test files for ViewModels following FOSMVVM testing patterns.
Conceptual Foundation
For full architecture context, see FOSMVVMArchitecture.md | OpenClaw reference
API catalog: check ../shared/api-catalog/FOSTesting.md § FOSTesting before hand-writing helpers.
ViewModel testing in FOSMVVM verifies three critical aspects:
- Codable round-trip - ViewModel encodes and decodes without data loss
- Versioning stability - Structure hasn't changed unexpectedly
- Multi-locale translations - All
@LocalizedString properties have values in all supported locales
The LocalizableTestCase protocol provides infrastructure that tests all three in a single call.
The version baseline is a COMMITTED artifact (in a downstream app). Versioning
stability works by comparing the ViewModel's current serialization against a stored
baseline JSON ({Name}ViewModel_<version>.json, written beside the test under
Tests/.../.VersionedTestJSON/). For an app that ships a versioned wire contract to
real clients, commit that baseline to git — it is the canary that catches an
accidental wire-shape change across builds. If it isn't committed, it regenerates fresh
every clean build and silently protects nothing. (Different policy for FOSUtilities
itself: its own baselines are regenerable fixtures with no shipped contract — the
version tags are arbitrary — so FOS deliberately git-ignores them except one intentional
serialization canary. Downstream apps are the opposite: commit real baselines.) The
primary expectFullViewModelTests(_:) now forwards #filePath/#line, so the baseline
lands beside your test (under Tests/.../.VersionedTestJSON/) automatically —
commit that file.
When to Use This Skill
- Creating tests for new ViewModels
- Adding test coverage to existing ViewModels
- Verifying localization completeness across locales
- Testing ViewModels with embedded/nested child ViewModels
- Verifying
@LocalizedSubs substitution behavior
What This Skill Generates
| File | Location | Purpose |
|---|
{Name}ViewModelTests.swift | Tests/{Target}Tests/Localization/ | Test suite conforming to LocalizableTestCase |
{Name}ViewModel.yml | Tests/{Target}Tests/TestYAML/ | YAML translations for test (if needed) |
The Testing Pattern
Standard Pattern (Most Tests)
For most ViewModels, a single line provides complete coverage:
@Test func dashboardViewModel() throws {
try expectFullViewModelTests(DashboardViewModel.self)
}
This verifies:
- Codable encoding/decoding
- Versioned ViewModel stability
- Translations exist for all locales (en, es by default)
This is sufficient for the vast majority of ViewModel tests.
Extended Pattern (Specific Formatting Verification)
When testing specific formatting behavior (substitutions, compound strings), add locale-specific assertions:
@Test func greetingWithSubstitution() throws {
try expectFullViewModelTests(GreetingViewModel.self)
let vm: GreetingViewModel = try .stub()
.toJSON(encoder: encoder(locale: en))
.fromJSON()
#expect(try vm.welcomeMessage.localizedString == "Welcome, John!")
}
This is optional - use only when verifying specific formatting techniques.
Testing Discipline: Contract, Not Representation
The expect* helpers verify behavior the contract guarantees — Codable round-trip, version stability, translations exist. Keep any added assertions at that same altitude. (Background: Architecture Patterns → Encapsulation Is the Precondition; repo CLAUDE.md → Encapsulation Is the Precondition SOLID Assumes.)
- Never assert the encoded representation. Don't inspect the raw JSON for specific keys/shape (
obj["someKey"], "encodes as a bare string", exact byte layout, key order). There is no contract that a type encodes a particular way — only that it round-trips. Assert round-trip identity-preservation and behavior, never the shape. A representation assertion freezes an implementation detail and breaks on a harmless format change.
- Construct values the intended way; don't bypass a type's construction contract. Build instances with
.stub(), public inits, and the try value.toJSON(encoder:).fromJSON() round-trip. @testable import here exists to see internal ViewModel types and test infrastructure — it is not a license to reach a value's private/internal init or getter to fabricate or inspect state the public contract doesn't offer. @testable/private access is legitimate only for block/arc coverage, never for contract coverage. If a value type seals its backing (no public accessor), assert via == / hashValue / Comparable, not by reading internals.
expectVersionedViewModel is decode-only — never "regenerate" baselines to make a change pass. It writes a baseline once (if absent), then only re-decodes every committed version to prove old encodings still load; it does not encode-diff, and it skips ClientHostedViewModelFactory types. So a schema change must stay backward-decodable (add fields, don't rename/remove). Deleting the committed .VersionedTestJSON files to "refresh" them destroys the historical versions that are the entire point — don't. New fields are additive; old baselines must keep decoding.
- Date precision gotcha. FOSMVVM's canonical date format is millisecond precision, so a freshly-created
Date/timestamp is not equal to itself after a round-trip (sub-ms is truncated). Don't assert in-memory Date equality across encode→decode; compare via behavior/ordering, or start from an already-canonical fixture.
LocalizableTestCase Protocol
Test suites conform to LocalizableTestCase to access testing infrastructure:
import FOSFoundation
@testable import FOSMVVM
import FOSTesting
import Foundation
import Testing
@testable import {ViewModelsTarget}
@Suite("My ViewModel Tests")
struct MyViewModelTests: LocalizableTestCase {
let locStore: LocalizationStore
init() throws {
self.locStore = try Self.loadLocalizationStore(
bundle: {ViewModelsTarget}.resourceAccess,
resourceDirectoryName: ""
)
}
}
The {ViewModelsTarget}.resourceAccess is the resource accessor defined when creating the ViewModels SPM target (via FOSResourceAccessor build tool plugin).
What LocalizableTestCase Provides
| Property/Method | Purpose |
|---|
locStore | Required - the localization store |
locales | Optional - locales to test (default: en, es) |
encoder(locale:) | Creates a localizing JSONEncoder |
en, es, enGB, enUS | Locale constants |
Testing Methods
| Method | Use When |
|---|
expectFullViewModelTests(_:) | Primary - complete ViewModel testing |
expectTranslations(_:) | Translation-only verification |
expectFullFieldValidationModelTests(_:) | Testing FieldValidationModel types |
expectFullFormFieldTests(_:) | Testing FormField instances |
expectCodable(_:encoder:) | Codable round-trip only |
expectVersionedViewModel(_:encoder:) | Versioning stability only |
YAML Requirements
ViewModels with @LocalizedString
Every ViewModel with @LocalizedString properties needs YAML entries:
@ViewModel
public struct DashboardViewModel: RequestableViewModel {
@LocalizedString public var pageTitle
@LocalizedString public var emptyMessage
public let itemCount: Int
}
en:
DashboardViewModel:
pageTitle: "Dashboard"
emptyMessage: "No items yet"
es:
DashboardViewModel:
pageTitle: "Tablero"
emptyMessage: "No hay elementos todavía"
Embedded ViewModels
When a ViewModel contains child ViewModels, all types in the hierarchy need YAML entries:
@ViewModel
public struct BoardViewModel: RequestableViewModel {
@LocalizedString public var title
public let cards: [CardViewModel]
}
@ViewModel
public struct CardViewModel {
@LocalizedString public var cardTitle
}
Both BoardViewModel and CardViewModel need YAML entries (can be in same or separate files).
Private Test ViewModels
When tests define private ViewModel structs for testing specific scenarios, those also need YAML:
private struct TestParentViewModel: ViewModel {
@LocalizedString var title
let children: [TestChildViewModel]
}
private struct TestChildViewModel: ViewModel {
@LocalizedString var label
}
Add entries to a test YAML file for these private types.
How to Use This Skill
Invocation:
/fosmvvm-viewmodel-test-generator
Prerequisites:
- ViewModel structure understood from conversation context
- Localization properties identified (@LocalizedString, @LocalizedSubs, etc.)
- YAML localization files exist or will be created
- Child ViewModels identified (if any)
Workflow integration:
This skill is used when adding test coverage for ViewModels. The skill references conversation context automatically—no file paths or Q&A needed. Typically follows fosmvvm-viewmodel-generator.
Pattern Implementation
This skill references conversation context to determine test structure:
ViewModel Analysis
From conversation context, the skill identifies:
- ViewModels to test (from prior discussion or codebase)
- Localization requirements (@LocalizedString properties)
- Child ViewModels (embedded within parent)
- Substitution behavior (@LocalizedSubs needing specific verification)
YAML Coverage Check
Verifies completeness:
- ViewModel YAML entries (all @LocalizedString properties)
- Child ViewModel entries (nested types)
- Locale coverage (en, es, or project-specific locales)
Test File Generation
Creates test suite with:
- LocalizableTestCase conformance
- Localization store initialization
- expectFullViewModelTests() calls for each ViewModel
- Optional specific formatting tests (substitutions, compound strings)
Context Sources
Skill references information from:
- Prior conversation: ViewModels discussed or recently created
- ViewModel code: If Claude has read ViewModel files into context
- YAML files: From codebase analysis of existing localizations
- Test patterns: From existing test files in project
File Templates
See reference.md for complete file templates.
Common Scenarios
Testing a Single Top-Level ViewModel
@Test func dashboardViewModel() throws {
try expectFullViewModelTests(DashboardViewModel.self)
}
Testing Multiple Related ViewModels
@Test func boardViewModels() throws {
try expectFullViewModelTests(BoardViewModel.self)
try expectFullViewModelTests(ColumnViewModel.self)
try expectFullViewModelTests(CardViewModel.self)
}
Testing with Custom Locales
var locales: Set<Locale> { [en, es, enGB] }
@Test func multiLocaleViewModel() throws {
try expectFullViewModelTests(MyViewModel.self)
}
Testing Substitution Behavior
@Test func greetingSubstitutions() throws {
try expectFullViewModelTests(GreetingViewModel.self)
let vm: GreetingViewModel = try .stub(userName: "Alice")
.toJSON(encoder: encoder(locale: en))
.fromJSON()
#expect(try vm.welcomeMessage.localizedString == "Welcome, Alice!")
}
Testing Embedded ViewModels
@Test func parentWithChildren() throws {
try expectFullViewModelTests(ParentViewModel.self)
let vm: ParentViewModel = try .stub()
.toJSON(encoder: encoder(locale: en))
.fromJSON()
#expect(try vm.children[0].label.localizedString == "Child 1")
}
Troubleshooting
"Missing Translation" Error
FOSLocalizableError: _pageTitle -- Missing Translation -- en
Cause: YAML entry missing for a @LocalizedString property.
Fix: Add the property to the YAML file:
en:
MyViewModel:
pageTitle: "Page Title"
"Is pending localization" Error
Cause: The ViewModel wasn't encoded with a localizing encoder.
Fix: Ensure using encoder(locale:) or expectFullViewModelTests().
Test Passes But Translations Seem Wrong
Cause: YAML values exist but may have typos or wrong content.
Fix: Add specific assertions to verify exact values:
let vm = try .stub().toJSON(encoder: encoder(locale: en)).fromJSON()
#expect(try vm.title.localizedString == "Expected Value")
Naming Conventions
| Concept | Convention | Example |
|---|
| Test suite | {Feature}ViewModelTests | DashboardViewModelTests |
| Test file | {Feature}ViewModelTests.swift | DashboardViewModelTests.swift |
| YAML file | {ViewModelName}.yml | DashboardViewModel.yml |
| Test method | {viewModelName}() or descriptive | dashboardViewModel() |
See Also
Version History
| Version | Date | Changes |
|---|
| 1.0 | 2025-01-02 | Initial skill |
| 1.1 | 2026-01-19 | Updated LocalizableTestCase example to use {ViewModelsTarget}.resourceAccess pattern. |
| 1.2 | 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.3 | 2026-07-02 | Note the version baseline is a committed artifact for downstream apps (FOS's own baselines are regenerable/git-ignored fixtures — different policy); expectFullViewModelTests(_:) now forwards #filePath/#line so the baseline lands beside the caller's test. (backlog B7) |