The Ruby community is filled with attempts to use complex solutions to simple problems, often in the interest of making code smaller, tighter, or more convenient, but this is frequently a poor trade. Compactness in exchange for simplicity results in dense, complex code.
We rarely use
random in our code. But as soon as we write tests, suddenly we feel the need to sprinkle random all over.
Faker, for example, is a gem dedicated to generating random test data.
There are some cases where
random can form the basis for a poor man’s QuickCheck, but only in cases where there is a reason to believe that the content could affect the correctness of the code. It’s often easy to spot cases like this, but it is still insufficient. Really, it’s a way to address cases in which the developer ‘games’ the system by writing test data in such a way that the code will work correctly with them. But how often is that really the situation? I assert that it is the exception, not the rule, and that if the correct operation of the code is dependent on the content, then the edge cases should be enumerated by the developer and be displayed as individual tests. Relying on
random to do this for you is much less comprehensive, since it admits the possibility that a single run of the test suite won’t cover the code completely.
The temptation if you want to really write isolated unit tests is to mock in all surrounding objects. This is a losing strategy, since you then are forced into a fool’s choice. One path involves mocking all the objects in the system necessary for the test. This path is fraught with brittleness when those objects change, causing the test to fail and the mocks to have to be changed. This means even small changes cascade through the test scaffolding, meaning small product changes cause large code changes. The other path makes mocks that are permissive, at the expense of tests that do not fail when they should, giving false confidence that the code is working correctly.
Developers burned by this choice shy away from mocks, and attempt to use real objects instead. This creates a new problem, though: how do I create these objects for my tests? The easy answer is factories, but those breed complexity by hiding potentially massive infrastructure to create complex objects, often needlessly.
What are we doing?
Every year that passes that I spend writing code, I understand the aphorisms bequethed to me by the previous generations in a new way.
Code is written first to be read, and only incidentally to be executed.
There are innumerable ways to hide the complexity in the code. Guard statements, mocks, factories, and syntactic sugar of all kinds are great at masking the reality of what the code is doing. The art is in deciding what is safe to ignore, and what is really just masking bad design.
Simple code forces the developer to confront the complexity they’ve created, and syntactic sugar allows the developer to live in denial of that complexity.
What entity in the code is really responsible for making an object easy to test? Do we resort to mocks, declaring boldly that the real object isn’t suitable for testing? Do we delegate the responsibility to factories, hiding how the object is created from the test itself? Perhaps we can bake an ‘identity’ form directly into the object itself, giving access to it everywhere the full blown object could be used.
Making simplicity easily accessible has at least three benefits.
Think about edge cases
‘What happens when the list is empty?’
‘What if the price is 0?’
The most simple form of an object (which I call the ‘identity’ object) can help developers think about degenerate cases when information is missing, and what constitutes correct behavior under those conditions.
Identifying problems in a system that has multiple complex, interconnected components is difficult. If some components can be replaced by their ‘identity’ counterparts, we can isolate problems more quickly.
Establish the Contract
The ‘identity’ object immediately gives developers a second data point when designing their classes. Identifying the correct abstraction is always difficult the first time, often because you only have a single use case in mind: that which serves the needs of the business logic. The identity object, albeit not fleshed out, offers a second data point the developer can use to anchor their contract.
What are identity objects?
Identity objects are mailers that don’t mail, prices that are free, lists containing nothing, and empty diffs. They are the simplest implementation that still satisfies the contract. They are trivial to construct. Since Ruby doesn’t have interfaces, identity objects are the simple objects that still quack like a duck.