# Fosmvvm Viewmodel Test Generator

> Generate ViewModel tests with codable round-trip, versioning stability, and multi-locale translation verification.

- Skill: `foscomputerservices/fosmvvm-viewmodel-test-generator` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add foscomputerservices/fosmvvm-viewmodel-test-generator`
- Raw SKILL.md: https://api.skillmd.com/api/skills/foscomputerservices/fosmvvm-viewmodel-test-generator/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: foscomputerservices (https://skillmd.com/u/foscomputerservices)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/foscomputerservices/fosmvvm-viewmodel-test-generator

---


# FOSMVVM ViewModel Test Generator

> **Read [`shared/functional-discipline.md`](../shared/functional-discipline.md) before proceeding.** Every rule below derives from it.

Generate test files for ViewModels following FOSMVVM testing patterns.

## Conceptual Foundation

> For full architecture context, see [FOSMVVMArchitecture.md](../../docs/FOSMVVMArchitecture.md#testing-support) | [OpenClaw reference]({baseDir}/references/FOSMVVMArchitecture.md)

> **API catalog:** check [`../shared/api-catalog/FOSTesting.md`](../shared/api-catalog/FOSTesting.md) § FOSTesting before hand-writing helpers.

ViewModel testing in FOSMVVM verifies three critical aspects:

1. **Codable round-trip** - ViewModel encodes and decodes without data loss
2. **Versioning stability** - Structure hasn't changed unexpectedly
3. **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:

```swift
@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:

```swift
@Test func greetingWithSubstitution() throws {
    try expectFullViewModelTests(GreetingViewModel.self)

    // Verify specific substitution behavior
    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.

---

## Child ViewModels Need Their Own Tests

A parent's round-trip carries its children, so their `Codable` conformance is covered. **Their
translations are not.**

`expectTranslations` walks a single level — `Mirror(reflecting: model)`, inspecting only children
castable to `any Localizable` or `_LocalizedProperty`. A `[ChildViewModel]` is neither (there is no
`Array: Localizable` conformance), and so is a single nested `ViewModel` property. Both are skipped,
with no recursion. Encoding does not catch it either: a missing translation resolves to `""` rather
than throwing.

So a child ViewModel with any localized property gets its own test:

```swift
@Test func secretViewModel() throws {
    try expectFullViewModelTests(SecretViewModel.self)   // the child, on its own
}
```

Testing only the parent leaves every child's YAML unverified in every locale, and it fails silently
— the suite stays green while the Spanish build ships English.

## 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](../shared/architecture-patterns.md); 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:

```swift
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:

```swift
@ViewModel
public struct DashboardViewModel: RequestableViewModel {
    @LocalizedString public var pageTitle      // Needs YAML entry
    @LocalizedString public var emptyMessage   // Needs YAML entry
    public let itemCount: Int                   // No YAML needed
}
```

```yaml
# DashboardViewModel.yml
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:

```swift
@ViewModel
public struct BoardViewModel: RequestableViewModel {
    @LocalizedString public var title
    public let cards: [CardViewModel]  // Child ViewModel
}

@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:

```swift
// In test file
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](reference.md) for complete file templates.

---

## Common Scenarios

### Testing a Single Top-Level ViewModel

```swift
@Test func dashboardViewModel() throws {
    try expectFullViewModelTests(DashboardViewModel.self)
}
```

### Testing Multiple Related ViewModels

```swift
@Test func boardViewModels() throws {
    try expectFullViewModelTests(BoardViewModel.self)
    try expectFullViewModelTests(ColumnViewModel.self)
    try expectFullViewModelTests(CardViewModel.self)
}
```

### Testing with Custom Locales

```swift
var locales: Set<Locale> { [en, es, enGB] }  // Override default

@Test func multiLocaleViewModel() throws {
    try expectFullViewModelTests(MyViewModel.self)
    // Tests en, es, AND en-GB
}
```

### Testing Substitution Behavior

```swift
@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

```swift
@Test func parentWithChildren() throws {
    // Tests parent AND verifies children can be encoded/decoded
    try expectFullViewModelTests(ParentViewModel.self)

    // Optionally verify specific child values
    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:
```yaml
en:
  MyViewModel:
    pageTitle: "Page Title"  # Add this
```

### "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:
```swift
let vm = try .stub().toJSON(encoder: encoder(locale: en)).fromJSON()
#expect(try vm.title.localizedString == "Expected Value")
```

---

## Parallelism and Shared State

Swift Testing runs suites — and the tests inside a suite — in parallel by default.
A suite whose tests touch shared mutable state (a singleton, a `static var`, the
process environment, a fixed-path fixture) carries `.serialized`; the classic race
symptom is a recorded value asserting `0` because another test cleared the state
in between. `.serialized` protects within the suite only — state spanning multiple
suites needs a test-owned gate around the racing window, or dependency injection
so the state stops being shared at all.

## 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

- [FOSMVVMArchitecture.md - Testing Support](../../docs/FOSMVVMArchitecture.md#testing-support) - Architecture overview
- [Architecture Patterns → Encapsulation Is the Precondition](../shared/architecture-patterns.md) — **test the contract, not the representation.** A ViewModel test asserts behavior the contract guarantees (codable round-trip, version stability, correct localized projection, `vmId` equality/stability), never an incidental encoded shape, and never reaches into a sealed value's internals to assert them — that's the encapsulation break from the test side. Repo `CLAUDE.md` → *Encapsulation Is the Precondition SOLID Assumes*.
- [fosmvvm-viewmodel-generator](../fosmvvm-viewmodel-generator/SKILL.md) - For creating ViewModels
- [fosmvvm-fields-generator](../fosmvvm-fields-generator/SKILL.md) - For form validation testing
- [reference.md](reference.md) - Complete file templates

---

## 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) |

