Iza Rokita
Posted on March 18, 2021
Time flies, doesn’t it? I remember writing the 1st edition of this article 6 years ago when world haven’t heard about the COVID-19 and I was just an aspiring RoR developer trying to do my best at Railwaymen.
I came a long way to a Team Leader role and certainly learnt a lot since then. Was it possible by following the points I made back then? Certainly they didn’t hurt, but world caught up a bit and now they’re quite obvious. Given that, I hope you’re ready for the next round :).
Listen, that’s great but why exactly should I care?
Over those 6 years I’ve learnt that writing clean code is even more important than I thought initially. I’m not gonna repeat all the points from other blogs about why coding is an art and why you - here, now, - should worry about you 3 months from now. Instead, let’s open quite harsh.
See, business doesn’t care how clean your code is.
Trust me, they hardly even look at it. What they’re looking for is value: the amount of work you can get done in the given time boundaries.
Defining value that way you may find yourself in tough spot, trying to convince business to give you some time for this thing called “refactoring”. Oh no, it doesn’t bring any more value to the project. Maybe it will run faster, maybe not, are you even sure you can measure the difference in the 1st phase of the startup? So you give up and you go back to writing feature after feature, until the code becomes unmaintainable. Then you start struggling to deliver and it’s all going way down from now on. What did you do wrong? How to convince business to give you the time to write your dream clean code? And why you did not get it, but the.. boy scoutes did?
How clean code helps business?
Let’s leave technicalities for a moment. Instead, put yourself in the shoes of the business CEO. You have a vision, you know what you want to build, you know that you have roughly 2 months to reach the market to profit from the niche you found. You quickly find a dev team and you start your first sprint. Things are looking great, you’re moving fast, product is released, you pick up your first customers. With customers inevitably come more use cases so you have to develop even more and even faster. Then, the strangest thing happens: turns out that your development team takes more and more time to deliver. You don’t understand this.
They have all of this building blocks, what’s taking so long?
Well, probably they’re afraid to touch anything because your product is a Jenga tower at this point. Try to move one brick and see all the other ones falling. Software development is not a sprint - it’s more of a marathon. Clean code helps you keep running at steady pace instead of bursting out sprint and then not being able to finish at all.
Now, let's take a look at a real example of a business project that aims to gain new clients. A few weeks ago, we totally changed our website. Of course, it is fitted with a custom admin panel, and developed with RoR. Our team cared about clean code during the development process. Result? The new website is SEO optimized, mobile and user friendly. We already see that users spend more time on it, the traffic is increasing, and it will be much easier to maintain and develop it in the future.
Now that we know what’s in clean code for the business let’s see how the boy scouts rule can help you achieve it.
Always leave the campground cleaner than you found it
See, no-one - literally no-one - writes perfect code at the first try. That’s because the perfect code does not exist. You can’t take a peak into the future of the project and know which business decision your management will take and what tradeoffs you will have to make. That’s why writing clean code is a continuous effort.
Whenever you estimate a task take a moment and look into the code you will have to deal with. Then, include the little refactor you will have to do in the task. It shouldn’t be a big rewrite. Small things really matter.
Extract a method every now and then. Maybe improve variable naming? Perhaps, you can extract this little functionality into its own object and inject it as a collaborator to loose up some coupling? There are many small things that can improve the code dramatically over the time.
Of course, that doesn’t mean that you should not care about your first try. You should always write your best code, but you definitely should keep in mind that not so long from now you will have to extend it or change it to make a business decision happen. I guess, it really just boils down to the one question:
How to write clean code in Ruby?
The clean code is a code you won’t be afraid to change. You shouldn’t fear your own creation. To make that happen, care about your test suite. There are many fantastic resources on how to write good tests, but the most important tip for me was: do not DRY your tests too much.
You know this specs, where you open the file, there’s 20 let statements at the top, specs are one line each and all the setup is done in the before callback? I’m sure you’ve seen them and I’m sure you wrote some of them. I know, because I started exactly the same - chasing the wild goose of DRY-ing my specs to the limits. Now, reflect a little and ask yourself a question - are DRY-ed up specs REALLY more readable and comfortable to work with than more lines but of plain ruby? If you would inherit a project and try to figure how things work, would you rather have a longer spec, but more clear, than a shorter one, but confusing?
I guess we let the code speak for itself here.
RSpec.describe ExampleImportService do
let(:user_to_update_hash) { { email: "test@example.com", first_name: "Jane", last_name: "Doe" } }
let(:new_user_hash) { { email: "test2@example.com", first_name: "Jack", last_name: "Doe" } }
let(:mock_csv_string) { instance_double(String) }
before do
create(:user, email: "test@example.com", first_name: "John", last_name: "Doe")
allow(File).to receive(:read).and_return(mock_csv_string)
allow(CSV).to receive(:parse)
.with(mock_csv_string, headers: true)
.and_return([new_user_hash, user_to_update_hash])
subject.call
end
describe "#call" do
it "creates new user" do
expect(User.find_by(email: "test2@example.com")).to be_present
end
it "updates existing user" do
expect(User.find_by(email: "test@example.com").first_name).to eq("Jane")
end
end
# You could instead write:
describe "#call" do
def mock_import(import_data)
mock_csv_string = instance_double(String)
allow(File).to receive(:read).and_return(mock_csv_string)
allow(CSV).to receive(:parse)
.with(mock_csv_string, headers: true)
.and_return(import_data)
end
it "creates new user" do
new_user_hash = {
email: "test2@example.com",
first_name: "Jack",
last_name: "Doe"
}
service = described_class.new
mock_import([new_user_hash])
service.call
new_user = User.find_by(email: "test2@example.com")
expect(new_user).to be_present
end
it "updates existing user" do
create(:user, email: "test@example.com", first_name: "John", last_name: "Doe")
user_to_update_hash = {
email: "test@example.com",
first_name: "Jane",
last_name: "Doe"
}
service = described_class.new
mock_import([user_to_update_hash])
service.call
updated_user = User.find_by(email: "test@example.com")
expect(updated_user.first_name).to eq("Jane")
end
end
end
How to test cleanness of your Ruby code?
The chances are you’re now staring at the code example above. As you can see, the spec is rather long and there are many things going on. This isn’t necessarily a bad thing, you see. All those objects and mocks where there before (pun intended) - you just have them hidden behind all this RSpec DSL magic. One thing that immediately catches my eye when I’m reading the specs is the amount of setup needed. I don’t mean only the objects, I specifically mean the mocks.
The amount of mocks(allow/expect to receive) is the best sign of the extensibility level of your code. How many objects or classes does your test subject reach for to deliver the end result? Where does this data come from? Is there a HTTP call to the third party API? Why you had no other choice but to mock all of this?
Whenever you find yourself in the need of mocking an object in your spec there’s a chance that you just found your test subject’s collaborator. Collaborator is an object or a class that your subject reaches to get some work done. Common example is third party client, but also things from the rails world like mailers, loggers or even database. Having multiple collaborators is nothing to be ashamed of. The key concept is - can you swap them around? How coupled is your object to its own collaborators?
If you find yourself in the sea of mocks, there’s quite a chance that you’re lacking dependency injection. Dependency injection, put in simple words, is a way to provide collaborators to your object from the outside. It’s easily the most useful thing I learnt on my way. Once you start extracting your collaborators you will find yourself in the possession of many building blocks, like legos.
Then, whenever you’re asked to extend your functionality or add a new one based on it you already have a set of independent objects that you can pick from to build it. Also, you’re specs will look a lot better since you will be able to just inject a testing collaborator into your spec. Why would you mock call to third party API when you can inject collaborator with same interface which will just return a hash? Sure, you can record a cassette with VCR, but is it more readable and extensible than modifying a plain ruby hash? I hardly think so.
RSpec.describe ExampleImportService do
let(:user_to_update_hash) { { email: "test@example.com", first_name: "Jane", last_name: "Doe" } }
let(:new_user_hash) { { email: "test2@example.com", first_name: "Jack", last_name: "Doe" } }
let(:source) { TestSource.new(users: [user_to_update, new_user]) }
subject { described_class.new(source: source) }
before do
create(:user, email: "test@example.com", first_name: "John", last_name: "Doe")
subject.call
end
describe "#call" do
it "creates new user" do
expect(User.find_by(email: "test2@example.com")).to be_present
end
it "updates existing user" do
expect(User.find_by(email: "test@example.com").first_name).to eq("Jane")
end
end
# You could instead write:
describe "#call" do
it "creates new user" do
new_user_hash = {
email: "test2@example.com",
first_name: "Jack",
last_name: "Doe"
}
source = TestSource.new(users: [new_user_hash])
service = described_class.new(source: source)
service.call
new_user = User.find_by(email: "test2@example.com")
expect(new_user).to be_present
end
it "updates existing user" do
create(:user, email: "test@example.com", first_name: "John", last_name: "Doe")
user_to_update_hash = {
email: "test@example.com",
first_name: "Jane",
last_name: "Doe"
}
source = TestSource.new(users: [user_to_update_hash])
service = described_class.new(source: source)
service.call
updated_user = User.find_by(email: "test@example.com")
expect(updated_user.first_name).to eq("Jane")
end
end
end
The tests that keep on giving
The tests are really the best place to spot troubles in your architecture. After all, they illustrate how to use the code you just wrote. That’s why they should be as simple and easy to follow as possible. The amount of warnings and information they can give you just goes on and on. What if you extracted your collaborators, but your setup is still pretty long because your method takes 6 parameters? Might be a sign of primitive obsession.
What if you have multiple contexts checking same scenario but with different kind of values (arrays, hashes, numbers, strings, nils - you name it)? And, worst of all, those checks are nearly in every spec you make? That’s a sign you lack data validation/coercion at system boundary. The list goes on and on.
Conclusion
There is only one thing I’d like you to take away from this article. Writing clean code is a continuous effort. It doesn’t happen at first try, it doesn’t happen overnight. It’s hard to sell to business people, but yet the benefits of it are obvious when they come out. Remember the boy scout rule and do your best to leave your code in better state than the one you found it in. Even if git blame doesn’t point on you yet :).
If you feel that writing a clean code in Ruby on Rails comes easily to you, check our open job positions. It may be a person, we are looking for!
Join our Ruby on Rails Development Team Here!
The article was originally published here. Check our Railwaymen blog to get more resources like that!
Posted on March 18, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.