- Ruby on Rails
- Software Development
Embrace Failure with Test Driven Development
A practical guide to help you get into the test-driven development mindset in Ruby on Rails with RSpec
Note: This article is a re-post of an I article I published on Medium
What is Test Driven Development?
In short, test drive development or more commonly known as TDD is a development methodology by which test code with certain output expectations is written to fail and developers would work towards getting them to pass by writing the necessary code to produce the expected output.
Writing tests is a waste of time
That’s what I thought when I first learned to write tests. I was a freelance developer when I started. I was time-strapped and the last thing I needed was more work to get things done. Writing tests to me wasn’t only a concept that was hard to grasp, it was taking twice as much effort for me to get anything done. So, why do developers even write tests?
It wasn’t until I started building large applications with a team of engineers that I finally grasped the essence and importance of writing tests.
Start with why
Simon Sinek in his book “Start with Why” explains it best with “The Golden Circle”. There needs to be a pull factor that gives us enough reason to adopt a new habit or process. Here are 3 key reasons why:
1. Refactor with confidence
Let’s forget about programming for a second and pretend you’re a contractor tasked to make changes to an existing building. You removed a pillar and the next thing you know, the building completely collapses before your eyes. Had you known the pillar was one of the core supporting structures that others depend on, your building would still be standing.
Code has dependencies just like the supporting pillars in the building. Structures rely on one another to keep your application working. As developers, we constantly need to refactor our code. Having tests around the core parts of our application serves as a guard against breaking changes that can be fatal.
2. Code with clarity
It’s easy to get lost in the weeds once you’re knee-deep into building out a feature. Without a clear plan of execution, often times we miss out on scenarios or contexts that need to be fulfilled when building a feature.
Writing tests first offers us the opportunity to take a step back and “think” through all the possible scenarios with expected input and outcomes before writing any code. It serves as a “blueprint” of sorts that helps guide the development of a feature one step at a time, making sure all business objectives/cases are met, even new ones that you will uncover during the writing process.
I don’t know about you but I don’t like to keep things in my head for long periods of time. I forget things and others can’t read my mind. Tests serve as a reference to help your future self and your teammates understand, to a certain extent why a feature is written the way they are. Comments can only go so far unless you enjoy writing a novel next to your method definitions.
Enough with the reasons why you should write tests. Let’s jump right into some core concepts on how you should think about tests.
It’s about setting expectations
I’m sure you’ve heard about the term assertion when it comes to tests. In Wikipedia as it relates to software development, the assertion is defined as:
… a predicate (a Boolean-valued function over the state space, usually expressed as a logical proposition using the variables of a program) connected to a point in the program, that always should evaluate to true at that point in code execution.
I don’t know about you but that confuses the hell out of me when I first read it. Here’s a better take at it:
Think of assertions as expectations.
We expect a lot of things in life. I expect that my cake would come out perfect IF I follow the baking instructions. I expect to lose weight IF I exercise. I expect my kids to stop screaming IF I give them ice-cream. It’s a natural thought process that we expect certain outcomes from our application when certain criteria are met. However, unlike real life expectations, you have absolute control over the expected outcome in your application. If it is meant to output something, you can make it happen.
Writing tests is about writing expectations. RSpec makes it abundantly clear with their own DSL:
expect(page).to have_text(‘Hello World’)
I expect somewhere on the page to contain the text “Hello World”. All I need to do now is to write some code that will print the text “Hello World” on the page when it gets rendered. It's a simple concept.
Testing with RSpec
RSpec is a well known testing framework written in Ruby that I typically use to test my rails application. To use it, you will need to include the
rspec-rails gem in your Gemfile. Below is a short gist on how to get your Rails app setup:
# Spin up a new rails app and make sure to create the db and schema rails new example-app -T -d postgresql # Add and install the following gems to your Gemfile group :development, :test do ... gem 'rspec-rails' gem 'capybara' gem 'selenium-webdriver' end # Run Rspec generator rails g rspec:install # Require the following at the top of rails_helper.rb file require 'rspec/rails' require 'capybara/rspec' # Create your spec files under the respective spec folders spec/controllers/posts_controller.rb spec/system/user_create_post.rb # Run your spec files and watch them fail rspec spec/system/user_create_post.rb
rspec-rails gem defines 10 different types of specs for testing different parts of a Ruby on Rails application. Here’s an illustration of the main types:
Feature or System Specs
Let’s get this straight. Feature tests, system tests, integration tests, acceptance tests, and end-to-end tests, they’re all the same. Its purpose is to help you test your application from the perspective of a real-life user (i.e. going to a page, filling in a text field, clicking on a button, etc.). By simulating the interaction of a real-life user, all your routes, views, controllers and models get tested.
As of Rails 5.1+, with the introduction of
SystemTestCase, it is now recommended to write System specs in replacement of Feature specs.
It’s perfect for testing API requests and responses which is what I use request specs for most of the time.
A controller spec is nothing more than just unit tests for controllers. It allows you to simulate HTTP requests and specify expected outcomes such as:
- should render a template
- should redirect to another page
- some instance variable gets assigned to be used in the view
- cookies get set and sent back in response
One of the most common mistakes I find in controller specs is over-expecting outcomes. Expecting that a record gets created in a controller spec can be acceptable if your code does just that. What is not acceptable is when you start writing expectations for mailers, notifications and even view components (including
render_views in your controller spec). This is usually an indicator of a bloated controller that needs refactoring.
Model / Unit Specs
A model spec is simply a unit test for models. In a typical Rails application, it allows you to write expectations around:
- Associations with other models (i.e. has_many, belongs_to)
- Validations like uniqueness, presence, etc.
- Input and outcomes of methods that defines the behaviour of your model
A routing spec basically allows you to expect/assert your HTTP requests get routed to the right controller and action. This can prove to be useful when you have custom routes that can get pretty gnarly if not managed/tested.
I almost never write view specs but for completeness' sake, I’ve decided to include it here. As its name suggests, view specs allow you to expect your view/page to render certain content when rendered without actually invoking your controllers.
Now that you’ve understood what TDD is, and hopefully given yourself enough reason to adopt testing first in your next project, the next logical step is to figure out how to actually test your Rails application. I’m not talking about how to write tests, I’m talking about knowing how to test. There’s a big difference. It’s like knowing how to write a bunch of English words vs actually knowing how to compose a sentence with perfect grammar.
As illustrated in the diagram above, outside-in is a common approach where high-level feature tests that describe how a feature should work are written first. Working towards getting these high-level expectations to pass will in turn drive us down to lower-level tests such as routing, controller and model tests. Writing implementation code to get the tests passing on each level will help us ultimately build the feature we wanted.
Let’s get into some real-life examples:
Imagine we’re working on the next cool blog application and you’re asked to build a feature to post articles:
As a user, I can post a blog article so that I can share my thoughts with my friends
Before jumping straight into writing out the routes, controller, model and view code, we write a high-level system or feature spec to flesh out how a user would interact with our feature to create a blog post
# spec/system/create_post_spec.rb require 'rails_helper' describe "Create post", type: :system do it "enables me to create new post" do visit "/posts/new" fill_in 'Title', with: "First Blog Post" fill_in 'Body', with: "Test driven development is awesome!" click_on 'Submit' expect(page).to have_text("First Blog Post" end end
Running the spec now with
rspec spec/system/create_post_spec.rb will throw you a missing routes error. Awesome! Now, let’s add the appropriate route to our routes file
Rails.application.routes.draw do resources :posts end
Running the spec again will throw a new error indicating that we’re missing the controller class. Fixing this error would simply require you to create the missing controller.
As you can already tell, repeating this cycle will throw you new failures and as you work towards fixing these test failures, you will eventually end up writing the necessary routes, controller, model and view code to complete your feature.
As a society, we see failure as a negative thing. Ever since we were young, we were taught to avoid failure. Test-driven development on the other hand flips this mindset to instead embrace failure, to constantly write tests that fail and use it as a guiding light towards building our feature. I think that’s a beautiful mindset and process to adopt in both our development work and life.
As Jeff Bezos once wrote in a letter to shareholders:
Failure comes part and parcel with invention. It’s not optional. We understand that and believe in failing early and iterating until we get it right.