def unlock_next
curriculum
.next_after(current_book)
.unlock!(student)
end # @return [Book]
def next_after(book)
books[books.index(book) + 1]
end Book, and 3,144 times it was. The other 37 returned NilClass,
never in a spec, and every one of them ended in the same error one line later.
The problem
A few times a day, production raises this:
NoMethodError: undefined method 'unlock!' for nil
app/models/enrollment.rb:4:in 'unlock_next'
The stack trace tells you where the nil was used. It doesn’t tell you where it came from:
class Enrollment
def unlock_next
curriculum.next_after(current_book).unlock!(student)
end
end
class Curriculum
# @return [Book]
def next_after(book)
books[books.index(book) + 1]
end
end
The comment says next_after returns a Book. So does an RBS signature, if you have one, and so does
the name. Nothing checks it at runtime, and every spec for it picks a book from the middle of the
list. The suite is green and the declared type is wrong for exactly one case.
The fix
Ask where the nil came from:
$ ra where_nil_comes_from Enrollment#unlock_next
Curriculum#next_after returned nil
37 of 3,181 production runs · 0 of 12 spec examples
all 37 unwound with NoMethodError in Enrollment#unlock_next (enrollment.rb:4)
otherwise returned Book
next_after returns nil in production and never in a spec. That’s the missing case: the student is
on the last book. Handle it where it happens, and write the spec that was never there:
def unlock_next
next_book = curriculum.next_after(current_book)
return complete! if next_book.nil?
next_book.unlock!(student)
end
it "completes the enrollment after the last book" do
enrollment = build_enrollment(current_book: curriculum.books.last)
expect { enrollment.unlock_next }.to change(enrollment, :completed?).to(true)
end
Run it without a method and you get the list for the whole app: every method that ever returned nil and every argument that ever received it, ranked by how often, with the ones no spec covers first.
How it works
- Classes on every call. Each recorded call carries the classes of its arguments and of what it
returned.
nilis an object like any other, so it’s recorded asNilClass, exactly where it happened. - The exception on unwind. When a traced method raises, the recording keeps the exception’s class
as the call unwinds. That’s how the 37 nil returns are tied to the 37
NoMethodErrors. - Spec or production. Every call tree is stamped with its entry point, so a nil seen only in production, never in a spec, is reported as exactly that.
Limits
- Only nils that cross a traced method. A
nilfromparams[:id]or a hash lookup, used on the next line of the same method, never passes through a traced call, so it isn’t seen. Once it’s returned or passed to one of your methods, it is. - Only what ran. A method that could return nil but never did in any recording isn’t flagged.
- It finds the source, not the intent. Sometimes
nilis the right answer and the caller should handle it; sometimes the method shouldn’t return it. The report shows where; you decide which.
Related tools
how_it_got_here
How execution got here. The real call chains that reach a method, in order, taken from recorded runs. The stack trace you'd have got if it had raised, on demand, for every route in.
See more →what_it_receives
What a method receives. The observed shape of each argument at a call site. The messages the method actually sends to what it's passed, not only the class it was given.
See more →observed_shape
The observed contract. The messages your code actually sends to a value, read from real runs. The interface a call depends on, whether or not anyone declared it.
See more →