[For Newbie] Những kiến thức không thể thiếu nếu muốn sử dụng RSpec trong Rails app
RSpec là gì? RSpec là một trong những testing framework phổ biến nhất cho Ruby. RSpec-rails là một mở rộng của RSpec, cho phép bạn viết unit tests cho controller, views, helpers và model trong ứng dụng Rails. Nó cũng cung cấp khả năng viết integration tests với selenium (sử dụng ...
RSpec là gì?
RSpec là một trong những testing framework phổ biến nhất cho Ruby.
RSpec-rails là một mở rộng của RSpec, cho phép bạn viết unit tests cho controller, views, helpers và model trong ứng dụng Rails.
Nó cũng cung cấp khả năng viết integration tests với selenium (sử dụng capybara).
Cài đặt và khởi tạo RSpec-rails
Cài đặt
Thêm rspec-rails vào cả 2 môi trường :development và :test trong Gemfile:
group :development, :test do gem 'rspec-rails', '~> 3.7' end
Thêm vào cả :development là để khi generate model/controller, file spec cho model/controller đó sẽ tự động được thêm vào mà không cần dùng RAILS_ENV=test.
Đây là version mới nhất tại thời điểm của bài viết này.
Nó sẽ cài đặt những gems sau: rspec, rspec-core, rspec-expectations, rspec-mocks và rspec-rails
Khởi tạo và chạy Rpec
Khởi tạo thư mục spec/ (nơi chứa các file specs) với cmd:
rails generate rspec:install
Nó sẽ thêm những files sau đây được dùng cho việc cấu hình:
- .rspec
- spec/rails_helper.rb
- spec/spec_helper.rb
Để chạy các file specs, sử dụng cmd:
bundle exec rspec
Mặc định, câu lệnh trên sẽ chạy tất cả những file _spec.rb trong thư mục spec/
Nếu muốn chạy chỉ một file spec cụ thể nào đó, bạn sử dụng cmd giống như:
bundle exec rspec spec/models/book_spec.rb
Cấu trúc cơ bản của spec file
describe/it
Rspec sử dụng những từ như "describe" và "it" để thể hiện hành vi, tổ chức của codes giống như một cuộc hội thoại:
"Describe a bank account when it is first opened." "It has a balance of zero."
Phương thức describe tạo ra một example group. Bên trong describe block, bạn có thể khai báo các nested groups sử dụng describe hoặc context, hoặc bạn có thể khai báo các examples sử dụng it.
Ví dụ:
Một group, một example:
RSpec.describe "something" do it "does something" do end end
Nested example group (sử dụng context):
RSpec.describe "something" do context "in one context" do it "does one thing" do end end end
Hãy rõ ràng với những methods mà bạn đang mô tả.
Lời khuyên cho bạn khi sử dụng describe với class & methods:
- describe "ClasName" do end; - describe "#instance_method" do end; - describe ".class_method" do end;
Ví dụ:
# lib/calculator.rb: class Calculator def sum a, b a + b end end
# spec/calculator_spec.rb: require 'rails_helper' require 'calculator' RSpec.describe Calculator do describe '#sum' do it 'return sum of 2 numbers' do calc = Calculator.new result = calc.sum(1, 2) expect(result).to eq(3) # là một expectation end end end
Sử dụng context
Context là một phương pháp tốt giúp cho tests của bạn trở nên rõ ràng, dễ đọc và có tổ chức tốt hơn.
Ví dụ ta có một đoạn test như sau:
it 'has 200 status code if logged in' do expect(response).to respond_with 200 end it 'has 401 status code if not logged in' do expect(response).to respond_with 401 end
=> Bad codes. Không sử dụng if bên trong it. Thay vào đó hãy sử dụng context.
Đoạn test trên sẽ được viết lại với context như sau:
context 'when logged in' do it { expect(response).to respond_with 200 } end context 'when logged out' do it { expect(response).to respond_with 401 } end
Note: Tên context nên được bắt đầu với "when" hoặc "with"
Expectation
Thư viện rspec-expectations được sử dụng để xác định những kết quả mong đợi của việc test.
Cấu trúc cơ bản của một rspec expectation:
expect(actual).to matcher(expected) expect(actual).not_to matcher(expected)
Note: Bạn cũng có thể viết expect(..).to_not thay vì expect(..).not_to tùy ý
Ví dụ:
expect(10).to eq(10) expect(10).not_to eq(9) expect(10).to_not eq(11)
Matchers
rspec-expectations cung cấp một số các matchers phục vụ cho việc xác định kết quả mong đợi. Mỗi matcher có thể sử dụng với expect(..).to hay expect(..).not_to.
Một số matchers thường sử dụng:
expect(actual).to be(expected) # passes if actual.equal?(expected) expect(actual).to eq(expected) # passes if actual == expected expect(actual).to eql(expected) # passes if actual.eql?(expected) expect(actual).to equal(expected) # passes if actual.equal?(expected)
expect(5).to be >= 3 expect("Welcome to RSpec").to match(/RSpec$/) expect(dog).to be_instance_of(Dog) # dog.class = Dog expect(dog_is_a_friend).to be true expect(user.email).to be_nil # user.email = nil expect(str).to start_with("foo").and end_with("bazz") # str = 'foo bar bazz' expect([]).to be_empty expect([1, 3, 5]).to include(1, 3) Expect error: expect { … }.to raise_error(ErrorClass) expect([2, 4, 6]).to contain_exactly(6,2,4) expect([2, 4, 6]).to match_array([2, 6, 4]) Theo dõi sự thay đổi: expect { object.action }.to change(object, :value).from(old).to(new) expect { object.action }.to change(object, :value).by(delta) expect { a += 1 }.to change { a }.by(1) // a thay đổi 1 đơn vị expect { a += 3 }.to change { a }.from(2).to(5) // Ban đầu a = 2
Đọc thêm: Matchers Documentation
Ngoài ra chúng ta cũng có thể custom một matcher:
require 'rspec/expectations' RSpec::Matchers.define :be_a_multiple_of do |expected| match do |actual| actual % expected == 0 end end RSpec.describe 9 do it { expect(9).to be_a_multiple_of(3) } end RSpec.describe 9 do it { expect(9).not_to be_a_multiple_of(4) } end
Subject
Implicitly subject:
Khi argument đầu tiên của example group ngoài cùng là một class, thì một instance của class đó sẽ được truyền đến mỗi example thông qua subject.
Ví dụ:
RSpec.describe Array do describe "when first created" do it "should be empty" do expect(subject).to be_empty # subject ở đây là [] end end end
Explicit subject:
Ở đây chúng ta xác định một giá trị rõ ràng được trả về bởi subject method trong một example.
Ví dụ:
RSpec.describe Array, "with some elements" do subject { [1, 2, 3] } it "has the expected elements" do expect(subject).to eq([1, 2, 3]) end end
Cú pháp One-liner:
- is_expected.to tương đương với expect(subject).to
- should
Ví dụ:
RSpec.describe Array do describe "when first created" do it { should be_empty } # or it { is_expected.to be_empty } end end
Before & after hooks
Sử dụng before và after hook để thực hiện một đoạn codes nào đó trước/sau khi chạy một example:
before(:example) # chạy trước mỗi example before(:context) # chạy một lần duy nhất, trước tất cả examples trong một group after(:example) # chạy sau mỗi example after(:context) # chạy một lần duy nhất, sau tất cả examples trong một group
Ví dụ sau đây sẽ minh họa cho cách thực thi của chúng:
before(:context) # before(:context) run it { expect(5).to be >= 3 } it { expect("Welcome to RSpec").to match(/RSpec$/) } it { expect(dog).to be_instance_of(Dog) } before(:example) # before(:example) run it { expect(5).to be >= 3 } # before(:example) run it { expect("Welcome to RSpec").to match(/RSpec$/) } # before(:example) run it { expect(dog).to be_instance_of(Dog) }
before và after block được gọi theo thứ tự sau:
before :suite before :context before :example after :example after :context after :suite
Note:
- Setting một instance variable không hỗ trợ trong before(:suite)
- Mocks chỉ hỗ trợ trong before(:example)
- :example và :context scope tương đương với :each và :all
- Chúng ta chỉ cần viết before/after thôi, không cần before(:each)/after(:each)
before(:each)
Ví dụ ta có một class là Contact:
class Contact def names @names ||= [] end end
Một minh họa cho việc sử dụng before hook:
RSpec.describe Contact do before { @contact = Contact.new } describe "initialized in before(:each)" do it "has 0 contacts" do expect(@contact.names.count).to eq(0) end it "can accept new contact" do @contact.names << "New contact" end it "still has 0 contacts" do expect(@contact.names.count).to eq(0) end end end
before(:all)
RSpec.describe Contact do before(:all) { @contact = Contact.new } describe "initialized in before(:all)" do it "has 0 contacts" do expect(@contact.names.count).to eq(0) end it "can accept new contact" do @contact.names << "New contact" end it "has 1 contact" do expect(@contact.names.count).to eq(1) end end end
Helper methods:
Let và let!
Khi bạn phải assign một variable, thay vì sử dụng before block để tạo một instance variable như lúc nãy đã làm, hãy sử dụng let.
Khi sử dụng let, variable đó sẽ được lazy load, có nghĩa là chỉ khi nào nó thực sự được gọi, nó mới được tạo ra, và nó sẽ được cache lại cho đến khi test kết thúc.
Sử dụng let! khi bạn muốn định nghĩa một variable ngay sau khi block đó được định nghĩa.
Let method
Ví dụ:
RSpec.describe "let method" do let(:foo) do puts "define foo variable" "cute dog" end it "has expected result" do puts "begin it block" expect(foo).to eq("cute dog") end end
Kết quả:
let method
begin it block
define foo variable
has expected result
Let! method
Ví dụ:
RSpec.describe "let! method" do let!(:foo) do puts "define foo variable" "cute dog" end it "has expected result" do puts "begin it block" expect(foo).to eq("cute dog") end end
Kết quả:
let! method define foo variable begin it block has expected result
Note: Giá trị định nghĩa bên trong let chỉ được cache lại thông qua nhiều lần gọi trong cùng một example, chứ không được thông qua nhiều example khác nhau. Ví dụ:
RSpec.describe "let method" do let(:foo) do puts "define foo variable" "cute dog" end it "memoizes the value" do expect(foo).to eq("cute dog") expect(foo).to eq("cute dog") end it "is not cached across examples" do expect(foo).to eq("cute dog") end end
Kết quả:
let method
define foo variable
memoizes the value
define foo variable
is not cached across examples
Định nghĩa một helper method tùy ý
Chúng ta có thể định nghĩa các helper method trong các example group sử dụng def hoặc define_method của Ruby. Các helper methods đó được tiếp xúc với example trong cùng group mà chúng được định nghĩa hoặc ở những group lồng bên trong group đó.
Ví dụ:
RSpec.describe "define a helper method" do def help "foo" end it "has access to methods defined in its group" do expect(help).to be("foo") end end
described_class
Nếu argument đầu tiên của example group ngoài cùng là một class, class đó sẽ được truyền tới mỗi example thông qua described_class() method.
RSpec.describe Array do it "is available as described_class" do expect(described_class).to eq(Array) end end
Shared examples
Hãy đến với một ví dụ đơn giản sau đây:
RSpec.describe "Test shared_examples" do context "with first test case" do let(:something) { "parameter1" } it "uses the given parameter" do expect(something).to eq("parameter1") end end context "with second test case" do let(:another_thing) { "parameter2" } it "uses the given parameter" do expect(another_thing).to eq("parameter2") end end end
=> Codes có sự trùng lặp. Trong những trường hợp như thế này, hãy sử dụng shared_examples để DRY tests của bạn.
Shared examples được sử dụng để mô tả hành vi của các class hoặc module. Sau khi được khai báo, nội dung của một shared group sẽ được lưu lại. Nó sẽ chỉ được thực thi khi được include vào trong một example group nào đó.
Một shared examples được include vào trong một examples group sử dụng:
include_examples "shared example name" it_behaves_like "shared example name"
Note: Không nên sử dụng include_examples đến một shared example nhiều lần trong cùng một example group.
Đoạn test ở trên được viết lại sử dụng shared_examples như sau:
RSpec.shared_examples "shared example" do |parameter| let(:something) { parameter } it "uses the given parameter" do expect(something).to eq(parameter) end end RSpec.describe "Test shared_examples" do context "with first test case" do it_behaves_like "shared example", "parameter1" end context "with second test case" do it_behaves_like "shared example", "parameter2" end end
Shared Context
Shared context cũng được sử dụng như một phương pháp để DRY tests của bạn. Cách sử dụng của nó có hơi khác một chút so với shared examples:
- Shared examples chứa một tập các examples
- Shared context thường sẽ không chứa các examples, mà chỉ chứa những đoạn codes chuẩn bị cho việc test, như helper method, before block, let, subject,..
- Sử dụng include_context để include một shared context vào trong một example group.
Ví dụ sử dụng shared_context:
RSpec.shared_context "shared example" do |parameter| before { @some_var = :some_value } def shared_method "this is shared method" end let(:shared_let) { "this shared let" } subject { "this is the subject" } end RSpec.describe "Test shared_examples" do include_context "shared context" it { expect(@some_var).to eq(:some_value) } it { expect(shared_method).to eq("this is shared method") } it { expect(shared_let).to eq("this shared let") } it { is_expected.to eq("this is the subject") } end
Stub/Mock data
Khi viết tests, đôi khi bạn sẽ muốn sử dụng những mock object thay vì sử dụng những real objects. Lý do là nếu bạn đang sử dụng ActiveRecord và tạo ra các real object, tests của bạn có thể sẽ tương tác với database và làm chậm tests của bạn.
Thư viện rspec-mocks giúp bạn điều khiển context trong một example bằng cách cung cấp các method stubs, fakes và message expectations trên các test-doubles và các đối tượng thực.
Test doubles
Một test double là một object được đơn giản hóa và sẽ thay thế một object khác trong hệ thống của bạn trong khi test. Tạo một test double:
book = double("book") another_book = double("book", title: "The first book") book.title # <Double "book"> received unexpected message :title with (no args) another_book.title # The first book
Note: Bạn phải xác định một method cụ thể cho test double đó trước khi gọi method đó, nếu không nó sẽ raise một exception.
Nếu bạn muốn chắc chắn rằng test double của bạn sẽ giống với một object đã có trong hệ thống, instance_double sẽ giúp bạn làm điều đó. Nếu một object đã tồn tại, chúng sẽ ngăn chặn việc bạn thêm stubs và expectations cho những methods không tồn tại hoặc có parameters không hợp lệ.
Ví dụ ta có một class Book, với một trường title:
book = instance_double(Book, title: "The first book") # => ok another_book = instance_double(Book, name: "Something") # => Failure/Error: the Book class does not implement the instance method: name
Method stubs
Một method stub là một câu lệnh để một object (thực hoặc test double) nhận một giá trị biết trước. rspec-mocks hỗ trợ 3 mẫu khai báo method stubs:
allow(book).to receive(:title) { "The RSpec book" } allow(book).to receive(:title).and_return("The RSpec book") allow(book).to receive_messages( title: "The RSpec book", description: "Write tests use method stubs" )
Bạn cũng có thể sử dụng shortcut sau đây, tạo một test double sau đó khai báo method stub trong cùng một câu lệnh:
book = double("book", :title => "The RSpec book")
Stub một chuỗi methods
Bạn có thể sử dụng receive_message_chain thay thế receive để stub một chuỗi các methods như sau:
allow(book).to receive_message_chain("title.length") { 30 } allow(book).to receive_message_chain(:title, :length => 30) allow(book).to receive_message_chain(:title, :length) { 30 } book.title.length # => 30
Factory Bot
Factory Bot là một thư viện giúp chúng ta tạo ra những dữ liệu test. Khi viết unit tests trong ứng dụng Rails, hãy luôn nhớ sử dụng Factory Bot để tạo ra các object thay vì sử dụng Model.create().
Sử dụng factories giúp chúng ta giảm được sự rườm rà khi tạo những dữ liệu mới, tạo nhiều object, association một cách nhanh chóng, đơn giản.
Ví dụ:
FactoryBot.define do factory :book do title "my first book title" description "my first book description" end end RSpec.describe Book, type: :model do let(:book) { FactoryBot.create :book } context "when create the first book" do it { expect(book.title).to eq("my first book title") } it { expect(book.description).to eq("my first book description") } end end
Tìm hiểu chi tiết hơn về Factory Bot tại: Factory Bot Rails
Mọi công cụ đã được chuẩn bị đầy đủ cho việc viết unit tests trong Rails. Let's go.
Viết unit test trong rails
Generators
Các files specs thường được tạo ra cùng với các thành phần khác trong ứng dụng Rails. Ví dụ, cmd rails generate model sẽ vừa tạo ra một file model, vừa tạo ra một file spec cho model đó.
Ngoài ra, RSpec generator cũng có thể chạy độc lập, sử dụng cmd:
rails generate rspec:model User
sẽ tạo ra một file spec trong spec/models/user_spec.rb
Chúng ta sử dụng pattern tương tự với các specs khác: controller, view, helper, mailer,...
Model specs
Để hiểu rõ hơn về cách tạo các model specs, tôi sẽ hướng dẫn các bạn bằng một ví dụ cụ thể sau đây.
Đầu tiên, hãy tạo 2 model Author và Book với quan hệ "1 Author has many books, and 1 book belongs to 1 author".
rails generate model Author name:string age:integer rails generate model Book title:string description:text author:references
class Author:
class Author < ApplicationRecord has_many :books validates_presence_of :name end
Factories cho Author:
FactoryBot.define do factory :author do name "my first author" age 30 end end
class Book:
class Book < ApplicationRecord belongs_to :author validates_presence_of :title validates <