| name | arbitrary-usage |
| description | Apply the repo-preferred Arbitrary patterns in cloud-terrastodon. Use this for derive vs #[arbitrary(default)] vs newtype vs manual impl decisions, and for converting brittle tests to arbitrary-sample-plus-targeted-mutation patterns. |
| argument-hint | Type, field, or test area to update, for example: Entra application registration model, constrained name newtype, show-command matching tests |
| user-invocable | true |
Arbitrary Usage
Use this skill when adding or updating arbitrary::Arbitrary support in cloud-terrastodon.
This workflow captures the repo pattern reinforced by recent work on:
- Entra application registrations
- Entra service principals
- Azure App Service show-command tests
What This Produces
Arbitrary implementations that fit the repo's preferred style
- Tests that generate broad arbitrary samples and then mutate only the fields needed for the assertion
- Smaller, cleaner test helpers with less hard-coded JSON
- Better coverage of type surface area without fighting awkward fields like
serde_json::Value
When To Use
- Add
Arbitrary support to a new type in crates/azure_types
- Fix tests that currently depend on large hand-written JSON payloads
- Make matching or filtering tests more realistic without making them brittle
- Decide whether a type should derive
Arbitrary, use #[arbitrary(default)], introduce a newtype, or use a manual impl
Inputs To Gather First
Before editing, identify:
- Which type or tests are being changed
- Whether this is a transport-model problem, a constrained-value problem, or a test-shape problem
- Which exact fields block
#[derive(Arbitrary)], if any
- Whether the test is a behavior test or a deserialization-focused test
Quick Decision Tree
Use this order:
- Try
#[derive(Arbitrary)]
- If only a few awkward JSON or pass-through fields fail, add
#[arbitrary(default)] to those fields
- If the problem is a domain rule on one field, introduce or reuse a focused newtype
- If the type is a constrained name newtype, prefer a custom
Arbitrary impl that generates only valid values
- Use a manual
Arbitrary impl for a larger parent type only when the above options still produce unrealistic or invalid data
Default rules:
- Prefer
#[derive(Arbitrary)] in most cases
- Prefer
#[arbitrary(default)] for spillover JSON like HashMap<String, serde_json::Value> or Option<serde_json::Value>
- Prefer introducing a newtype when only one or two fields need special handling
- Prefer hard-coded JSON only when the real payload shape is what the test is asserting
- Prefer arbitrary sample generation plus targeted mutation for behavior tests
Procedure
1. Start With The Smallest Plausible Change
Try the simplest shape that matches the production type.
Preferred first attempt:
- Add
use arbitrary::Arbitrary;
- Add
#[derive(Arbitrary)]
- Reuse existing repo patterns before inventing a custom generator
Good fit for derive:
- Plain structs and enums whose fields already implement
Arbitrary
- Types already using repo-friendly newtypes
- Test-only helper types with little validation logic
2. Identify Exactly What Blocks Derive
If derive fails, inspect the specific fields that caused it.
Common blockers in this repo:
serde_json::Value
HashMap<String, serde_json::Value>
- Flattened JSON spillover properties
- Constrained string wrappers that cannot accept arbitrary raw strings
Default reaction:
- Do not jump straight to a manual impl for the whole parent type
- First ask whether only a few fields are the problem
3. Use #[arbitrary(default)] For Awkward Fields
When a field exists for tolerance, payload preservation, or pass-through behavior and does not matter to most tests, default it.
Common examples:
#[serde(flatten)] additional-properties maps
- Optional JSON blobs
- Arrays of
serde_json::Value
- Loose payload fragments that are not used by the behavior under test
Pattern:
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Arbitrary)]
pub struct Example {
pub id: MyId,
#[arbitrary(default)]
pub info: Option<serde_json::Value>,
#[serde(flatten)]
#[arbitrary(default)]
pub additional_properties: HashMap<String, serde_json::Value>,
}
Use #[arbitrary(default)] when:
- The field is not central to most tests
- The default value is semantically acceptable
- The field exists mainly for forward compatibility or payload preservation
Do not default a field when:
- The field is central to the behavior under test
- A default value would hide important logic branches
- The field encodes a real project invariant
4. Introduce Newtypes Before Going Manual
If a field has project-specific validity rules, consider a newtype instead of a custom impl on the whole parent type.
This is often the right choice for:
- Name types with length limits
- Name types with restricted characters
- Name types with start or end character rules
- IDs with a stable format that should not stay as
String
Why:
- It localizes the generation problem
- It keeps parent structs derive-friendly
- It makes production code safer, not just tests cleaner
Special rule for constrained name newtypes:
- Once the constrained name newtype exists, prefer a custom
Arbitrary impl on that newtype.
- Do not generate invalid names and hope tests overwrite them later.
- Do not rely on retry-heavy generation from arbitrary raw strings when a direct valid generator is practical.
5. Manually Implement Arbitrary Only When Needed
Use a manual impl as the fallback, not the default.
Justified cases:
- The type has heavy validity constraints that derive cannot express
- Defaulting key fields would make the generated values too unrealistic
- The type needs coordination across multiple fields
- A constrained newtype must generate only values that satisfy naming rules
When writing a manual impl for a constrained name newtype:
- Treat valid production values as a hard requirement, not a best effort goal
- Aim to cover as much of the valid input surface as practical
- Exercise different valid lengths, prefixes, suffixes, and character combinations
- Avoid collapsing to one or two trivial examples
- Prefer generating valid values, not arbitrary strings followed by retry loops everywhere
6. Classify Tests Before Rewriting Them
Choose the test strategy based on what the test is actually proving.
Use arbitrary sample plus targeted mutation when the test is about:
- Matching or filtering behavior
- Branching on one or two fields
- Search keys like ids, names, URIs, hostnames, or IPs
Keep hard-coded JSON when the test is about:
- serde rename behavior
- null-handling behavior
- real payload compatibility
- alternate wire representations
- regression protection against actual Graph or ARM payloads
Do not force arbitrary generation into tests that are really validating deserialization.
7. Treat Behavior Tests As Two-Phase: Generate, Then Shape
For behavior tests, do not depend on the arbitrary sample to already contain the exact field values you want.
Preferred pattern:
- Generate a broad arbitrary sample
- Mutate the field or fields the test cares about
- Assert only the behavior under test
Pattern:
fn sample_thing() -> Thing {
let data = (0u8..=255).cycle().take(4096).collect::<Vec<_>>();
let mut unstructured = Unstructured::new(&data);
Thing::arbitrary(&mut unstructured)
.expect("sample thing should be generated from arbitrary")
}
#[test]
fn matches_by_name() {
let mut thing = sample_thing();
thing.name = "expected-name".to_string();
assert!(matches_thing(&thing, "expected-name"));
}
This is preferred because:
- The sample still exercises the rest of the structure
- The test becomes deterministic
- The assertion stays focused on one branch of behavior
8. Validate At The Right Level
After adding or changing Arbitrary:
- Run the smallest relevant test first
- Then run the most relevant crate-level compile or test command
- If command shapes changed, validate the entrypoint crate and the actual CLI help surface when useful
Useful commands in this repo:
cargo test -p cloud_terrastodon_azure_types --no-run
cargo test -p cloud_terrastodon_entrypoint --no-run
cargo test -p cloud_terrastodon_entrypoint
.\check-all.ps1
Decision Summary
Choose derive
Use #[derive(Arbitrary)] when:
- Most fields already support
Arbitrary
- Problem fields can be defaulted safely
- The type is mostly a transport container
Choose #[arbitrary(default)]
Use it when:
- The field is incidental to most behavior tests
- It is a payload-preservation field
- It is loose JSON that does not deserve a custom generator yet
Choose A Newtype
Introduce or reuse a newtype when:
- The field has naming or format rules in production code
- The same validation concern will recur in multiple places
- Strong typing improves both runtime safety and test generation
Skip a newtype when:
- The field is purely incidental and never reused
- The extra type would add complexity without improving correctness
Choose A Manual Impl
Use a manual Arbitrary impl when:
- The type is a constrained name newtype
- The type must generate only valid constrained values
- Defaulting too many fields would gut the value of generation
- The parent type would otherwise become mostly empty noise
Anti-Patterns
- Do not rely on arbitrary output to accidentally contain the exact id, URI, hostname, IP, or name a test needs.
- Do not hand-write a giant JSON payload for a simple matching test when an arbitrary sample plus one or two mutations would do.
- Do not implement manual
Arbitrary for a large transport struct when only one or two fields are problematic.
- Do not generate invalid constrained names and patch them up later in every caller.
- Do not hide important behavior behind
#[arbitrary(default)] on fields that are central to the logic being tested.
Completion Criteria
A change to Arbitrary usage is complete when all of the following are true:
- The type uses derive by default unless there is a concrete reason not to
- Problematic fields are handled with
#[arbitrary(default)] or focused helper newtypes before considering a manual impl
- Constrained name newtypes have a custom
Arbitrary implementation that generates only valid values
- Any other manual impl is localized and justified by real constraints
- Behavior tests generate an arbitrary sample and then mutate target fields instead of depending on huge JSON fixtures
- Deserialization tests still use realistic payloads where that is the real behavior under test
- The relevant crate compiles and tests pass
Examples
- Add
Arbitrary to an Entra application registration type by deriving it and defaulting JSON spillover fields
- Update a service principal show-command test to generate an arbitrary sample and then set
service_principal_names explicitly
- Create a constrained name newtype with a manual
Arbitrary impl that always emits values valid under the naming rules instead of generating random invalid strings
Case Studies
Use these as concrete repo examples, but keep the inline lesson in mind even if the files evolve later.
1. Derive Plus #[arbitrary(default)] For JSON-Heavy Transport Types
Path:
crates/azure_types/src/application_registration.rs
What this demonstrates:
- A mostly transport-shaped Graph type can still derive
Arbitrary
serde_json::Value fields and flattened spillover maps should usually be handled with #[arbitrary(default)]
- You do not need a manual
Arbitrary impl for the whole type just because a handful of pass-through JSON fields are awkward
Promoted lesson:
- Default the noisy JSON-preservation fields so the rest of the type can stay derive-friendly
2. Apply The Same Pattern Consistently Across Similar Graph Types
Path:
crates/azure_types/src/service_principal.rs
What this demonstrates:
- Another large Graph transport type can use the same approach as application registrations
- Nested credential structs can still derive
Arbitrary while only the serde_json::Value fields need defaults
- Consistency across related types reduces one-off generator code and keeps tests easier to read
Promoted lesson:
- If two transport models have the same problem shape, solve them the same way unless there is a concrete reason not to
3. Arbitrary Sample Helpers Should Be Small And Boring
Path:
crates/entrypoint/src/cli/command/azure/entra/application_registration/azure_entra_application_registration_show.rs
What this demonstrates:
- The sample helper should just generate a broad arbitrary instance from
Unstructured
- The tests then mutate only the target field, such as
identifier_uris or unique_name
- The sample helper should not be doing the test setup work for every scenario
Promoted lesson:
- Keep
sample_* helpers generic; specialize in each test, not in the helper
4. Matching Tests Must Mutate The Field They Intend To Match On
Path:
crates/entrypoint/src/cli/command/azure/entra/service_principal/azure_entra_service_principal_show.rs
What this demonstrates:
- After switching a helper from hard-coded JSON to arbitrary generation, tests can fail if they assume the generated sample already contains the target value
- The fix is to mutate the exact field under test, such as
service_principal_names or app_id
- This keeps the rest of the structure broad and realistic while making the assertion deterministic
Promoted lesson:
- For behavior tests, generate first and then shape the exact match target explicitly
5. The Pattern We Want To Copy For Show-Command Matching Tests
Path:
crates/entrypoint/src/cli/command/azure/app_service/azure_app_service_show.rs
What this demonstrates:
- A good matching test helper generates a broad arbitrary sample once
- Each test then overwrites the exact fields relevant to the scenario, like hostnames or private endpoint data
- This avoids giant JSON fixtures while still exercising the surrounding object shape
Promoted lesson:
- Prefer the two-phase test structure: arbitrary sample first, targeted mutation second
Notes For This Repo
serde_json::Value is usually a signal to try #[arbitrary(default)] first
- Flattened
additional_properties maps should usually default rather than forcing a manual impl for the entire parent type
- Arbitrary-generated test helpers should still be deterministic enough for the assertion by mutating the field under test
- If only one or two fields are difficult, solve those fields specifically instead of hand-writing an Arbitrary impl for a large transport struct
- Prefer production-quality type improvements like newtypes over test-only workarounds when the field truly has domain constraints
Example Prompts
Use the arbitrary-usage skill for Entra application registration Arbitrary support.
Apply arbitrary-usage to convert this show-command matching test away from a hand-written JSON fixture.
Use arbitrary-usage to decide whether this Azure name wrapper should derive Arbitrary or implement it manually.