[RUBY] Set RSpec to DRY

It is a memorandum.

References Everyday Rails-Introduction to Rails Testing with RSpec https://leanpub.com/everydayrailsrspec-jp

Support module

It is a method to eliminate code duplication, and standardizes processing (login processing, etc.). For example, if there are five tests that perform the login process in advance, when the login button is changed, five changes are required. Separating this into a single module improves readability while keeping modifications to a minimum.

Try separating the following login process into modules

#Get login screen
visit root_path
#Click sign in
click_link "Sign in"
#Enter the user's email address in the Email text field
fill_in "Email", with: user.email 
#Enter password as above
fill_in "Password", with: user.password
#Click the login button
click_button "Log in"

Suppose the code in the methods described in this module is scattered all over the test.

To separate this code, create spec / supprot / login_support.rb and modularize it.

login_support.rb


module LoginSupport
 def sign_in_as(user)
    visit root_path
    click_link "Sign in"
    fill_in "Email", with: user.email
    fill_in "Password", with: user.password
    click_button "Log in"
 end
#Read entire RSpec
RSpec.configure do |config|
  config.include LoginSupport
end
↓
#If you want to read it explicitly for each test, in the spec file you want to read
include LoginSupport
#If so, it will be read.

Now you can call the methods (helper methods) in the module. Let's compare before and after rewriting. Everyday Rails-Borrow the code to get started with Rails testing with RSpec.

Before replacement

projects_spec.rb


require 'rails_helper'

RSpec.feature "Projects", type: :feature do
  scenario "user creates a new project" do
    user = FactoryBot.create(:user)

  #from here
    visit root_path
    click_link "Sign in"
    fill_in "Email", with: user.email
    fill_in "Password", with: user.password
    click_button "Log in"
  #Replace up to here with helper method

    expect {
      click_link "New Project"
      fill_in "Name", with: "Test Project"
      fill_in "Description", with: "Trying out Capybara"
      click_button "Create Project"

      expect(page).to have_content "Project was successfully created"
      expect(page).to have_content "Test Project"
      expect(page).to have_content "Owner: #{user.name}"
    }.to change(user.projects, :count).by(1)
  end
end

After replacement

projects_spec.rb


require 'rails_helper'

RSpec.feature "Projects", type: :feature do
  scenario "user creates a new project" do
    user = FactoryBot.create(:user)

  #from here

    sign_in_as(user)
  #Or
  sign_in_as user
   #But OK

  #I replaced the above with a helper method

    expect {
      click_link "New Project"
      fill_in "Name", with: "Test Project"
      fill_in "Description", with: "Trying out Capybara"
      click_button "Create Project"

      expect(page).to have_content "Project was successfully created"
      expect(page).to have_content "Test Project"
      expect(page).to have_content "Owner: #{user.name}"
    }.to change(user.projects, :count).by(1)
  end
end

The important thing here is to make the method name easy to understand. It is important to keep in mind a name that you can easily understand, because you cannot understand the operation by the name of the method, and going to refer to the original file will only make the test inconvenient.

Lazy loading with let

In some cases, the data required for the test is set up before the test (data creation process such as user creation) before executing describe or context. Since before is executed every time describe or context is executed, the following problems may occur. -Because it is executed every time, it may have an unexpected effect on the test. -There is a high risk that the performance of the test will be reduced because it is executed every time. -The amount of code written increases in proportion to the amount of test, which impairs cost and readability.

Lazy loading by let clears this. Since let is executed only when it is called, the above problem can be solved. Let's compare before and after rewriting. Everyday Rails-Borrow the code to get started with Rails testing with RSpec.

let unused

note_spec.rb


require 'rails_helper'

