0

9 tip để cải thiện khả năng maintain RSpec

Song song với việc viết code, thì để đảm bảo code đó đảm bảo về mặt logic, đúng spec hơn, ít lỗi hơn và có khả năng maintain hơn thì việc viết unit test là một việc quan trong không hề kém, việc này đôi khi quan trọng như là viết code vậy. Với Ruby on Rails khi code thì việc viết Rspec cũng là công việc song hành, vậy làm sao để viết Rspec cũng dễ dàng maintain có tính mở rộng thì sau đây mình sẽ đưa ra 9 tips bạn đọc có thể xem có thể áp dụng với dự án hiện tại của mình không nhé.

Có 2 nguyên tắc để xây dựng nên những tip này, đó là:

  1. DRY — Don’t Repeat Yourself principle
  2. Use the right tool at the right place for the right purpose

1. Cấu trúc code đúng vào vị trí của nó

Thường thì có 3 khối cơ bản cho các test case đó là

  • Setup — before, let, let!
  • Assert — it
  • Teardown — after

Code phải được cấu trúc phù hợp thành các block phù hợp.

# BAD
#
describe '#sync' do
  it 'updates the local account balance' do
    local_account.open
    transfer(1000)
    expect { local_account.sync }.to change { local_account.balance }.by(1000)
    widthdraw_all
    local_account.close
  end
end

# GOOD
#
describe '#sync' do
  subject { local_account.sync }

  before do # Setup
    local_account.open
    transfer(1000)
  end
  
  after do # Teardown
    widthdraw_all
    local_account.close
  end

  it 'updates the local account balance' do # Assert
    expect { subject }.to change { local_account.balance }.by(1000)
  end
end

2. Hạn chế mock global classes/modules/objects

Global classes/modules/objects có xu hướng được sử dụng ở nhiều nơi nằm ngoài scope test hiện tại. Mocking những thành phần đó sẽ vi phạm nguyên tắc isolation principle của unit testing, điều này sẽ dẫn đến tác dụng phụ.

Quy tắc này đặc biệt đúng khi mock phương thức new của các class.

# BAD
#
class UserService
  attr_reader :user

  def verify_email
    # ...
    email_service = EmailService.new(user)
    email_service.send_confirmation_email
    # ...
  end
end

describe UserService do
  describe '#verify_email' do
    before do
      email_service = double(:email_service)
      # BAD: Mocking `new` method of EmailService
      allow(EmailService).to receive(:new).and_return(email_service)
      allow(email_service).to receive(:send_confirmation_email)
    end
  end
end


# GOOD
#
class UserService
  attr_reader :user

  def verify_email
    email_service = generate_email_service
    email_service.send_confirmation_email
  end

  private

  # You can also use memoization if ONLY 1 instance of EmailService is needed
  def generate_email_service
    EmailService.new(user)
  end
end

describe UserService do
  describe '#verify_email' do
    before do
      email_service = double(:email_service)
      # GOOD: Mocking its own method `generate_email_service`
      allow(described_class).to receive(:generate_email_service).and_return(email_service)
      allow(email_service).to receive(:send_confirmation_email)
    end
  end
end

3. Sử dụng instance_double thay vì double

Khi bạn muốn tạo một mock instance của một class, instance_double là một lựa chọn an toàn hơn. Khác với double, instance_double sẽ đưa ra các exceptions nếu các mocked behaviors được thực hiện dưới dạng các instance method của provided class. Điều này cho phép chúng ta nắm bắt các vấn đề sâu hơn so với việc sử dụng double.

class FootballPlayer
  def shoot
    # ...shoot...
  end
end

messi = instance_double(FootballPlayer)
allow(messi).to receive(:shoot) # OK
allow(messi).to receive(:shoot).with('power') # Wrong numbers of arguments
allow(messi).to receive(:score) # Player does not implement: score

ronaldo = double('FootballPlayer')
allow(ronaldo).to receive(:shoot) # OK
allow(ronaldo).to receive(:shoot).with('power') # OK - but silent failure
allow(ronaldo).to receive(:score) # OK - but silent failure

4. Sử dụng DESCRIBE cho testing targets và CONTEXT cho các tình huống(scenarios)

Nó chỉ là một cách để làm cho code của mình nghe trôi chảy hơn.

describe UserStore do
  describe '.create' do
    context 'when user does not exists' do
      it 'creates a new user' do
        # ...
      end

      describe 'the newly created user' do
        it 'has the correct attributes' do
          # ...
        end
      end
    end

    context 'when user already exists' do
      it 'raises error' do
        # ...
      end
    end
  end
end

5. Viết code implement DESCRIBE và CONTEXT ngay bên dưới statement

Điều này rất quan trọng để đảm bảo các test được thiết lập theo các described contexts. Điều này cũng giúp phân biệt context này với context khác.

