|
| 1 | +# Updating Model Unit Tests |
| 2 | + |
| 3 | +## Context |
| 4 | + |
| 5 | +Model unit test files in `spec/unit/models/` are auto-generated by the `openapi-generator-cli` (`rake generate` or equivalent). The generator output is a *starting point* — every spec must be updated to match the template described here. |
| 6 | + |
| 7 | +This doc is the source of truth for that update step. Follow it exactly when bringing a fresh-generated spec into compliance. |
| 8 | + |
| 9 | +## Workflow |
| 10 | + |
| 11 | +1. Auto-generate the SDK (writes specs to `spec/unit/models/`). |
| 12 | +2. For each spec under `spec/unit/models/`, pick the matching template: |
| 13 | + - [Standard Model Template](#standard-model-template) — regular models (the model file defines a `class`) |
| 14 | + - [Enum Model Template](#enum-model-template) — pure enum models (the model file defines a class with `all_vars` and frozen string constants only) |
| 15 | + - [oneOf Model Template](#oneof-model-template) — discriminator-based oneOf models (the model file defines a `module`, not a `class`) |
| 16 | +3. Run `rake unit` and confirm `0 failures`. |
| 17 | +4. Check coverage didn't regress meaningfully (see [Coverage notes](#coverage-notes)). |
| 18 | + |
| 19 | +## Standard Model Template |
| 20 | + |
| 21 | +Apply to any spec representing a regular model (object with attributes), e.g. `message_spec.rb`, `call_state_spec.rb`, `verify_code_request_spec.rb`. |
| 22 | + |
| 23 | +### Required structure |
| 24 | + |
| 25 | +```ruby |
| 26 | +describe Bandwidth::ModelName do |
| 27 | + let(:model_name_default) { Bandwidth::ModelName.new } |
| 28 | + let(:model_name_values) { Bandwidth::ModelName.new({ <all attrs populated> }) } |
| 29 | + |
| 30 | + describe '#initialize' do |
| 31 | + it 'causes an ArgumentError by passing an Array to the initialize method' |
| 32 | + it 'causes an ArgumentError by passing an invalid attribute to the initialize method' |
| 33 | + end |
| 34 | + |
| 35 | + describe '#acceptable_attributes' do |
| 36 | + it 'expects acceptable JSON attributes to be those in the attribute map' |
| 37 | + end |
| 38 | + |
| 39 | + describe '#openapi_nullable' do |
| 40 | + # If model has nullable attrs, assert the exact Set. Otherwise assert empty Set. |
| 41 | + it 'expects nullable attributes to be ...' |
| 42 | + end |
| 43 | + |
| 44 | + describe '#build_from_hash' do |
| 45 | + # camelCase keys in, snake_case attr reads out, instance check. |
| 46 | + it 'validates instance of ModelName created by the build_from_hash method' |
| 47 | + end |
| 48 | + |
| 49 | + describe '#to_s' do |
| 50 | + # Use the *populated* values instance, not the empty default. |
| 51 | + it 'returns a string representation of the object' |
| 52 | + end |
| 53 | + |
| 54 | + describe '#eq? #==' do |
| 55 | + # Assert both equality AND inequality. |
| 56 | + it 'returns true/false when comparing objects' |
| 57 | + end |
| 58 | + |
| 59 | + describe '#to_body #to_hash' do |
| 60 | + it 'returns a hash representation of the object' |
| 61 | + end |
| 62 | + |
| 63 | + # Only when the model has validated attrs (nil/pattern/min/max). |
| 64 | + describe 'custom attribute writers' do |
| 65 | + it '#attr=' do ... end |
| 66 | + end |
| 67 | +end |
| 68 | +``` |
| 69 | + |
| 70 | +### What to drop from the auto-generated spec |
| 71 | + |
| 72 | +- **`#hash` block.** Asserting `obj.hash.is_a?(Integer)` is testing Ruby itself. No signal. |
| 73 | +- **Empty `enum validation` block** (e.g. `describe 'enum validation' do; it 'works' do; end; end`). Pure noise — passes while testing nothing. |
| 74 | +- **`EnumAttributeValidator` block.** It tests generator scaffolding with fake allow-lists (`['valid']`, `[1]`), not the model's actual enums. The customer-facing behavior — "does an invalid enum value raise when set on a model?" — belongs in custom attribute writer tests, where it actually exercises the model. |
| 75 | + |
| 76 | +### What to add when missing |
| 77 | + |
| 78 | +- **`_default` let.** Pair `_values` with a default instance so `#eq?` can assert both equal-to-equal and equal-to-different. For models with required (non-nil-validated) attrs, build `_default` with only the required attrs populated. |
| 79 | +- **`#openapi_nullable` block.** Always include, even if the nullable set is empty. Read the model's `self.openapi_nullable` to get the exact set. |
| 80 | +- **Custom attribute writer tests.** One `it` per validated attribute. Cover every validation the model performs: |
| 81 | + - `nil` check → `'<attr> cannot be nil'` |
| 82 | + - pattern → `'invalid value for "<attr>", must conform to the pattern ...'` |
| 83 | + - min/max length → `'invalid value for "<attr>", the character length must be ...'` |
| 84 | + - min/max value → `'invalid value for "<attr>", must be smaller/greater than or equal to ...'` |
| 85 | + - enum allow-list → `'invalid value for "<attr>", must be one of ...'` |
| 86 | + |
| 87 | +### What to fix when incorrect |
| 88 | + |
| 89 | +- **`#to_s` using the empty `_default` instance.** Switch to `_values` so the assertion is meaningful (asserts `'{}'` proves nothing). |
| 90 | +- **`#eq?` only checking equality.** Update to assert both `_default.eql?(equal_instance)` is true AND `_default.eql?(_values)` is false. |
| 91 | + |
| 92 | +## Enum Model Template |
| 93 | + |
| 94 | +Apply to pure enum models, e.g. `call_state_enum_spec.rb`. |
| 95 | + |
| 96 | +```ruby |
| 97 | +describe Bandwidth::SomeEnum do |
| 98 | + describe 'constants' do |
| 99 | + it 'defines VALUE_NAME' do |
| 100 | + expect(Bandwidth::SomeEnum::VALUE_NAME).to eq('value_string') |
| 101 | + end |
| 102 | + # ...one per enum constant |
| 103 | + end |
| 104 | + |
| 105 | + describe '.all_vars' do |
| 106 | + it 'returns every valid enum value' do |
| 107 | + expect(Bandwidth::SomeEnum.all_vars).to eq([<every value in order>]) |
| 108 | + end |
| 109 | + end |
| 110 | + |
| 111 | + describe '.build_from_hash' do |
| 112 | + it 'returns the value when it matches a valid enum value' do |
| 113 | + expect(Bandwidth::SomeEnum.build_from_hash('valid_value')).to eq('valid_value') |
| 114 | + # ...one per valid value |
| 115 | + end |
| 116 | + |
| 117 | + it 'raises an error for an invalid enum value' do |
| 118 | + expect { Bandwidth::SomeEnum.build_from_hash('invalid') }.to raise_error(RuntimeError) |
| 119 | + end |
| 120 | + end |
| 121 | +end |
| 122 | +``` |
| 123 | + |
| 124 | +### What to drop |
| 125 | + |
| 126 | +- Instance-construction tests (`Bandwidth::SomeEnum.new` then `be_instance_of`). The enum module isn't meant to be instantiated for use — only `build_from_hash` matters. |
| 127 | + |
| 128 | +## oneOf Model Template |
| 129 | + |
| 130 | +Apply to discriminator-based oneOf models, e.g. `callback_spec.rb`. These models are defined as Ruby `module`s (not classes) and act as routers — given a payload with a discriminator field, they dispatch to one of several target classes via `.build`. |
| 131 | + |
| 132 | +You can identify a oneOf model by inspecting `lib/bandwidth-sdk/models/<name>.rb`: it will be a `module` exposing `openapi_one_of`, `openapi_discriminator_name`, `openapi_discriminator_mapping`, and `build` as class methods. |
| 133 | + |
| 134 | +```ruby |
| 135 | +describe Bandwidth::SomeOneOf do |
| 136 | + describe '.openapi_one_of' do |
| 137 | + it 'lists the classes defined in oneOf' do |
| 138 | + expect(Bandwidth::SomeOneOf.openapi_one_of).to eq([ |
| 139 | + :'ClassA', |
| 140 | + :'ClassB' |
| 141 | + ]) |
| 142 | + end |
| 143 | + end |
| 144 | + |
| 145 | + describe '.openapi_discriminator_name' do |
| 146 | + it 'returns the discriminator property name' do |
| 147 | + expect(Bandwidth::SomeOneOf.openapi_discriminator_name).to eq(:'type') |
| 148 | + end |
| 149 | + end |
| 150 | + |
| 151 | + describe '.openapi_discriminator_mapping' do |
| 152 | + it 'maps every discriminator value to a oneOf class' do |
| 153 | + expect(Bandwidth::SomeOneOf.openapi_discriminator_mapping).to eq({ |
| 154 | + :'discriminator-value-1' => :'ClassA', |
| 155 | + :'discriminator-value-2' => :'ClassB' |
| 156 | + }) |
| 157 | + end |
| 158 | + |
| 159 | + it 'maps only to classes listed in openapi_one_of' do |
| 160 | + mapping_targets = Bandwidth::SomeOneOf.openapi_discriminator_mapping.values.uniq.sort |
| 161 | + expect(mapping_targets).to eq(Bandwidth::SomeOneOf.openapi_one_of.sort) |
| 162 | + end |
| 163 | + end |
| 164 | + |
| 165 | + describe '.build' do |
| 166 | + # Stub build_from_hash on each target class so we test the routing without |
| 167 | + # depending on the target's construction logic (which is covered by its own spec). |
| 168 | + it 'routes <ClassA> discriminator values to ClassA.build_from_hash' do |
| 169 | + Bandwidth::SomeOneOf.openapi_discriminator_mapping.each do |discriminator, klass| |
| 170 | + next unless klass == :'ClassA' |
| 171 | + data = { type: discriminator.to_s } |
| 172 | + expect(Bandwidth::ClassA).to receive(:build_from_hash).with(data).and_return(:class_a_result) |
| 173 | + expect(Bandwidth::SomeOneOf.build(data)).to eq(:class_a_result) |
| 174 | + end |
| 175 | + end |
| 176 | + |
| 177 | + # ...one block per oneOf target class |
| 178 | + |
| 179 | + it 'returns nil when the discriminator value is missing' do |
| 180 | + expect(Bandwidth::SomeOneOf.build({})).to be_nil |
| 181 | + end |
| 182 | + |
| 183 | + it 'returns nil when the discriminator value does not match any mapping' do |
| 184 | + expect(Bandwidth::SomeOneOf.build({ type: 'unknown' })).to be_nil |
| 185 | + end |
| 186 | + end |
| 187 | +end |
| 188 | +``` |
| 189 | + |
| 190 | +### Required structure |
| 191 | + |
| 192 | +- `.openapi_one_of` — assert the exact list of target class symbols |
| 193 | +- `.openapi_discriminator_name` — assert the exact discriminator property symbol |
| 194 | +- `.openapi_discriminator_mapping` — assert the exact mapping AND assert it only maps to classes in `openapi_one_of` |
| 195 | +- `.build` — one block per target class verifying routing (via stubbed `build_from_hash`), plus the two nil cases (missing discriminator, unknown discriminator value) |
| 196 | + |
| 197 | +### What to drop from the auto-generated spec |
| 198 | + |
| 199 | +- **Weak `not_to be_empty` assertions.** Replace with exact equality checks against the model's actual values. |
| 200 | +- **`mapping.values.sort == one_of.sort` assertion.** This is buggy in the generator output — `mapping.values` has duplicates when multiple discriminator values route to the same class. Use `.uniq.sort` instead. |
| 201 | +- **Empty `.build` describe block.** Replace with the stub-based routing tests above. |
| 202 | + |
| 203 | +### Why stub `build_from_hash`? |
| 204 | + |
| 205 | +oneOf targets often have many required attributes (and nested required models), making it tedious to construct valid data for every discriminator value. Stubbing isolates the test to what `.build` is actually doing — routing based on a discriminator. The target class's `build_from_hash` is covered by that class's own spec. |
| 206 | + |
| 207 | +### What to skip |
| 208 | + |
| 209 | +- `#initialize`, `#openapi_nullable`, `#to_s`, `#eq?`, `#to_body`, custom attribute writers — these don't apply. The oneOf model is a `module`, not an instantiable class. |
| 210 | + |
| 211 | +### Variant: discriminator-based `openapi_any_of` |
| 212 | + |
| 213 | +Some modules expose `openapi_any_of` instead of `openapi_one_of` but otherwise have the identical shape (same `openapi_discriminator_name`, `openapi_discriminator_mapping`, and `build` implementation). Treat them exactly like the oneOf template above — just substitute `openapi_any_of` for `openapi_one_of` everywhere (describe block name, assertion target, the `uniq.sort` cross-check). |
| 214 | + |
| 215 | +Example: `multi_channel_action_spec.rb` (the model is `lib/bandwidth-sdk/models/multi_channel_action.rb`). |
| 216 | + |
| 217 | +### Variant: no-discriminator `openapi_one_of` |
| 218 | + |
| 219 | +Some `oneOf` modules expose `openapi_one_of` but **no** discriminator (no `openapi_discriminator_name`/`openapi_discriminator_mapping`). Instead, `build(data)` iterates `openapi_one_of` and uses a private `find_and_cast_into_type` helper to attempt deserialization into each target class in order, returning the first match (or `nil` if none match). |
| 220 | + |
| 221 | +You **cannot use the stubbed-routing pattern** for these — `find_and_cast_into_type` does a pre-flight check against the target's `acceptable_attributes` before calling `build_from_hash`, so a naked stub of `build_from_hash` is bypassed. Use real payloads instead, sized to match one target's attribute shape. |
| 222 | + |
| 223 | +```ruby |
| 224 | +describe Bandwidth::SomeNoDiscriminatorOneOf do |
| 225 | + describe '.openapi_one_of' do |
| 226 | + it 'lists the classes defined in oneOf' do |
| 227 | + expect(Bandwidth::SomeNoDiscriminatorOneOf.openapi_one_of).to eq([ |
| 228 | + :'ClassA', |
| 229 | + :'ClassB' |
| 230 | + ]) |
| 231 | + end |
| 232 | + end |
| 233 | + |
| 234 | + describe '.build' do |
| 235 | + it 'routes payloads matching ClassA attributes to ClassA' do |
| 236 | + data = { someClassAAttr: 'value' } |
| 237 | + expect(Bandwidth::SomeNoDiscriminatorOneOf.build(data)).to be_instance_of(Bandwidth::ClassA) |
| 238 | + end |
| 239 | + |
| 240 | + it 'routes payloads matching ClassB attributes to ClassB' do |
| 241 | + data = { someClassBAttr: 'value' } |
| 242 | + expect(Bandwidth::SomeNoDiscriminatorOneOf.build(data)).to be_instance_of(Bandwidth::ClassB) |
| 243 | + end |
| 244 | + |
| 245 | + it 'returns nil when the payload does not match any oneOf schema' do |
| 246 | + expect(Bandwidth::SomeNoDiscriminatorOneOf.build({ unknown: 'value' })).to be_nil |
| 247 | + end |
| 248 | + end |
| 249 | +end |
| 250 | +``` |
| 251 | + |
| 252 | +Pick payload attributes that exist on only one target so `find_and_cast_into_type`'s `acceptable_attributes` check disambiguates correctly. Skip `.openapi_discriminator_name` and `.openapi_discriminator_mapping` blocks — those methods don't exist on these modules. |
| 253 | + |
| 254 | +Example: `rbm_message_content_rich_card_spec.rb`. |
| 255 | + |
| 256 | +## Gotchas |
| 257 | + |
| 258 | +### Writers unreachable through `Model.new` |
| 259 | + |
| 260 | +Some validated setters (notably `to=` and `media=` on `Bandwidth::Message`) only get invoked from `initialize` when the input is an Array. Passing `nil` to `Model.new` won't trigger the writer's nil check. |
| 261 | + |
| 262 | +**Fix:** call the writer directly on an instance. |
| 263 | + |
| 264 | +```ruby |
| 265 | +# Wrong — won't raise: |
| 266 | +expect { Bandwidth::Message.new({ to: nil }) }.to raise_error(ArgumentError, 'to cannot be nil') |
| 267 | + |
| 268 | +# Right — exercises the writer: |
| 269 | +expect { message_values.to = nil }.to raise_error(ArgumentError, 'to cannot be nil') |
| 270 | +``` |
| 271 | + |
| 272 | +When in doubt, read the model's `initialize` and check whether `self.<attr> =` is gated behind a type check. |
| 273 | + |
| 274 | +### `#build_from_hash` needs full required-attr data for nested models |
| 275 | + |
| 276 | +When a model has an attribute typed as another model (e.g., `:'business_address' => :'Address'`), `build_from_hash` recursively deserializes that nested hash into a real `Address` instance. That instance's `initialize` runs all required-attr setters, so any missing required attr raises `ArgumentError`. |
| 277 | + |
| 278 | +**This bites only in the `#build_from_hash` test, not `#to_s` / `#to_body` / `_values`** — when you pass a nested hash to `Model.new` directly (via `_values`), the parent's `attr_accessor` just stores the hash verbatim; no recursive deserialization happens. |
| 279 | + |
| 280 | +```ruby |
| 281 | +# Wrong — raises ArgumentError: 'addr1 cannot be nil' inside Address.new: |
| 282 | +Bandwidth::VerificationRequest.build_from_hash({ |
| 283 | + businessAddress: { name: 'Bandwidth' }, # Address requires addr1, city, state, zip, url |
| 284 | + # ... |
| 285 | +}) |
| 286 | + |
| 287 | +# Right — full required-attr data for the nested Address: |
| 288 | +Bandwidth::VerificationRequest.build_from_hash({ |
| 289 | + businessAddress: { name: 'Bandwidth', addr1: '900 Main Campus Dr', city: 'Raleigh', state: 'NC', zip: '27606', url: 'https://www.bandwidth.com' }, |
| 290 | + # ... |
| 291 | +}) |
| 292 | +``` |
| 293 | + |
| 294 | +Apply this recursively — if the nested model itself has nested models with required attrs, fill those too. Check the nested model's setters (`def <attr>=` blocks raising `'<attr> cannot be nil'`) to know which attrs are required. |
| 295 | + |
| 296 | +### `#to_s` is sensitive to attribute order |
| 297 | + |
| 298 | +The expected string must match the exact order of attributes as serialized by `to_hash`. Generate the expected string by running the populated instance through `to_s` in a console and pasting the result. Don't hand-craft it. |
| 299 | + |
| 300 | +### Nullable attribute symbols |
| 301 | + |
| 302 | +`openapi_nullable` returns a `Set` of `Symbol`s using snake_case attribute names with a leading colon-quote (e.g. `:'parent_call_id'`). Mirror the model's `openapi_nullable` definition exactly. |
| 303 | + |
| 304 | +## Coverage notes |
| 305 | + |
| 306 | +Dropping `EnumAttributeValidator` blocks reduces line coverage slightly, because the nested `EnumAttributeValidator` class isn't exercised elsewhere. Two options: |
| 307 | + |
| 308 | +1. **Accept the dip.** That code is generator scaffolding; coverage of it is low-value. |
| 309 | +2. **Add an enum-validated writer test** for at least one attribute per model with enums (e.g. `Bandwidth::Message.new(direction: 'invalid')` should raise). This exercises `EnumAttributeValidator` through a real path and recovers the coverage. |
| 310 | + |
| 311 | +The coverage logic itself is being updated as part of expanding to all models — don't fight stale thresholds. |
| 312 | + |
| 313 | +## Verification |
| 314 | + |
| 315 | +```sh |
| 316 | +rake unit |
| 317 | +``` |
| 318 | + |
| 319 | +Confirm `0 failures` before considering a spec done. |
0 commit comments