Here I wrote the parts I considered most important from this book, Jason goes from explaining different types of tests, which ones he uses, DSL, how to think on testing from a specification perspective not validation, make testing a habit that compound down the road and increase productivity even though it feels in the begining that it deters you from coding faster.
Who this book is for, what’s in this book and how to use this book
First I capture what to do in the form of a test. Then I follow my own instructions by getting the test to pass. Then I repeat. This is a much lighter cognitive burden than if I were to juggle these different mental jobs and allows me to be productive for longer because I don’t run out of mental energy as early in the day.
The first truth is that it’s impossible to write a piece of code cleanly on the first try. Some amount of refactoring, typically a lot of refactoring, is necessary in order to get the code into a reasonably good state.
The second truth is that it’s impossible to do non-trivial refactorings without having automated tests. The feedback cycle is just too long when all the testing is done manually. Either that or the risk of refactoring without testing afterward is just too large to be justified.
“What level of test coverage should I shoot for?” is one of the questions most commonly asked by beginners to Rails testing.
My answer is that you shouldn’t shoot for a particular level of test coverage. I recommend that instead you make testing a habitual part of your development workflow. A healthy level of test coverage will flow from there.
All software has bugs, but if you feel like the rate of new bugs appearing in production is unacceptably high, it may be a symptom of too little test coverage.
The only alternative to using automated tests, aside from not testing at all, is to test manually.
Infrequent deployments can arise as a symptom of too few tests for a couple different reasons. One possible reason is that the need for manual testing bottlenecks the deployment timing. If it takes two days for manual testers to do a full regression test on the application, you can of course only deploy a fully-tested version of your application once every two days at maximum.
Inability to refactor or make big changes.
Testing != TDD
TDD is a specific kind of testing practice where you write the tests before you write the code that makes the test pass.
A common Rails testing question is which testing framework to use. RSpec and Minitest are the two options that most people are deciding between.
Most of us don’t have much choice as to whether to use RSpec or Minitest at work.
For better or worse, it’s my experience and the experience of most Rails developers I’ve talked with that most commercial projects use RSpec. (Note how I said most commerical projects. Most commercial projects use RSpec and most OSS Ruby projects, in my experience, use Minitest. I do not know why this is the way it is.)
What does this mean? My take is that this means if your goal is to get a Rails job, learning RSpec over Minitest will give you a higher probability that your skills match the tech stack that’s used at any particular company
RSpec and Minitest differ syntactically but they don’t really have meaningful conceptual differences.
I’ll explain what the major tools are but I want to preface it by saying that the most important thing to learn to be a successful tester is testing principles, not testing tools.
RSpec. RSpec is a test framework. A test framework is what gives us a structure for writing our tests as well as the ability to run our tests.
Factory Bot. One of the challenges of Rails testing is generating test data. There are two common ways of generating test data in Rails tests: fixtures and factories.
Fixtures typically take the form of one or more YAML files with some hard-coded data. The data is translated into database records one time, before any of the tests are run, and then deleted afterward. (This happens in a separate test database instance of course.)
With factories, database data is generated specifically for each test. Instead of loading all the data once at the beginning and deleting it at the end, data is inserted before each test case and then deleted before the next test case starts. (More precisely, the data isn’t deleted, but rather the test is run inside a database transaction and the data is never committed in the first place, but that’s a mechanical detail that’s not important right now.)
I tend to prefer factories because I like having my data generation right inside my test, close to where the test is happening. With fixtures the data setup is too distant from where the test happens.
Capybara. Some Rails tests only exercise Ruby code. Other tests actually open up a browser and simulate user clicks and keystrokes. Simulating user input this way requires us to use some sort of tool to manipulate the browser. Capybara is a library that uses Ruby to wrap a driver (usually the Selenium driver), letting us simulate clicks and keystrokes using convenient Ruby methods.
VCR and WebMock. One principle of testing is that tests should be deterministic, meaning they run the same way every time no matter what. When an application’s behavior depends on external services (e.g. a third-party API like Stripe) it makes it harder to have deterministic tests. The tests can be made to fail by an internet connection failure or a temporary outage of the external service.
The different kinds of RSpec tests and when to use each:
The eight types of RSpec specs
• Model specs • System specs • Request specs • Helper specs • View specs • Routing specs • Mailer specs • Job specs
Jason usage:
• Model specs always • System specs always • Request specs rarely • Helper specs rarely • View specs never • Routing specs never • Mailer specs never • Job specs never
I use model specs to test my models’ methods. When I do so, I tend to use a test-first approach and write a failing test before I add a new line of code so that I’m sure every bit of code in my model is covered by a test.
System specs are the only type of test that give me confidence my whole application really works.
Even though system specs are indispensable, they’re not without drawbacks. System specs are somewhat “heavy”
I tend not to use request specs much because in most cases they would be redundant to my system specs. If I have system specs covering all my features, then of course a broken controller would fail one or more of my tests, making tests specifically for my controllers unnecessary.
I also try to keep my controllers sufficiently simple as to not call for tests of their own.
There are just three scenarios in which I do use request specs.
If I’m working on a legacy project with fat controllers, sometimes I’ll use request specs to help me harness and refactor all that controller code.
If I’m working on an API-only Rails app, then system specs are physically impossible and I drop down to request specs instead.
if it’s just too awkward or expensive to use a system spec in a certain case then I’ll use a request spec instead.
View and routing spec
I find view specs and routing specs to be redundant to system specs. If something is wrong with one of my views or routes, it’s highly likely that one of my system specs will catch the problem.
What are all the Rails testing tools and how do I use them?
RSpec is a test framework. A test framework is what gives us a structure for writing our tests as well as the ability to run our tests.
How do I add tests to an existing Rails project?
If you have little testing experience, I would suggest getting some practice on a fresh Rails app before trying to introduce testing to the existing Rails project you want to add tests to. Adding tests to an existing project is a distinct skill from writing tests for new projects.
If you’re already comfortable with testing
1) develop a shared vision with your team, To improve test coverage, a team must first create a shared vision. They need to agree on what “good testing” means for them, choose the tools and approach they’ll use, and define clear goals and steps to reach their desired level of testing.
2) start with what’s easiest, then When introducing tests to an untested codebase, don’t start with the most important or complex features, as those are usually hardest to test. Also, requiring tests for every new change can be impractical because new code often depends on untested parts. Instead, begin with the easiest areas—like simple CRUD operations—to build basic testing habits and infrastructure. This creates a foundation (“a beachhead”) to gradually expand test coverage later.
3) expand your test coverage After establishing tests for simple features, gradually move on to more complex ones. This step-by-step approach makes it easier to achieve solid test coverage than starting with the hardest or most valuable parts right away. With the foundational principles and tools in place, it’s time to begin writing your first practice tests.
I’ll describe how to set up a new Rails application for testing in three parts:
Template: Application template I created that will do two things: 1) install a handful of testing-related gems and 2) add a config file that will tell RSpec not to generate certain types of files
gem_group :development, :test do
gem 'rspec-rails'
gem 'factory_bot_rails'
gem 'capybara'
gem 'webdrivers'
gem 'faker'
end
initializer 'generators.rb', <<-CODE
Rails.application.config.generators do |g|
g.test_framework :rspec,
fixtures: false,
view_specs: false,
helper_specs: false,
routing_specs: false,
request_specs: false,
controller_specs: false
end
CODE
after_bundle do
generate 'rspec:install'
end
You can see the code that will be run within your app if you go to the next link https://raw.githubusercontent.com/jasonswett/testing_application_template/master/application_template.rb
Setup process:
When I run rails new, I always use the -T flag for “skip test files” because I always use RSpec instead of the Minitest that Rails comes with by default. Also, incidentally, I always use PostgreSQL. This choice of course has little to do with testing but I’m including it for completeness.
I’m also using the -m flag so I can pass in my application template.
rails new my_project -T -d postgresql \
-m https://raw.githubusercontent.com/\
jasonswett/testing_application_template\
/master/application_template.rb
The Gems:
rspec-rails: The rspec-rails gem is the version of the RSpec gem that’s specifically fitted to Railsfactory_bot_rails: Factory Bot is a tool for generating test data.capybara: Capybara is a tool for writing acceptance tests, i.e. tests that interact with the browser and simulate clicks and keystrokes.webdrivers: In order for Selenium to work with a browser, Selenium needs drivers.faker: By default, Factory Bot (the tool for generating test data) will give us factories that look something like this:FactoryBot.define do
factory :customer do
first_name { "MyString" }
last_name { "MyString" }
email { "MyString" }
end
end
When collision on attribute first_name arise due to uniqueness, we use Faker:
FactoryBot.define do
factory :customer do
first_name { Faker::Name.first_name }
last_name { Faker::Name.last_name }
email { Faker::Internet.email }
end
end
I also often end up adding the VCR and WebMock gems when I need to test functionality that makes external network requests.
Next steps
After I initialize my Rails app, I usually create a walking skeleton by deploying my application to a production and staging environment and adding one small feature, for example the ability to sign in.
Building the sign-in feature will prompt me to write my first tests. By working in this way I front-load all the difficult and mysterious work of the project’s early life.
A Rails testing “hello world” using RSpec and Capybara
Jason encourages the reader to start a rails app and add a controller and a test to have a quick win.
# step 1:
rails new my_project -T -d postgresql -m https://raw.githubusercontent.com/jasonswett/testing_application_template/master/application_template.rb
# step 2:
$ rails g controller hello_world index
# step 3: within the view app/views/hello_world/index.html.erb add the following:
Hello, world!
# step 4: boot up rails server and go to the url ` open http://localhost:3000/hello_world/index`
$ rails server
# step 5: create the next file spec/hello_world_spec.rb
require 'rails_helper'
RSpec.describe 'Hello world', type: :system do
describe 'index page' do
it 'shows the right content' do
visit hello_world_index_path
expect(page).to have_content('Hello, world!')
end
end
end
# step 6: run the test !
rspec spec/hello_world_spec.rb
Here is a Jason explanation on what we just wrote as a test:
# This pulls in the config from spec/rails_helper.rb
# that's needed in order for the test to run.
require 'rails_helper'
# RSpec.describe, or just describe, is how all RSpec tests start.
# The 'Hello world' part is an arbitrary string and could have been anything.
# In this case we have something extra in our describe block, type: :system.
# The type: :system setting does have a functional purpose. It's what
# triggers RSpec to bring Capybara into the picture when we run the test.
RSpec.describe 'Hello world', type: :system do
describe 'index page' do
it 'shows the right content' do
# This is where Capybara starts to come into the picture. "visit" is a
# Capybara method. hello_world_index_path is just a Rails routing
# helper method and has nothing to do with RSpec or Capybara.
visit hello_world_index_path
# The following is a mix of RSpec syntax and Capybara syntax. "expect"
# and "to" are RSpec, "page" and "have_content" are Capybara. Newcomers
# to RSpec and Capybara's English-sentence-like constructions often
# have difficulty remembering when two words are separated by a dot or
# an underscore or parenthesis, myself included. Don't worry, you'll
# get familiar over time.
expect(page).to have_content('Hello, world!')
end
end
end
Before trusting a passing test, you must first see it fail to confirm it’s actually testing what you think it is. A test that passes even when the code is broken is a false positive. By intentionally breaking the feature and ensuring the test fails, you verify that the test correctly distinguishes between working and broken behavior.
# change the text in the view app/views/hello_world/index.html.erb
Jello, world!
When we run our test now it should see it fail with an error message like expected to find text “Hello, world!” in “Jello, world!”.
What Factory Bot is
One of the biggest challenges to a new tester is the question of how to generate test data. Most features in a web application will require you to have some certain database records in place first, but it’s not always clear the best way to bring those records into existence. There are multiple ways to accomplish this.
For users of RSpec, the de facto standard way to create test data is to use a tool called Factory Bot. Factory Bot is a Ruby library that allows for convenient generation of database data for tests.
There are three ways to generate test data in Rails:
Let’s first explore manual creation.
Manual data creation can be convenient enough if you only have a few attributes on a model and no dependencies.
valid_payment_type = PaymentType.new(name: 'Visa')
invalid_payment_type = PaymentType.new(name: '')
But now let’s say we have the idea of an Order which is made up of multiple LineItems and Payments, each of which has a PaymentType.
order = Order.create!(
line_items: [
LineItem.create!(name: 'Electric dog polisher', price_cents: 40000)
],
payments: [
Payment.create!(
amount_cents: 40000,
payment_method: PaymentMethod.create!(name: 'Visa')
)
]
)
That’s annoying. This is where factories come in handy.
Factories
The idea with a factory is basically that you have a method/function that generates new class instances for you.
Here’s an example of how the setup for an Order instance might look if we used a factory, specifically Factory Bot.
order = FactoryBot.create(
:order, # 👈 model Order
line_items: [FactoryBot.create(:line_item, price_cents: 40000)], # 👈 associations with LineItems
payments: [FactoryBot.create(:payment, amount_cents: 40000)]# 👈 associations with Payments
)
In this case we’re specifying only the details that are relevant to the test. We don’t care about the line item name or the payment method. As long as we have a payment total that matches the line item total, that’s all where care about.
Fixtures
Typically fixtures are expressed in terms of YAML files.
# orders.yml
payments_equal_line_item_total:
# no attributes needed
# line_items.yml
electric_dog_polisher:
order: payments_equal_line_item_total
name: 'Electric dog polisher'
price_cents: 40000
# payment_methods.yml
visa:
name: 'Visa'
# payments.yml
first:
order: payments_equal_line_item_total
payment_method: visa
amount_cents: 40000
Once the fixture data is established, instantiating an object that uses the data is as simple as referring to the key for that piece of data:
order = orders(:payments_equal_line_item_total)
Which is best?
Summary
Manual Data Generation
Factories vs Fixtures
The author prefers factories for several reasons:
Testing Philosophy
Practical Recommendation
Factories are the go-to method and general recommendationThe key takeaway: factories are preferred for their transparency and encouraging minimal, test-specific data generation, but the author remains pragmatic about using the right tool for the situation.
To install Factory Bot, add the factory_bot_rails gem to the :development, :test group of your Gemfile.
group :development, :test do
gem 'factory_bot_rails'
end
Factory definitions:
user = FactoryBot.create(:user)
Notice how we don’t have to specify anything at all about the record’s attributes. We only had to pass in :user as an argument. Factory Bot will automatically take care of the details for the user record based on the instructions we specify in the user factory.
FactoryBot.define do
factory :user do
first_name { 'John' }
last_name { 'Smith' }
email { 'john.smith@example.com' }
end
end
As you may have guessed, :user maps to our User model (assuming we have one) and first_name, last_name and email all map to attributes in the User model.
Below is a more detailed explanation of what each part of the factory definition does.
# FactoryBot is the name of a class.
# "define" is a class method on the FactoryBot class.
FactoryBot.define do
# "factory" is a method. It takes, as an argument, the name of the factory
# we're defining. By convention, the argument we pass gets matched up with
# an ActiveRecord class, e.g. :user gets matched up with User.
factory :user do
# Each attribute in our ActiveRecord model can have a corresponding line
# in our factory definition. In this case, first_name, last_name and
# email are all dynamically-defined methods. Each of these methods
# takes a block which supplies the value of the attribute.
first_name { 'John' }
last_name { 'Smith' }
email { 'john.smith@example.com' }
end
end
Where to put factory definitions
I put all my factory definitions in spec/factories and I generally use a convention of putting just one factory in each file. For example, if I were to have a factory for a Product model, I would put it in spec/factories/products.rb.
There’s unfortunately a problem with our configuration: if we do FactoryBot.create(:user) again, we’ll just get an exact duplicate with all the same attribute values, which is of course often not desirable.
We’ll address this issue later on though.
For creating objects using Factory Bot, there are two main methods offered: create and build. Let’s take a look at each, continuing to use our User factory as an example.
If we were to run FactoryBot.create(:user), it would return a persisted instance of a
User model.
If we were to run FactoryBot.build(:user), it would return an unpersisted
instance of a User model.
Whenever possible, we’re going to favor .build over .create, as persisting to the database is one of the slowest operations in our tests.
If we go to the terminal and instantiate the next model with different methods:
# we try out `.create`
=> user = FactoryBot.create(:user)
=> user.persisted? # true
=> user.id # an ID value 45700
# then we try out `.build`
> user = FactoryBot.build(:user)
> user.persisted? # false
> user.id # nil
Using Factory Bot with Faker
There’s a problem with using hard-coded values in factory definitions. What if our users table had a unique constraint on the email column? In that case, the first usage of FactoryBot.create(:user) would work fine, but the second time we did it, the database wouldn’t allow the duplicate record to be created, and we’d have a problem.
Here’s an example of how that would go using Faker:
FactoryBot.define do
factory :user do
first_name { 'John' }
last_name { 'Smith' }
email { Faker::Internet.email }
end
end
Faker::Internet.email will return values like kris@hoeger.io or marionstroman@hamill.io. I find these sorts of values nicer to work with than a hard-coded value with a number or hash slapped on the end of it.
Entire Factory for User model using Faker.
FactoryBot.define do
factory :user do
first_name { Faker::Name.first_name }
last_name { Faker::Name.last_name }
email { Faker::Internet.email }
end
end
Nested factories
Sometimes you want to be able to create records that are 95% the same as the “default” but have one or two small differences.
The Problem:
role set to 'physician')Three Options Evaluated
Key Testing Principle Tests should only include the minimum necessary setup data. Extraneous data misleads future maintainers who can’t distinguish between essential and superfluous setup.
# Normal Factory for User model
FactoryBot.define do
factory :user do
username { Faker::Internet.username }
password { Faker::Internet.password }
end
end
# Nested factory
FactoryBot.define do
factory :user do
username { Faker::Internet.username }
password { Faker::Internet.password }
factory :physician_user do
role { 'physician' }
end
end
end
# Usage of that nested Factory
FactoryBot.create(:physician_user)
Traits
Traits solve a similar problem to the one nested factories solve, but in a different way. With nested factories, you’re defining a child factory inside an existing factory, with the child factory inheriting from the parent factory.
With traits, you’re defining additional qualities that can optionally be added to an existing object.</b>
# Normal Factory for User model
FactoryBot.define do
factory :user do
username { Faker::Internet.username }
password { Faker::Internet.password }
end
end
# Traits
FactoryBot.define do
factory :user do
username { Faker::Internet.username }
password { Faker::Internet.password }
trait :with_name do
first_name { "John" }
last_name { "Smith" }
end
trait :with_phone_number do
phone_number { "(555) 555-5555" }
end
end
end
# Usage of that Traits
FactoryBot.create(:user, traits: [:with_name, :with_phone_number]).
When to use traits versus nested factories
My method is pretty simple: If the factory I’m considering has something, I use a trait. If the factory is something, I use a nested factory. Let’s look at a concrete example.
“Has” example (trait)
# The user is still conceptually a regular old `user`. The only difference is that this user happens to have a value for its `phone_number` attribute.
FactoryBot.define do
factory :user do
username { Faker::Internet.username }
password { Faker::Internet.password }
trait :with_phone_number do
phone_number { "(555) 555-5555" }
end
end
end
# Usage of that Traits
FactoryBot.create(:user, :with_phone_number)
“Is” example (nested factory)
# A `physician` user has different capabilities from a regular `user` and is used in different ways from a regular `user`.
FactoryBot.define do
factory :user do
username { Faker::Internet.username }
password { Faker::Internet.password }
factory :physician_user do
role { 'physician' }
end
end
end
# Usage of that nested Factory
FactoryBot.create(:physician_user)
Callbacks
Imagine you need to create a User record that has a Message associated with it. This is an option:
user = FactoryBot.create(:user)
create(:message, user: user)
But if you need to create such users over and over, your code could get repetitive. You can create a more convenient way to meet your needs by using callbacks.
Below is a concrete example. We have a :user factory and then, inside that, a nested
factory called :user_with_message.
FactoryBot.define do
factory :user do
username { Faker::Internet.username }
password { Faker::Internet.password }
factory :user_with_message do
after(:create) do |user|
create(:message, user: user)
end
end
end
end
When FactoryBot.create(:user_with_message) is run, everything happens that would happen when FactoryBot.create(:user) is run, plus the stuff in the after(:create) callback is executed.
The various callback types:
• after(:create) - called after a factory is saved (via FactoryBot.create)
• after(:build) - called after a factory is built (via FactoryBot.build or FactoryBot.create)
• before(:create) - called before a factory is saved (via FactoryBot.create)
• after(:create) - called after a factory is saved (via FactoryBot.create)
• after(:stub) - called after a factory is stubbed (via FactoryBot.build_stubbed)
Transient attributes
Transient attributes are values that are passed into the factory but not directly set on the object’s attributes.
PDF file attachment example
Let’s say we have an InsuranceDeposit class that has PDF file attachments.
class InsuranceDeposit
has_many_attached :pdf_files
end
An example without using transient attributes
insurance_deposit = create(:insurance_deposit)
# This setup is noisy and hard to understand
file = Tempfile.new
pdf = Prawn::Document.new
pdf.text "my arbitrary PDF content"
pdf.render_file file.path
insurance_deposit.pdf_files.attach(
io: File.open(file.path),
filename: "file.pdf"
)
This way is non-ideal because a) the code is hard to understand and b) if we use these steps end more than one place, we’ll have duplication
An example with transient attributes
FactoryBot.define do
factory :insurance_deposit do
transient do
pdf_content { "" }
end
after(:create) do |insurance_deposit, evaluator|
file = Tempfile.new
pdf = Prawn::Document.new
pdf.text evaluator.pdf_content
pdf.render_file file.path
insurance_deposit.pdf_files.attach(
io: File.open(file.path),
filename: "file.pdf"
)
end
end
end
# usage of transient
create(:insurance_deposit, pdf_content: "my arbitrary PDF content")
This is much tidier than the original. If we want to see how pdf_content works, we can open up the insurance_deposit factory and have a look.
RSpec Syntax: Introduction
In this section we’ll address both the RSpec DSL and the underlying Ruby concepts that are helpful to understand in order to understand RSpec’s DSL.
Here’s what we’re going to cover in this section:
No matter what test framework is being used, a test tends to contain four basic parts, or phases:
We can illustrate these four phases using an example. Let’s say we have an application that has a list of users that can receive messages. Only active users are allowed to receive messages. So, we need to assert that when a user is inactive, that user can’t receive messages.
Here’s how this test might go:
User record (setup)false (exercise)messageable” (assertion)User record we created in step 1 (teardown)The purpose of each test phase
The setup phase typically creates all the data that’s needed in order for the test to operate. (There are other things that could conceivably happen during a setup phase but for our current purposes we can think of the setup phase’s role as being to put data in place).
In our case, the creation of the User record is all that’s involved in the setup step, although more complicated tests could of course create any number of database records and potentially establish relationships among them.
The exercise phase walks through the motions of the feature we want to test. With our messaging example, the exercise phase is when the user gets put in an inactive state.
Side note: the distinction between setup and exercise may seem blurry, and indeed it sometimes is, especially in low-level tests like our current example. If someone were to argue that setting the user to inactive should actually be part of the setup, I’m not sure how I’d refute them.
The assertion phase is basically what all the other phases exist in support of. The assertion is the actual test part of the test, the thing that determines whether the test passes or fails.
Each test needs to clean up after itself. If it didn’t, then each test would potentially pollute the world in which the test is running and affect the outcome of later tests, making the tests non-deterministic. We don’t want this. We want deterministic tests, i.e. tests that behave the same exact way every single time no matter what. The only thing that should make a test go from passing to failing or vice-versa is if the behavior that the test tests changes.
In reality, Rails tests tend not to have an explicit teardown step. The main pollutant we have to worry about with our tests is database data that gets left behind. RSpec is capable of taking care of this problem for us by running each test in a database transaction. The transaction starts before each test is run and aborts after the test finishes. So really, the data never gets permanently persisted in the first place.
A concrete example
RSpec.describe User do
let!(:user) { User.create!(email: 'test@example.com') } # setup
describe '#messageable?' do
context 'is inactive' do
it 'is false' do
user.update!(active: false) # exercise
expect(user.messageable?).to be false # assertion
user.destroy! # teardown
end
end
end
end
The purpose of let and the differences between let and instance variables
RSpec’s let helper method is a way of defining values that are used in tests. Below is a typical example.
require 'rspec'
RSpec.describe User do
let(:user) { User.new }
it 'does not have an id when first instantiated' do
expect(user.id).to be nil
end
end
# here we use a before hook https://rspec.info/features/3-12/rspec-core/hooks/before-and-after-hooks/
require 'rspec'
RSpec.describe User do
before { @user = User.new }
it 'does not have an id when first instantiated' do
expect(@user.id).to be nil
end
end
Differences between let and instance variables
Summary
Stylistic Differences
@ prefixlet syntax slightly tidierMechanical Differences
nil
nil to a method and test the wrong behaviorlet helper: Defines a memoized method, not an instance variable
let can create values that are evaluated lazilyMost Important Difference: Test Isolation
before blocks can leak between test files
@customer set in “File A” can be referenced in “File B”let is safer for maintaining test isolationBottom Line: let provides better error detection and test isolation compared to instance variables.
How let works
let is NOT a variable, it’s a method that returns a memoized method (a method that only runs once).
def my_name
puts 'thinking about what my name is...'
'Jason Swett'
end
puts my_name
# output:
thinking about what my name is...
Jason Swett
Same thing using let
require 'rspec'
describe 'my_name' do
let(:my_name) do
puts 'thinking about what my name is...'
'Jason Swett'
end
it 'returns my name' do
puts my_name
end
end
# output:
thinking about what my name is...
Jason Swett
Memoization in action
The method body only executes once, but returns the value twice.
require 'rspec'
describe 'my_name' do
let(:my_name) do
puts 'thinking about what my name is...'
'Jason Swett'
end
it 'returns my name' do
puts my_name
puts my_name # Called twice
end
end
# output:
thinking about what my name is... # Only prints once!
Jason Swett
Jason Swett
Lazy Evaluation let
require 'rspec'
describe 'let' do
let(:message) do
puts 'let block is running'
'VALUE'
end
it 'does stuff' do
puts 'start of example'
puts message
puts 'end of example'
end
end
# output:
start of example
let block is running # Runs only when message is called
VALUE
end of example
Immediate Evaluation let!
require 'rspec'
describe 'let!' do
let!(:message) do
puts 'let block is running'
'VALUE'
end
it 'does stuff' do
puts 'start of example'
puts message
puts 'end of example'
end
end
# output:
let block is running # Runs BEFORE test starts
start of example
VALUE
end of example
Author’s Recommendation
Always use let! instead of let
Why?
let) can be subtly confusing
Practical takeaways:
• The biggest advantage to using let over instance variables is that instance variables can leak from test to test, which isn’t true of let.
• The difference between let and let! is that the former is lazily evaluated while the latter is immediately evaluated.
• I always use the let! version because I find the execution path to be more easily understandable.
In this chapter we’re going to write a small test and thoroughly examine it. We’ll look at the parts of the test bit-by-bit so we can understand each piece as well as the whole. By the end of this chapter, the syntax of an RSpec file will look much less mysterious.
We’re going to write a small, trivial method and then write an RSpec test for the method. Our method, called emphasize, will convert a string to uppercase and add an exclamation point at the end. For example, the emphasize method would convert awesome to AWESOME!.
def emphasize(text)
"#{text.upcase}!"
end
Here is the test for that method:
RSpec.describe 'emphasizing text' do
it 'makes the text uppercase and adds an exclamation point' do
expect(emphasize('hello')).to eq('HELLO!')
end
end
We’re going to examine each part of this test, but first let’s talk about some terminology.
Specs, examples, “example groups” and “it blocks”
Tests vs. specs
In RSpec, the contents of an RSpec file are referred to as a spec, short for specification. Spec files in RSpec are suffixed with _spec. These files must be suffixed so in order for the RSpec runner to recognize them as spec files. An example spec filename would be patient_spec.rb. Each spec usually contains a number of example groups.
Example groups are a way of putting related tests into groups. The example group is the outermost block which starts with RSpec.describe.
RSpec.describe 'emphasizing text' do # ⬅ this is the example group
it 'makes the text uppercase and adds an exclamation point' do
expect(emphasize('hello')).to eq('HELLO!')
end
end
# they can be nested too
RSpec.describe 'emphasizing text' do # ⬅ this is the example group for valid input
describe "valid input" do
it 'makes the text uppercase and adds an exclamation point' do
expect(emphasize('hello')).to eq('HELLO!')
end
end
describe "invalid input" do # ⬅ this is the example group for invalid input
it 'returns nil when input is not String' do
expect(emphasize(7)).to eq(nil)
end
end
end
Examples and “it blocks”
Each individual test in RSpec is known as an example. A single spec (which, remember, means a single RSpec file) may contain any number of examples:
Why it blocks? My interpretation is that the it blocks help make each example read like an English sentence. In the example above the English sentence would of course be “it makes the text uppercase and adds an exclamation point”
RSpec.describe 'emphasizing text' do
it 'makes the text uppercase and adds an exclamation point' do # ⬅ this is the it block or example or test case
expect(emphasize('hello')).to eq('HELLO!')
end
end
The expect keyword
The expect is the part of the test (or, more precisely, the part of the example) where the test itself actually happens. In the snippet below we’re expecting that the return value of emphasize('hello') is equal to 'HELLO!'. If this expectation is met when we run the spec, then this example will pass. Otherwise the example will fail and the RSpec test runner will let us know exactly how the example failed.
RSpec.describe 'emphasizing text' do
it 'makes the text uppercase and adds an exclamation point' do
expect(emphasize('hello')).to eq('HELLO!') # ⬅ this is the expect keyword for this test case
end
end
The eq matcher
The syntax of .to eq() often looks strange and arbitrary to RSpec beginners. Much of the RSpec syntax (which we’ll see a lot more of soon) can look like a soup of dots, spaces and underscores, with no apparent rhyme or reason as to what’s what. Luckily, I assure you that this confusion goes away as you get more familiar with what the RSpec DSL is made of and as you get practice writing RSpec tests.
For the .to part of expect(emphasize('hello')).to eq('HELLO!'), it helps to recognize that to is just a method. In fact, we can add the missing optional parentheses on the to method to make it clearer that to is just a method.
expect(emphasize('hello')).to(eq('HELLO!'))
eq is also a method. The argument that we pass to the to method is eq('HELLO!'), so in
other words, whatever the return value of eq('HELLO') is is what gets passed to to. What’s
the return value of eq('HELLO')?
What we’re going to do:
In this chapter we’re going to build our own RSpec-like test framework called MySpec. By the end of this chapter you’ll be able to run the following test file
# Our desired test file
require_relative './my_spec'
def emphasize(text)
"#{text.upcase}!"
end
# Above this line is the "application code"
# and below this line is the test code
# -----------------------------------------
MySpec.describe 'emphasizing text' do
it 'makes the text uppercase and adds an exclamation point' do
expect(emphasize('hello')).to eq('HELLO!')
end
end
Step 1: expect
For convenience, we’ll include the “application code” (i.e. the emphasize method) right in the same file as the test code.
# emphasize_spec.rb
def emphasize(text)
"#{text.upcase}!"
end
expect(emphasize('hello')).to eq('HELLO!')
Throughout this process, we’re going to use the method of “error-driven development” (not a real thing, just something I made up). We’ll write some code, run the code, see what errors we get, fix the errors, and repeat.
if we run the file $ ruby emphasize_spec.rb we get the next error:
emphasize_spec.rb:5:in `<main>': undefined method `expect' for main:Object (NoMethodError)
The error is telling us expect is an undefined method, which is true. We haven’t defined a method called expect yet. Let’s define it.
def emphasize(text)
"#{text.upcase}!"
end
def expect(things) # we added the method expect with an argument
end
expect(emphasize('hello')).to eq('HELLO!')
Now, if we run the file we get a different error:
emphasize_spec.rb:8:in `<main>': undefined method `eq' for main:Object (NoMethodError)`
It’s time to add eq to the file:
def emphasize(text)
"#{text.upcase}!"
end
def expect(things)
end
def eq(thing)# we added the method eq with an argument
end
expect(emphasize('hello')).to eq('HELLO!')
Now that we have defined both expect and eq we get another error:
emphasize_spec.rb:11:in `<main>': undefined method `to' for nil:NilClass (NoMethodError)
This one is perhaps less straightforward, so I’ll explain what’s happening. This error means that the object we’re calling to on is nil.
We’re calling to on expect(emphasize('hello')), so it must be that the return value of expect(emphasize('hello')) is nil.
This is indeed the case. When we defined expect, we didn’t put a body in the method definition, so the return value of expect is nil.
To fix this error, we need to change expect from returning nil to returning an object that will respond to a method called to. The only mystery is exactly what we should return.
def emphasize(text)
"#{text.upcase}!"
end
def expect(things)
ExpectationTarget.new # we added this new class so that we don't call .to on nil object
end
def eq(thing)
end
expect(emphasize('hello')).to eq('HELLO!')
Now, the error is:
emphasize_spec.rb:15:in `<main>': undefined method `to'
for #<ExpectationTarget:0x00007fe3020271b8> (NoMethodError)
And that’s because we have not even defined the ExpectationTarget class
def emphasize(text)
"#{text.upcase}!"
end
class ExpectationTarget
def initialize(output)
@output = output
end
def to(expected_output)
if @output == expected_output
puts '.'
else
raise "Expected #{@output} to equal #{expected_output}"
end
end
end
def expect(output)
ExpectationTarget.new(output)
end
def eq(expected_output)
expected_output
end
expect(emphasize('hello')).to eq('HELLO!')
If we run the file now we see an output of just a dot (.). This is because our expected output, HELLO!, does indeed match the actual output.
We can organize our code better in different files, for example the MySpec framework in my_spec.rb and the emphasize method in emphasize_spec.rb.
In the next chapter we’re going to make our syntax more closely match the real RSpec syntax. In addition to our expect, we’re going to have surrounding it and describe blocks.
In the last chapter we got as far as re-implementing RSpec’s expect, to and eq.
Now let’s make our test look even more like a real RSpec test by implementing describe and it.
Within the file emphasize_spec.rb
require_relative './my_spec'
def emphasize(text)
"#{text.upcase}!"
end
MySpec.describe 'emphasizing text' do
it 'makes the text uppercase and adds an exclamation point' do
expect(emphasize('hello')).to eq('HELLO!')
end
end
We’ll start with just the inner part, the it block.
Now, if we run emphasize_spec.rb, we get the following error. The it method we referred to is of course undefined.
emphasize_spec.rb:27:in `<main>': undefined method `it' for main:Object (NoMethodError)
Let’s add the it method with a yield keyword since it block doesn’t actually need to do anything, it’s just a wrapper for the benefit of the human reader.
def emphasize(text)
"#{text.upcase}!"
end
class ExpectationTarget
def initialize(output)
@output = output
end
def to(expected_output)
if @output == expected_output
puts '.'
else
raise "Expected #{@output} to equal #{expected_output}"
end
end
end
def expect(output)
ExpectationTarget.new(output)
end
def eq(expected_output)
expected_output
end
def it(description)
yield
end
expect(emphasize('hello')).to eq('HELLO!')
Adding the outer block MySpec.describe
Within emphasize_spec.rb we add the outer block and run the file.
require_relative './my_spec'
def emphasize(text)
"#{text.upcase}!"
end
MySpec.describe 'emphasizing text' do
it 'makes the text uppercase and adds an exclamation point' do
expect(emphasize('hello')).to(eq('HELLO!'))
end
end
the error is our friend and we have seen it many times before: emphasize_spec.rb:31:in '<main>': uninitialized constant MySpec (NameError)
Let’s add that MySpec class.
class MySpec
end
def emphasize(text)
"#{text.upcase}!"
end
class ExpectationTarget
def initialize(output)
@output = output
end
def to(expected_output)
if @output == expected_output
puts '.'
else
raise "Expected #{@output} to equal #{expected_output}"
end
end
end
def expect(output)
ExpectationTarget.new(output)
end
def eq(expected_output)
expected_output
end
def it(description)
yield
end
expect(emphasize('hello')).to eq('HELLO!')
If we run the file, we get the next error emphasize_spec.rb:34:in '<main>': undefined method 'describe' for MySpec:Class (NoMethodError)
class MySpec
def self.describe(description)
yield
end
end
def emphasize(text)
"#{text.upcase}!"
end
class ExpectationTarget
def initialize(output)
@output = output
end
def to(expected_output)
if @output == expected_output
puts '.'
else
raise "Expected #{@output} to equal #{expected_output}"
end
end
end
def expect(output)
ExpectationTarget.new(output)
end
def eq(expected_output)
expected_output
end
def it(description)
yield
end
expect(emphasize('hello')).to eq('HELLO!')
Now everything works, and we now have a complete test that very much resembles an RSpec test!
The describe and context keywords are just aliases for one another. Mechanically, they’re 100% equivalent. The only reason the two keywords exist is for the benefit of the human reader.
When I use describe
I tend to use describe when I’m describing a method or a feature. If I were to write a test for a method called first_name, I might write it something like this:
RSpec.describe User do
describe '#first_name' do # ⬅ here is the method first_name declared
it 'returns the first name' do
# test code goes here
end
end
end
# Sometimes I want to test a feature that doesn’t have a neat one-to-one relationship with a method.
RSpec.describe User do
describe 'phone format' do # ⬅ here is the feature we test
it 'strips the non-numeric characters' do
# test code goes here
end
end
end
When I use context
I tend to use context when I want to test various permutations of a behavior.
RSpec.describe User do # ⬅ group example for User class
describe 'phone format' do # ⬅ describre block to group two nested blocks
context 'phone number is not the right length' do # ⬅ here a context block to describe a behavior
# test code goes here
end
context 'containers non-numeric characters' do # ⬅ here a context block to describe a behavior
# test code goes here
end
end
end
The purpose of model specs
Model specs are for testing the behavior of your application. This may sound obvious, but many programmers seem to misunderstand the meaning of “behavior” and instead test the implementation of their application’s features, which unfortunately misses the point of testing altogether. Let’s talk about the difference between testing implementation and testing behavior.
Implementation vs. behavior
An example of an implementation test might be: “Does the Patient model have a has_many :payment_entries association?” If you look at patient.rb and it contains the line has_many :payment_entries, then the test passes.
An example of a behavior test might be “When I add a payment entry for $50 for a patient, does the patient’s balance decrease by $50?” To perform this test you might check the patient’s balance, see that it’s $80, then enter a $50 payment into the system and finally navigate to the patient’s profile and verify that the patient’s balance is now $30 instead of $80.
If you’ve verified that adding a $50 payment entry decreases a patient’s balance by $50, then you’ve also verified that a has_many :payment_entries association exists , since the feature can’t work without the payment_entries association. So having an additional test solely for the payment_entries association would be redundant and superfluous.
Notice how the behavior test has nothing to do with how the feature is actually coded. We don’t care how it works, we only care that it works.
Common errors of testing implementation instead of behavior
• Verifying that validations exist • Verifying that associations exist • Verifying that a class responds to certain methods • Verifying that callbacks exist • Verifying that a database table has certain columns and indexes
These are all examples of testing implementation rather than behavior. All such tests are quite frankly, pointless. Instead of testing these things directly, it’s more helpful to test the behaviors that these things enable.
The value of loose coupling
One benefit of testing behavior rather than implementation is loose coupling. Two things are loosely coupled to the degree that you can change one without having to change the other.
If you write tests that test implementation, you’ve guaranteed tight coupling. Loose coupling is only possible with tests that test behavior.
Testing that a model responds to certain methods
it { expect(factory_instance).to respond_to(:public_method_name) }
There’s negligible value in simply testing that a model responds to a method. Better to test that that method does the right thing.
Testing for the presence of callbacks
it { expect(user).to callback(:calculate_some_metrics).after(:save) }
it { expect(user).to callback(:track_new_user_signup).after(:create) }
Don’t verify that the callback got called. Verify that you got the result you expected the callback to produce.
Tips for writing valuable RSpec tests
Here’s how I tend to write model specs: for every method I create on a model, I try to poke at that method from every possible angle and make sure it returns the desired result.
For example, I recently added a feature in an application that made it impossible to schedule an appointment for a patient who has been inactivated. So I wrote three test cases:
It may seem obvious what a Rails model is. To many Rails developers, the model is the MVC layer that talks to the database. But in my experience, there are actually a lot of different conceptions as to what Rails models are, and not all of them agree with each other. I think it’s important for us to firmly establish what a Rails model is before we start talking about how to test Rails models.
To me, a model is an abstraction that represents a small corner of reality in a simplified way. Models exist to make it easier for a programmer to think about and work with the concepts in a program. Models are not a Rails idea or even an OOP idea. A model could be represented in any programming language and in any programming paradigm.
Why model specs are different from other types of specs
Because models aren’t a Rails idea but rather a programming idea, testing models in Rails isn’t that conceptually different from testing models in any other language or framework. In a way this is a great benefit to a learner because it means that if you know how to test models in one language, your testing skills will easily translate to any other language.
System specs are relatively easy to get started with because you can more or less follow a certain step-by-step formula for writing system specs for CRUD features and be well on your way. There’s not as much of a step-by-step formula for writing model tests
The tutorial
Here are the things you can expect to have a better understanding of after completing this tutorial.
The scenario
We want our phone number model to be able to take phone numbers in any of the following formats:
555-856-8075 (555) 856-8075 +1 555 856 8075
And strip them down to look like this:
5558568075
Our PhoneNumber class won’t know anything about databases, it will just be responsible for converting a “messy” phone number to a normalized one.
Our first test
A big part of the art of model testing is coming up with various scenarios and deciding how our code should behave under those scenarios.
The first scenario we’ll test here is: “When we have a phone number where the digits are separated by dashes, the dashes should all get stripped out.”
require_relative './phone_number.rb'
RSpec.describe PhoneNumber do
# test for scenario 1
context "phone number contains dashes" do
it "strips out the dashes" do
phone_number = PhoneNumber.new("555-856-8075")
expect(phone_number.value).to eq("5558568075")
end
end
# test for scenario 2
context "phone number contains parentheses" do
it "strips out the non-numeric characters" do
phone_number = PhoneNumber.new("(555) 856-8075")
expect(phone_number.value).to eq("5558568075")
end
end
# test for scenario 3
context "phone number contains country code " do
it "strips out the country code" do
phone_number = PhoneNumber.new("+1 555 856 8075")
expect(phone_number.value).to eq("5558568075")
end
end
end
class PhoneNumber
attr_reader :value
EXPECTED_NUMBER_OF_DIGITS = 10
def initialize(value)
@value = value.gsub(/\D/, "").split("").last(EXPECTED_NUMBER_OF_DIGITS).join
end
end
Learning objectives
The scenario
We’ll be working on the exact same scenario as Part One: normalizing messy phone numbers. We’ll even be using all the exact same test cases. The reason we’re keeping those things the same is to show the Rails-models-versus-POROs differences in sharp relief.
The PhoneNumber model
$ rails g model phone_number value:string
$ rails db:migrate
# spec/models/phone_number_spec.rb
require "rails_helper"
RSpec.describe PhoneNumber, type: :model do
# Our first test case
context "phone number contains dashes" :model do
it "strips out the dashes" do
phone_number = FactoryBot.create(:phone_number, value: "555-856-8075")
expect(phone_number.value).to eq("5558568075")
end
end
# Our second test case
context "phone number contains parentheses" do
it "strips out non-numeric characters" do
phone_number = FactoryBot.create(:phone_number, value: "(555) 856-8075")
expect(phone_number.value).to eq("5558568075")
end
end
# Our third test case
context "phone number contains country code" do
it "strips out country code" do
phone_number = FactoryBot.create(:phone_number, value: "+1 555 856 8075")
expect(phone_number.value).to eq("5558568075")
end
end
end
# app/models/phone_number.rb
class PhoneNumber < ApplicationRecord
before_validation :strip_non_numeric_from_value
EXPECTED_NUMBER_OF_DIGITS = 10
def :strip_non_numeric_from_value
self.value = self.value.gsub(/\D/, "").split("").last(EXPECTED_NUMBER_OF_DIGITS).join
end
end
Refactoring
Rather than repeatedly creating a new phone_number variable using FactoryBot.create, we can DRY up our code a little by putting the FactoryBot.create in a let! block at the beginning and then updating the phone number value for each test.
require "rails_helper"
RSpec.describe PhoneNumber, type: :model do
let!(:phone_number) do
FactoryBot.create(:phone_number)
end
context "phone number contains dashes" do
before { phone_number.update!(value: "555-856-8075") }
it "strips out the dashes" do
expect(phone_number.value).to eq("5558568075")
end
end
context "phone number contains parentheses" do
before { phone_number.update!(value: "(555) 856-8075") }
it "strips out the non-numeric characters" do
expect(phone_number.value).to eq("5558568075")
end
end
context "phone number contains country code" do
before { phone_number.update!(value: "+1 555 856 8075") }
it "strips out the country code" do
expect(phone_number.value).to eq("5558568075")
end
end
end
Takeaways Rails model tests can be written by coming up with a list of desired behaviors and translating that list into test code.
When learning how to write Rails model tests, it can be helpful to first do some tests with plain old Ruby objects (POROs) for practice.
Writing tests before we write the application code can make the process of writing the application code easier.