describe 'FootballPlayer' do
  let(:speed) { 50 }
  let(:shooting) { 50 }

  let(:player) do
    create(:football_player,
      speed: speed,
      shooting: shooting,
    )
  end

  describe '#position' do
    subject { player.position } 

    context 'when the player is fast' do
      let(:speed) { 98 } # implements 'when the user is fast'

      it { is_expected.to eq 'winger' }
    end

    context 'when the player shoots well' do
      let(:shooting)  { 90 } # implements 'when the player shoots well'

      it { is_expected.to eq 'striker' }
    end

    context 'when the player is injured' do
      before { player.injure } # implements `when the player is injured`

      it { is_expected.to eq 'benched' }

      context 'when the player uses doping' do # both injured and using doping
        before { player.use_doping }
        
        it { is_expected.to eq 'midfielder' }
      end
    end
  end
end

6. Sử dụng bulk mothods nếu có thể

# BAD
it 'has correct attributes' do
  expect(user.name).to eq 'john'
  expect(user.age).to eq 20
  expect(user.email).to eq '[email protected]'
  expect(user.gender).to eq 'male'
  expect(user.country).to eq 'us'
end

# GOOD
it 'has correct attributes' do
  expect(user).to have_attributes(
    name: 'john',
    age: 20,
    email: '[email protected]',
    gender: 'male',
    country: 'us',
  )
end

7. Hiểu cách transactions hoạt động trong RSpec

Theo mặc định, các transaction được tạo và bao quanh mỗi example. Điều này cho phép tất cả các database operation bên trong một example được roll back để đảm bảo một clean slate cho example tiếp theo.

Việc tạo database record bên trong các hooks nhất định như trước (: context) hoặc trước (: all) sẽ không được khôi phục bởi các transactions mặc định đã đề cập ở trên. Điều này sẽ dẫn đến dữ liệu bị cũ.

context 'context 1' do
  before(:context) do
    create(:user) # WON'T BE ROLLED-BACK
  end
  
  before do
    create(:user) # will be rolled-back
  end

  # ...
end

context 'context 2' do
  before(:context) do
    create(:user) # WON'T BE ROLLED-BACK
  end

  # ...
end

# BY NOW, THERE ARE 2 USER RECORDS COMMITED TO DATABASE

8. Hạn chế sử dụng expect cho mocking

Mặc dù expect có thể được sử dụng cho mục đích mocking, expect là command chính thức cho các assertion.

Còn allow là công cụ chính xác để mock.

Hơn nữa, chúng ta hãy tự nhắc mình rằng mocking là một phần của giai settup test, KHÔNG phải giai đoạn assertion .

# BAD: expect...and_return
it 'returns the sync value' do
  expect(service).to receive(:sync).and_return(value) # mix between setup and assertion
  expect(subject).to eq value
end

# GOOD
before do 
  allow(service).to receive(:sync).and_return(value) # Set up
end

describe 'the service' do
  it 'syncs' do 
    expect(service).to receive(:sync) # assert
  end
end

it { is_expected.to eq value } # assert

9. Sử dụng configs cho các test case dạng khuôn mẫu.

Nó sẽ DRY và dễ dàng hơn cho người đọc theo dõi.

# BAD
#
describe '.extract_extension' do
  subject { described_class.extract_extension(filename) }
  
  context 'when the filename is empty' do
    let(:filename) { '' }
    it { is_expected.to eq '' }
  end

  context 'when the filename is video123.mp4' do
    let(:filename) { 'video123.mp4' }
    it { is_expected.to eq 'mp4' }
  end

  context 'when the filename is video.edited.mp4' do
    let(:filename) { 'video.edited.mp4' }
    it { is_expected.to eq 'mp4' }
  end

  context 'when the filename is video-edited' do
    let(:filename) { 'video-edited' }
    it { is_expected.to eq '' }
  end

  context 'when the filename is .mp4' do
    let(:filename) { '.mp4' }
    it { is_expected.to eq '' }
  end
end


# GOOD
#
describe '.extract_extension' do
  subject { described_class.extract_extension(filename) }

  test_cases = [
    '' => '',
    'video123.mp4' => 'mp4'
    'video.edited.mp4' => 'mp4'
    'video-edited' => ''
    '.mp4' => ''
  ]

  test_cases.each do |test_filename, extension|
    context "when filename = #{test_filename}" do
      let(:filename) { test_filename }
      it { is_expected.to eq extension }
    end
  end
end

Kết

Đây là một số thứ tôi đã đúc kết ra khi làm việc với rspec mong bạn đọc có thể áp dụng nó chút nào đó vào dự án của mình.

Bài viết được tham khảo từ: 9 tips to improve RSpec maintainability


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.