def unlock_next
books = curriculum
.unlocked_for(student)
books.select(&:unlocked?)
.map(&:id)
end def unlocked_for(student)
eligible(student)
end #select · each Book#unlocked?#id · 12 examples · 3,181 prod runs Enrollment passes a Student and gets back an Array of books. The messages it
then sends to that array and each book in it are the contract, whatever the signature says.
The problem
Ask what Curriculum#unlocked_for returns and you’ll get one of three answers. The code says
“whatever eligible returns”, a type signature (if there is one) says Array, and neither says what
the caller needs. Calling code relies on something much more specific: a collection whose elements
answer #unlocked? and #id.
That specific thing is the contract that matters. It’s what a double has to honour, what a replacement class has to implement, and what a refactor must not break. It’s written down nowhere.
The fix
Your agent asks over MCP before writing a double:
observed_shape("Curriculum#unlocked_for", as: "return")
→ {
return: { class: Array,
responds: [select, each, any?] },
element: { class: Book,
responds: [unlocked?, id, title] },
examples: 12,
prod_runs: 3181
}
In your editor it’s an inlay hint on the call:
def unlock_next = curriculum.unlocked_for(student)
# ⤷ Array<Book> · #unlocked? #id #title
On the command line:
$ ra observed_shape Curriculum#unlocked_for
return → #select #each #any? Array
element → #unlocked? #id #title Book
· from 12 spec examples and 3,181 production runs
Now the double can honour the contract the caller actually relies on:
book = instance_double(Book, unlocked?: true, id: 1, title: "Geometry")
curriculum = instance_double(Curriculum, unlocked_for: [book])
Ask for an argument instead (as: "argument") and you get the messages the method sends to what it
was passed: the interface a caller has to supply.
How it works
The recording keeps the identity of every object a traced method returns. observed_shape follows
that object forward to every later call where it’s the receiver, and collects the messages sent to
it. For a collection, it does the same for the elements that come out of it.
The result has two halves. The messages are the shape your code depends on. The classes are the ones that actually turned up, so you can see when three unrelated classes all satisfy the same shape.
Limits
- Only what ran. A value’s shape is the messages it received in recorded runs. Code paths no spec or run reached contribute nothing.
- Values passed straight through are invisible. If a method returns something that’s handed on untouched and never called, there’s no shape to observe.
- Most reliable close to the call. Following an object within a method is exact. Once it’s been stored and passed around, the messages sent to it may come from code with other expectations.
Related tools
verify_mock
Catch a lying mock. A stub that passes green but returns something the real collaborator never would. Mutation testing can't see it. The recording can.
See more →split_interface
Split a fat interface. A class whose callers each use a different slice of it. It names the role interfaces to split it into, measured from who actually calls what.
See more →how_to_reach
Reach any method. The collaborators and call path that get an object into the state a method needs, taken from a real run. Setup for tests that use real objects instead of doubles.
See more →