Bốn cách để cải thiện và tăng tốc độ khi viết RSpec test

Tests là một phần rất quan trọng trong mỗi ứng dụng, tuy nhiên, thi thoảng sẽ rất khó để giữ cho bộ test nhanh và rõ ràng, đặc biệt khi có nhiều lập trình viên từng làm trước hoặc đang làm cùng bạn trong dự án. Trong bài viết này, mình tập trung vào việc cải thiện RSpec tests bằng 2 cách: cấu trúc lại những đoạn test bị lặp lại nhiều lần và tăng tốc độ test bằng cách giảm request tới database.

Shared examples

Hãy xem xét ví dụ sau đây:

class AgePolicy
  def old_enough?(age)
    age >= 18
  end
end

Đây là một hàm đơn giản để giúp chúng ta kiểm tra xem người dùng đã đủ tuổi để thực hiện 1 hành động gì đó trong ứng dụng hay chưa. Chúng ta có một đoạn test vô cùng đơn giản cho class này:

require 'spec_helper'

describe AgePolicy do
  describe '#old_enough?' do
    it 'returns false if user is 16 years old' do
      policy = AgePolicy.new

      expect(policy.old_enough?(16)).to eq(false)
    end

    it 'returns false if user is 12 years old' do
      policy = AgePolicy.new

      expect(policy.old_enough?(12)).to eq(false)
    end

    it 'returns true if user is 18 years old' do
      policy = AgePolicy.new

      expect(policy.old_enough?(18)).to eq(true)
    end

    it 'returns true if user is 20 years old' do
      policy = AgePolicy.new

      expect(policy.old_enough?(20)).to eq(true)
    end
  end
end

Như bạn thấy, điều duy nhất chúng ta test là tuổi. Đây là một trường hợp mà ta có thể dùng shared_examples được cung cấp bởi framework RSpec

require 'spec_helper'

describe AgePolicy do
  describe '#old_enough?' do
    shared_examples 'user eligible for taking an action' do |age|
      it "returns true if user is #{age} years old" do
        policy = AgePolicy.new

        expect(policy.old_enough?(age)).to eq(true)
      end
    end

    shared_examples 'user not eligible for taking an action' do |age|
      it "returns false if user is #{age} years old" do
        policy = AgePolicy.new

        expect(policy.old_enough?(age)).to eq(false)
      end
    end

    it_behaves_like 'user not eligible for taking an action', 16
    it_behaves_like 'user not eligible for taking an action', 12
    it_behaves_like 'user eligible for taking an action', 18
    it_behaves_like 'user eligible for taking an action', 20
  end
end

Mặc dù giờ nhìn hơi khó đọc hơn, nhưng chúng ta sẽ tránh được việc lặp code trong mỗi ví dụ, test cũng sẽ nhanh hơn 1 chút.

Custom matchers

RSpec có rất nhiều matcher hữu dụng. Thỉnh thoảng, có những giá trị mà chúng ta expect được lặp đi lặp lại trong code. Một ví dụ rất tốt cho trường hợp này là khi ta test response của controller. Hãy xem xét ví dụ dưới đây

class SomeController
  def show
    render json: { success: true }
  end
end

Giờ làm sao để test nếu nó trả về thuộc tính success với giá trị true? Nó sẽ như thế này

describe SomeController do
  describe 'GET #show' do
    it 'returns success response' do
      get :show, id: 11, format: :json
      
      expect(JSON.parse(response.body)).to eq({success: true})
    end
  end
end

Sẽ đơn giản hơn nhiều nếu như chỉ viết thế này

expect(response).to be_json_success

Để tạo ra một matcher như vậy, bạn tạo file matchers.rb (tên tùy ý) trong thư mục spec/support và mô tả matcher của bạn trong đó:

RSpec::Matchers.define :be_json_success do |expected|
  match do |actual|
    json_response = JSON.parse(actual.body)
    expect(json_response['success']).to eq(true)
  end
end

Bước cuối cùng là thêm dòng require 'support/matchers' trong file spec_helper.rb

Loại bỏ các association không cần thiết khỏi factories

Giả sử bạn có model User và mỗi user có một bản ghi ContactLocation. Factory của bạn trông sẽ tương tự như vậy:

FactoryGirl.define do
  factory :user do
    contact
    location  
  end
end

Mỗi lần bạn sử dụng FactoryGirl.create :user hay FactoryGirl.build :user, sẽ đồng thời có thêm 2 bản ghi nữa được tạo ra, kể cả có dùng build. Nếu bạn chỉ muốn tạo ra user mà không cần bất kì bản ghi association nào, bạn nên sử dụng FactoryGirl.build_stubbed :user thay thế.

Giờ hãy thử tưởng tượng bản sử dụng 1 factory cho mọi case test. Nếu bạn thật sự luôn cần cả associations thì không vấn đề gì, nhưng nếu thỉnh thoảng mới cần đến associations thì có thể xem xét sử dụng traits để tăng tốc độ của test:

FactoryGirl.define do
  factory :user do
    first_name { "John" }
    last_name { "Doe" }
    
    trait :with_location do
      location
    end
  end
end

Và bạn sử dụng bằng cách gọi FactoryGirl.create :user, :with_location.

Sử dụng Rails transactional để test feature

Bao nhiêu lần bạn chỉ sử dụng 1 bản ghi cho mọi case test? Hãy sử dụng lại factory user ở phần trên để viết 1 test đơn giản:

require 'spec_helper'

describe User do
  let!(:user) { FactoryGirl.create :user }
  
  it 'does something' do
    # test with user
  end
  
  it 'does something' do
    # test with user
  end
  
  it 'does something' do
    # test with user
  end
end

Bao nhiêu bản ghi user được tạo trong cơ sở dữ liệu? Một bản ghi cho mỗi example vì vậy câu trả lời là 3. Để tránh việc này bạn sử dụng helper let_it_be của gem test-prof. Helper này sử dụng Rails transactional tests feature, có nghĩa là nó sẽ tạo ra duy nhất 1 bản ghi khi bắt đầu case test và xóa đi khi test xong. Nếu gem đã được cài đặt, bạn phải thêm require 'test_prof/recipes/rspec/let_it_be' vào spec_helper.rb và helper đã sẵn sàng sử dụng.

require 'spec_helper'

describe User do
  let_it_be(:user) { FactoryGirl.create :user }
  
  it 'does something' do
    # test with user
  end
  
  it 'does something' do
    # test with user
  end
  
  it 'does something' do
    # test with user
  end
end

Nếu như bạn dùng cùng 1 bản ghi cho nhiều example thì nó thậm chí có thể giúp bạn tăng tốc độ lên 50%. Đừng quên bạn cũng có thể sử dụng stubs nếu không thực sự cần thiết phải lấy dữ liệu từ database.

Nguồn: http://pdabrowski.com/blog/ruby-on-rails/testing/4-ways-to-refactor-and-speed-up-tests/