RSpec.describe Note, type: :model do
  before do
    #User created
    @user = User.create(
      first_name: "Joe",
      last_name:  "Tester",
      email:      "[email protected]",
      password:   "dottle-nouveau-pavilion-tights-furze",
    )
    #Project creation
    @project = @user.projects.create(
      name: "Test Project",
    )
  end

  it "Must be valid if there are users, projects, messages" do
    note = Note.new(
      message: "This is a sample note.",
      user: @user,
      project: @project,
    )
    expect(note).to be_valid
  end

  it "If there is no message, it is in an invalid state" do
    note = Note.new(message: nil)
    note.valid?
    expect(note.errors[:message]).to include("can't be blank")
  end

  describe "Search for messages that match the string" do
    before do
      @note1 = @project.notes.create(
        message: "This is the first note.",
        user: @user,
      )
      @note2 = @project.notes.create(
        message: "This is the second note.",
        user: @user,
      )
      @note3 = @project.notes.create(
        message: "First, preheat the oven.",
        user: @user,
      )
    end

    context "When finding matching data" do
      it "Returning notes that match the search string" do
        expect(Note.search("first")).to include(@note1, @note3)
      end
    end

    context "When no matching data is found" do
      it "Returning an empty collection" do
        expect(Note.search("message")).to be_empty
      end
    end
  end
end

use let

note_spec.rb


require 'rails_helper'

RSpec.describe Note, type: :model do
  #use let instead of before
  let(:user) { FactoryBot.create(:user) }
  let(:project) { FactoryBot.create(:project, owner: user) }

  it "Must be valid if there are users, projects, messages" do
    note = Note.new(
      message: "This is a sample note.",
      user: user,
      project: project,
    )
    expect(note).to be_valid
  end

  it "If there is no message, it is in an invalid state" do
    note = Note.new(message: nil)
    note.valid?
    expect(note.errors[:message]).to include("can't be blank")
  end

  describe "Search for messages that match the string" do
    #use let instead of before
    let(:note1) {
      FactoryBot.create(:note,
        project: project,
        user: user,
        message: "This is the first note."
      )
    }

    let(:note2) {
      FactoryBot.create(:note,
        project: project,
        user: user,
        message: "This is the second note."
      )
    }

    let(:note3) {
      FactoryBot.create(:note,
        project: project,
        user: user,
        message: "First, preheat the oven."
      )
    }

    context "When finding matching data" do
      it "Returning notes that match the search string" do
        expect(Note.search("first")).to include(note1, note3)
      end
    end

    context "When no matching data is found" do
      it "Returning an empty collection" do
        expect(Note.search("message")).to be_empty
      end
    end
  end
end

When using before, instance variables were used to cross the scope in the test, but in let, it is described by variables. Now that the test passes, one problem arises here. Add expect (Note.count) .to eq 3 to the last "returning an empty collection".

note_spec.rb


    context "When no matching data is found" do
      it "Returning an empty collection" do
        expect(Note.search("message")).to be_empty
     #Add this sentence
     expect(Note.count).to eq 3
      end
    end
  end
end

The above description does not pass the test. The reason is simple: let is lazy loading.

In short, no data is created unless called.

With expect (Note.count) .to eq 3, the test goes to the DB to find the data. Naturally, the DB is empty because let is not called. However, since the definition of expectation is that the number of data in note is equal to 3, the number of data is 0, the test does not pass, and an error occurs.

One way to solve this is to simply call let, An example using let! is shown below.

note_spec.rb


   #let instead of let!use
    let!(:note1) {
      FactoryBot.create(:note,
        project: project,
        user: user,
        message: "This is the first note."
      )
    }

    let!(:note2) {
      FactoryBot.create(:note,
        project: project,
        user: user,
        message: "This is the second note."
      )
    }

    let!(:note3) {
      FactoryBot.create(:note,
        project: project,
        user: user,
        message: "First, preheat the oven."
      )
    }

   #====abridgement=====

    context "When no matching data is found" do
      it "Returning an empty collection" do
        expect(Note.search("message")).to be_empty
     #Add this sentence
     expect(Note.count).to eq 3
      end
    end
  end
end

This will pass the test. Let! preloads while let is lazy loading. The image is the same as before, and data is created for each evaluation block.

