Tôi đã test một Rails Application như thế nào? Phần 3: Model Rspec
Như các bạn đã biết thì trong 1 project có rất nhiều phần để có thể cấu thành nên một sản phẩm hoàn chỉnh, và điều đầu tiên mình muốn nhắc đến, rất quan trọng và ảnh hưởng rất lớn đến xử lý và dữ liệu đầu ra. Đó là models. Với các bài viết trước chúng ta đã hiểu về sự quan trọng của việc test và ...
Như các bạn đã biết thì trong 1 project có rất nhiều phần để có thể cấu thành nên một sản phẩm hoàn chỉnh, và điều đầu tiên mình muốn nhắc đến, rất quan trọng và ảnh hưởng rất lớn đến xử lý và dữ liệu đầu ra. Đó là models. Với các bài viết trước chúng ta đã hiểu về sự quan trọng của việc test và những bước đầu tiên để có thể khởi tại môi trường, và trong bài viết này mình sẽ hướng dẫn các bước test model cơ bản nhất
- Đầu tiên, chúng ta sẽ tạo spec cho model đã có sẵn, trong ví dụ của bài này thì sẽ là Contact model
- Tiếp đến, chúng ta sẽ đơn giản hóa các bước để tạo và maintain test data với factories
- Cuối cùng, chúng ta sẽ cùng nhau viết test cho model: Validation, Class, Instance Methods, và cấu trúc spec
Chúng ta sẽ cùng nhau tại ra spec files và factories bằng tay (Mặc dù RSpec có thể generate theo cách mà chúng ta đã config ở Phần 2)
Giải thích cấu trúc của một model spec
Mình nghĩ rằng điều dễ nhất để có thể học test là test ở model, bởi vì làm như vậy cho phép bạn kiểm tra từng block code trong application. Test code tốt ở level này (model) sẽ cho bạn một nền tảng vững chắc cho các bước tiếp theo vì đơn giản Model là những điều đơn giản cơ bản nhất của một project. Để bắt đầu, một model spec cần phải bảo gồm những thứ sau
- Default Factory bebe ddwiwch generate với object mình cần
- dữ liệu không thành công phải không hợp lệ
- class and instance methods thực hiện như mong đợi Đây là thời điểm tốt để xem xét cấu trúc cơ bản của một mô hình RSpec spec. Nào hãy cùng mình xem về cấu trúc Contact model requirements
describe Contact it "has a valid factory" it "is invalid without a firstname" it "is invalid without a lastname" it "returns a contact's full name as a string"
Chúng ta sẽ cùng tìm hiểu chi tiết, đặc biệt là với những bạn mới, có thể sẽ có rất nhiều điều mới lạ, và khó hiểu khi mới nhìn vào cấu trúc này.
- Mỗi một ví dụ, hay một test case sẽ được bắt đầu với it. Ví dụ bạn muốn viết rõ it này của mình test về cái gì, ví dụ mình muốn test firstname và lastname có validation riêng biệt. Thì theo cách này, nếu như 1 example bị fails, thì mình sẽ biết rằng là do cái validation cụ thể nào dẫn đến lỗi. Mà không hề tốn quá nhiều effort để tìm kiếm ra các đầu mối gây ra lỗi
- Mỗi ví dụ hay một test của chúng ta cần phải rõ ràng. Sau đoạn mô tả ở sau it sẽ là các đoạn code xử lý để giải thích cho phần mình đã mô tả trước đó Để hiểu rõ hơn thì chúng ta sẽ cùng nhau thực hành trên một ví dụ cụ thể đó là Contact model
Tạo một model spec
Mở thư mục spec và tại một thư mục con tên models. Bên trong thư mục con đó tạo một file contact_spec.rb và thêm vào như bên dưới:
# spec/models/contact.rb require 'spec_helper' describe Contact do it "has a valid factory" it "is invalid without a firstname" it "is invalid without a lastname" it "returns a contact's full name as a string" end
Chúng ta sẽ điền chi tiết vào từng case, nhưng bây giờ mình muốn các bạn chạy luôn file test này
$ rspec spec/models/contact_spec.rb
Bạn sẽ nhìn thấy output giống như thế này
Contact has a valid factory (PENDING: Not yet implemented) is invalid without a firstname (PENDING: Not yet implemented) is invalid without a lastname (PENDING: Not yet implemented) returns a contact's full name as a string (PENDING: Not yet implemented) Pending: Contact has a valid factory # Not yet implemented # ./spec/models/contact_spec.rb:4 Contact is invalid without a firstname # Not yet implemented # ./spec/models/contact_spec.rb:5 Contact is invalid without a lastname # Not yet implemented # ./spec/models/contact_spec.rb:6 Contact returns a contact's full name as a string # Not yet implemented # ./spec/models/contact_spec.rb:7 Finished in 0.00045 seconds 4 examples, 0 failures, 4 pending
HIện tại tất cả đang là Pending, vậy cùng nhau làm cho chúng pass nhé!
Generating test data với factories
Nếu nghe đến việc tạo dữ liệu mẫu (test data) chắc hẳn bạn sẽ cảm giác nó là một công việc điên rồ, dạng nhập liệu, hay với các model có một lô 1 lốc validation thì cảm giác tạo ra test data là một cái gì ôi thôi rồi kinh khủng. Điều thứ 2 khi tại các dữ liệu này bằng tay thì Rails sẽ bypass qua Active Record, điều đó đồng nghĩa việc test của bạn sẽ lack rất nhiều điều, đặc biệt là validation. Nhưng với rails thì FactoryGirl hay Faker sẽ giúp bạn rất nhiều trong việc xử lý và tạo ra các test data phù hợp với từng test case.
Việc sử dụng Factory: Đơn giản, nhanh gọn, building blocks cho test data. FactoryGirl là một gem rất dễn sử dụng để tạo ra test data, còn với Faker các bạn có thể tạo ra các dữ liệu fake một cách chuyên nghiệp theo từng lĩnh vực. Nào bây giờ hãy cùng mình setup một Factory nhé.
Đầu tiên quay lại thư mục spec, tạo thêm một thư mục factories, và tạo một file contacts.rb với nội dung như sau:
# spec/factories/contacts.rb FactoryGirl.define do factory :contact do |f| f.firstname "John" f.lastname "Doe" end end
Với đoạn code trên thì mỗi khi bạn tạo một factory contact sẽ trả về giá trị first name là John còn last name là Doe - statics data. Tuy nhiên trong rất nhiều trường hợp, test validation, hay test cần dữ liệu mang tính random thì chúng ta sẽ sử dụng đến Faker để tạo ra dynamic data.
# spec/factories/contacts.rb require 'faker' FactoryGirl.define do factory :contact do |f| f.firstname { Faker::Name.first_name } f.lastname { Faker::Name.last_name } end end
Và bây giờ, specs của bạn sẽ random. Faker sẽ tự random ra một tên tương ứng. Chúng ta cùng quay lại contact_spec.rb, chúng ta sẽ cùng nhau hoàn thành ví dụ đầu tiên (it “has a valid factory”). Các bạn thay đổi như sau:
# spec/models/contact_spec.rb require 'spec_helper' describe Contact do it "has a valid factory" do Factory.create(:contact).should be_valid end it "is invalid without a firstname" it "is invalid without a lastname" it "returns a contact's full name as a string" end
Dòng code trên sử dụng Rspec (be_valid) matcher để verify rằng cái factory của chúng ta trả về dữ liệu mà chúng ta mong muốn (validation trong model). Chạy lại RSpec chúng ta sẽ thấy kết quả 1 case pass và 3 case vẫn đang pending. Vậy là case đầu tiên của chúng ta đã pass.
Testing validations
Validation là một các tốt để có thể test automation. Các case test có thể viết rất nhanh và đơn giản chỉ trong một đến 2 dòng code nhờ vào sự tiện lợi của factories. Dưới đây mình sẽ thêm ào một số validation spec cho first_name:
# spec/models/contact_spec.rb it "is invalid without a firstname" do Factory.build(:contact, firstname: nil).should_not be_valid end
Chúng ta đang làm gì với Factory Girl trong trường hợp này. Đầu tiên, thay vì sử dụng Factory.create(), thì mình sử dụng Factory.build(). Các bạn có hiểu về sự khác nhau giữa nó hay không. Factory() build model và lưu lại nó, trong khi đó Factory.build() sẽ tạo ra model nhưng lại không lưu nó lại. Nếu chúng ta sử dụng Factory() trong ví dụ này thì nó sẽ ảnh hưởng đến các ví dụ ở trên (tạo ra data valid)
Chạy lại RSpec một lần nữa và chúng ta đã pass 2 spec và còn lại 2 spec pending. Chúng ta tiếp tục với lastname nhé
# spec/models/contact_spec.rb it "is invalid without a lastname" do Factory.build(:contact, lastname: nil).should_not be_valid end
Bạn nghĩ rằng sự liên quan giữa các test case này gần như không có, hay những cái này có thực sự liên quan đến model hay không cần thiết, thực sự bạn đã nhầm đặc biệt khi kết hợp với nhiều validation khác nhau hoặc khi các validation có sữ liên quan đến nhau trong các model khác thì thực sự nó là một điều cực quan trọng. Nếu bạn để lack nó thì khi xảy ra lỗi thì sẽ tốt rất nhiều thời gian lẫn tiền bạc để có thể xử lý đc nó. Ví dụ: bạn muốn chắc chắn rằng chúng ta không lặp lại các số điện thoại, và nó phải unique, vậy chúng ta sẽ phải test như thế nào?
Ở trong spec model Phone, chúng ta làm theo hướng dẫn sau:
# spec/models/phone_spec.rb it "does not allow duplicate phone numbers per contact" do contact = Factory(:contact) Factory(:phone, contact: contact, phone_type: "home", number: "785-555-1234") Factory.build(:phone, contact: contact, phone_type: "mobile", number: "785-555-1234").should_not be_valid end
Trong model Phone chúng ta sẽ code như sau để có thể pass được case này:
# app/models/phone.rb validates :phone, uniqueness: { scope: :contact_id }
Factory() là viết tắt của Factory.create()
Testing instance methods
Thật tiện lợi khi chúng ta có một biến @contact và khi gọi hàm @contact.name có thể render ra full name contact thay vì tạo string từng chút một.
# app/models/contact.rb def name [firstname, lastname].join " " end
Chúng ta có thể sử dụng một kĩ thuật cực kì đơn giản đã làm khi viết các validation example để có thể tạo ra 1 spec pass feature trên
# spec/models/contact_spec.rb it "returns a contact's full name as a string" do contact = Factory(:contact, firstname: "John", lastname: "Doe") contact.name.should == "John Doe" end
Testing class methods and scopes
Hãy cùng nhau test một chức năng khi trả lại list contacts với chữ cái bắt đầu trong tên. Ví dụ, nếu mình chọn chữ T thì sẽ trả về kết quả như Tuấn, Tùng, Tài v..v.. chứ không phải là Sơn. Đầu tiên, chúng ta sẽ cúng thên function như là một class method.
# app/models/contact.rb def self.by_letter(letter) where("lastname LIKE ?", "#{letter}%").order(:lastname) end
Để test được chúng ta viết case như sau:
# spec/models/contact_spec.rb require 'spec_helper' describe Contact do # validation examples ... it "returns a sorted array of results that match" do smith = Factory(:contact, lastname: "Smith") jones = Factory(:contact, lastname: "Jones") johnson = Factory(:contact, lastname: "Johnson") Contact.by_letter("J").should == [johnson, jones] end end
DRYer specs trước và sau
Hiện tại spec chúng ta cùng xây dựng ở trên cảm giác là một đống dư thừa rất nhiều và có rất nhiều đoạn bị lặp lại hay xử lý code khong cần thiết. Chúng ta đã cũng tạo ra 3 object với mỗi 1 case test. Giống như code bình thường thì DRY là một điều luôn luôn có trong test (trừ một số trường hợp mình sẽ đề cập phía dưới). Hãy cùng nhau xem một số mẹo để làm cho code của bạn đc "sạch sẽ" và "hiệu quả" hơn
Điều đầu tiên đó là mình sẽ tạo các describe blocks bên trong describe Contact block để focus vào từng feature. Outline tổng thể của chúng ta sẽ có dạng như thế này
# spec/models/contact_spec.rb require 'spec_helper' describe Contact do # validation examples ... describe "filter last name by letter" do # filtering examples ... end end
Bên trong mỗi một describe bick chúng ta có thể thêm vào context block:
# spec/models/contact_spec.rb require 'spec_helper' describe Contact do # validation examples ... describe "filter last name by letter" do context "matching letters" do # matching examples ... end context "non-matching letters" do # non-matching examples ... end end end
Trong khi describe và context có thể hoán đổi cho nhau, nhưng mình thường sử dụng như thế này để có thể phân biệt và thấy được sự mạch lạc trong file spec. Tiện cho việc kiểm tra và maintain sau này. describe sẽ là dành cho function của class, context sẽ dùng cho các state đặc biệt. Trong các case trên, mình có state của chữ cái match với result selected, và một state cho non-matching selected
Việc khởi tạo biến, các resource cần có trước khi sử dụng trong từng describe, thay vì từng context chúng ta sẽ khởi tạo chung ban đầu sẽ giúp cho chúng ta rất là nhiều và tăng performance khi chạy. Bằng cách sử dụng before chúng ta có thể giải quyết được vấn đề đó
# spec/models/contact_spec.rb require 'spec_helper' describe Contact do # validation examples ... describe "filter last name by letter" do before :each do @smith = Factory(:contact, lastname: "Smith") @jones = Factory(:contact, lastname: "Jones") @johnson = Factory(:contact, lastname: "Johnson") end context "matching letters" do # matching examples ... end context "non-matching letters" do # non-matching examples ... end end end
Chúng ta có thể thấy trong đoạn code trên mình đã thêm before block với việc khởi tạo 3 biến @smith, @jones, và @johnson. và before sẽ chạy trước khi bạn chạy mỗi một example, và các biến này k bị ảnh hưởng đến nhau trong mỗi một example. Ngoài ra, các biến này sẽ chỉ sử dụng trong các context trong describe block đó mà thôi. Điều đó sẽ giúp giảm bộ nhớ, và tăng hiệu năng vì khi nào cần chúng ta mới gọi đến và tạo ra nó. Dưới đây là full specs sau khi đã DRY của chúng ta
require 'spec_helper' describe Contact do it "has a valid factory" do Factory(:contact).should be_valid end it "is invalid without a firstname" do Factory.build(:contact, firstname: nil).should_not be_valid end it "is invalid without a lastname" do Factory.build(:contact, lastname: nil).should_not be_valid end it "returns a contact's full name as a string" do Factory(:contact, firstname: "John", lastname: "Doe").name.should == "John Doe" end describe "filter last name by letter" do before :each do @smith = Factory(:contact, lastname: "Smith") @jones = Factory(:contact, lastname: "Jones") @johnson = Factory(:contact, lastname: "Johnson") end context "matching letters" do it "returns a sorted array of results that match" do Contact.by_letter("J").should == [@johnson, @jones] end end context "non-matching letters" do it "does not return contacts that don't start with the provided letter" do Contact.by_letter("J").should_not include @smith end end end end
Và kết quả sau khi chạy spec:
Contact has a valid factory is invalid without a firstname is invalid without a lastname returns a contact's full name as a string filter last name by letter matching letters returns a sorted array of results that match non-matching letters does not return contacts that don't start with the provided letter Finished in 0.33642 seconds 6 examples, 0 failures