This means that the problems mentioned above have not been cleared. In this case, whether to use before or let! Depends on the situation. From the point of view of myself learning, I feel that it is okay to use before and let properly.

As a finding, basically let! I want to proceed in the direction of not using. I'd like to leave it to before when pre-loading is necessary, rather than making misreading or pocha mistakes because there is only a subtle difference between let and let !.

shared_context (sharing context)

You can use shared_context to do the required setup with multiple test files. The usage is similar to the support module. Let's compare before and after rewriting. Everyday Rails-Borrow the code to get started with Rails testing with RSpec.

tasks_controller.rb


require 'rails_helper'

RSpec.describe TasksController, type: :controller do
  #Summarize the setup here
  let(:user) { FactoryBot.create(:user) }
  let(:project) { FactoryBot.create(:project, owner: user) }
  let(:task) { project.tasks.create!(name: "Task task") }

  describe "#show" do
    it "responds with JSON formatted output" do
      sign_in user
      get :show, format: :json,
        params: { project_id: project.id, id: task.id }
      expect(response.content_type).to eq "application/json"
    end
  end

  describe "#create" do
    it "responds with JSON formatted output" do
      new_task = { name: "New test task" }
      sign_in user
      post :create, format: :json,
        params: { project_id: project.id, task: new_task }
      expect(response.content_type).to eq "application/json"
    end

    it "adds a new task to the project" do
      new_task = { name: "New test task" }
      sign_in user
      expect {
        post :create, format: :json,
          params: { project_id: project.id, task: new_task }
      }.to change(project.tasks, :count).by(1)
    end

    it "requires authentication" do
      new_task = { name: "New test task" }
      # Don't sign in this time ...
      expect {
        post :create, format: :json,
          params: { project_id: project.id, task: new_task }
      }.to_not change(project.tasks, :count)
      expect(response).to_not be_success
    end
  end
end

Create a separate file and combine the 3 lines of let with shared_context Create spec / support / contexts / project_setup.rb

project_setup.rb


RSpec.shared_context "project setup" do
  let(:user) { FactoryBot.create(:user) }
  let(:project) { FactoryBot.create(:project, owner: user) }
  let(:task) { project.tasks.create!(name: "Task task") }
end

Go back to tasks_controller.rb and rewrite the let part.

tasks_controller.rb


require 'rails_helper'

RSpec.describe TasksController, type: :controller do
  #Delete let and project_setup.Include rb.
  include_context "project setup"

#====Omitted below=====

Now you have multiple test files to combine the required setups into one.

Summary

・ Support module Common processing such as helper methods can be cut out in a separate file and included if necessary. ・ Let You can create the data used for the test. Impact on testing due to lazy loading Can be expected to improve performance. ・ Shared_context It is possible to put together the setup used for testing. Cut it out to another file and use it as an include.

Thank you for staying with us until the end. If you have any suggestions or advice, I would appreciate it if you could comment.

Recommended Posts

Set RSpec to DRY
Introduction to RSpec 1. Test, RSpec
Introduction to RSpec 2. RSpec setup
[RSpec] How to test error messages set by Shoulda-Matchers
Introduction to RSpec 6. System specifications
Introduction to RSpec 3. Model specs
How to set Docker nginx
How to set Java constants
Things to set after installing RubyMine
How to set Spring Boot + PostgreSQL
[Circle CI 2.0] Set to support JavaScript
How to set Lombok in Eclipse
[RSpec] How to write test code
to_ ○
Set Spring Security authentication result to JSON
Easy way to set iOS app icon
[Note] How to get started with Rspec
I want to RSpec even at Jest!
How to set up and use kapt
[Java] How to set the Date time to 00:00:00
Steps to set a favicon in Rails
How to set JAVA_HOME with Maven appassembler-maven-plugin
I set Ubuntu 20.04 to recognize MX Master 3
Needed for iOS 14? How to set NSUserTrackingUsageDescription
How to write an RSpec controller test