+9

Tất tần tật về RSpec cơ bản

Vài lời phi lộ 😙

Thỉnh thoảng, chúng ta có thể thấy một số bình luận rằng "RSpec khá khó". Đúng là RSpec có cú pháp đặc biệt một chút và tính năng khá nhiều, nên cảm giác ban đầu có thể là "khó hiểu". (Nhớ lại, lúc đầu khi mình thấy cú pháp của nó, cảm giác cũng hơi "bối rối" một chút 😄)

Tuy nhiên, không chỉ riêng RSpec, bất kì framework nào, khi bạn đã quen thuộc, việc viết code trở nên trôi chảy hơn. Thực sự mình viết test code với RSpec và cảm thấy rất tiện lợi.

Trong bài viết này, mình sẽ tổng hợp những cú pháp và tính năng của RSpec mà theo mình, chỉ cần nắm vững những điều này, chúng ta sẽ ổn!

Cụ thể như sau:

  • Hiểu vai trò của describe / it / expect
  • describe lồng nhau
  • Cách sử dụng context
  • Cách sử dụng before
  • Cách sử dụng let / let! / subject
  • Cách sử dụng shared_examples
  • Cách sử dụng shared_context
  • Biết lựa chọn giữa pendingskip

Đối tượng độc giả

  • Những ai mới bắt đầu với RSpec và cảm thấy hơi "e dè".
  • Những ai đã sử dụng RSpec một vài lần nhưng vẫn cảm thấy "chưa thấm".
  • Những ai đã từng làm việc với dự án liên quan đến Ruby và bị "đẩy" vào viết test bằng RSpec.

Phiên bản RSpec và Ruby được áp dụng

  • RSpec 3.1.0
  • Ruby 2.1.3 Lưu ý, trong bài viết này, Rails sẽ không được đề cập. Chúng ta sẽ tập trung vào "chương trình Ruby thuần".

Về việc thiết lập RSpec

Mình đã làm viết một bài hướng dẫn cách thiết lập môi trường RSpec (không dùng Rails). Những ai mới bắt đầu sử dụng RSpec có thể xem blog này để tham khảo.

Cách setup môi trường Rspec3 không cần dùng Rails - 2023

Môi trường thực thi trong video này như sau:

  • ruby 2.6.3
  • rspec 3.8.0
  • bundler 2.0.2
  • rubygems 3.0.3

Bắt đầu bước đầu tiên nào

Hiểu vai trò của describe / it / expect

Một test RSpec đơn giản sẽ có dạng như sau:

Nhân tiện, khi viết hướng dẫn, mình nhận ra rằng chúng ta nên viết describe ở ngoài cùng là RSpec.describe.

RSpec.describe 'Phép toán cơ bản' do
  it '1 + 1 sẽ bằng 2' do
    expect(1 + 1).to eq 2
  end
end

describe (hay RSpec.describe) dùng để nhóm các test lại với nhau. Ở đây, chúng ta đang thông báo rằng "Đây là nhóm test cho phép toán cơ bản". Nếu dịch describe ra tiếng Việt, nó có nghĩa là "mô tả" hoặc "giải thích".

it giúp tổ chức các test thành các đơn vị gọi là example. Nếu tất cả các expectation (so sánh) trong một example đều thành công, example đó sẽ được xem là đã "thành công". Nếu một expectation bị thất bại, example đó sẽ "thất bại".

expect là một method dùng để thực hiện so sánh. Ở đây, chúng ta đang so sánh xem 1 + 1 có bằng 2 không bằng cách sử dụng to eq.

Một lưu ý quan trọng là khi sử dụng describe, bạn nên tránh viết phức tạp. Mục tiêu của describe chỉ là nhóm các test lại với nhau dưới một chủ đề cụ thể.

Trong quá trình làm việc, bạn sẽ gặp phải nhiều trường hợp khác nhau cần so sánh, nhưng chỉ cần nắm vững cú pháp này, việc viết test với RSpec sẽ trở nên dễ dàng hơn rất nhiều.

Tóm tắt:

  • describe dùng để nhóm các test lại với nhau.
  • it dùng để định nghĩa một test đơn lẻ.
  • expect dùng để so sánh giữa kết quả thực tế và kết quả mong đợi.

Cú pháp khác

Trong RSpec, có một số cú pháp khác bạn có thể sử dụng:

  • describe có thể được thay thế bằng context
  • it có thể được thay thế bằng specify
  • expect cũng có thể viết dưới dạng khác như is_expected.to hoặc should

Tuy nhiên, trong bài viết này, chúng ta sẽ tập trung vào cú pháp cơ bản và phổ biến nhất.

Làm sao để describe lồng nhau?

Nếu bạn muốn tổ chức các test một cách cụ thể hơn, bạn có thể sử dụng describe lồng nhau. Điều này giúp bạn nhóm các test dưới một chủ đề con.

RSpec.describe 'Phép toán' do
  describe 'cơ bản' do
    it '1 + 1 sẽ bằng 2' do
      expect(1 + 1).to eq 2
    end
  end
  
  describe 'nâng cao' do
    it '1 + 2 không bằng 2' do
      expect(1 + 2).not_to eq 2
    end
  end
end

Ở đây, chúng ta có một nhóm test cho "Phép toán", và bên trong đó lại chia thành hai nhóm con là "cơ bản" và "nâng cao". Bằng cách này, bạn có thể tổ chức các test một cách hợp lý hơn.

Tóm tắt:

  • describe có thể lồng nhau để nhóm các test dưới một chủ đề con.

Trong phần này, chúng ta đã tìm hiểu về cú pháp cơ bản của RSpec và cách tổ chức các test. Trong các phần tiếp theo, chúng ta sẽ đi sâu vào các tính năng và kỹ thuật khác của RSpec.

Sử dụng context và before để hiệu quả hơn

Hãy xem xét cách sử dụng contextbefore thông qua một ví dụ thực tế.

Ở đây, chúng ta sẽ kiểm tra một class như sau:

class User
  def initialize(name:, age:)
    @name = name
    @age = age
  end

  def greet
    if @age <= 12
      "ぼくは#{@name}だよ。"
    else
      "僕は#{@name}です。"
    end
  end
end

Dựa trên kiến thức đã học từ "Bước đầu tiên", chúng ta có thể viết bài kiểm tra như sau:

RSpec.describe User do
  describe '#greet' do
    it 'Trường hợp dưới 12 tuổi, trả lời bằng chữ Hiragana' do
      user = User.new(name: 'たろう', age: 12)
      expect(user.greet).to eq 'ぼくはたろうだよ。'
    end

    it 'Trường hợp từ 13 tuổi trở lên, trả lời bằng Kanji' do
      user = User.new(name: 'たろう', age: 13)
      expect(user.greet).to eq '僕はたろうです。'
    end
  end
end

Chú ý, "greet" trong tiếng Nhật có nghĩa là "chào hỏi".

Dù không có vấn đề gì với cách viết trên, nhưng hãy thử refactor bài kiểm tra theo phong cách RSpec hơn một chút.

Chú thích: Điểm khác biệt so với "Bước đầu tiên" Trong describe, bạn cũng có thể truyền class thay vì chuỗi, như describe User.

Ngoài ra, khi bạn muốn nói rằng "chúng ta đang kiểm tra phương thức greet của instance", bạn thường viết như describe '#greet'.

Nhóm theo điều kiện với context

Trong RSpec, ngoài describe, chúng ta cũng có context để nhóm các bài kiểm tra. Cả hai đều giống nhau về chức năng, nhưng context thường được sử dụng khi phân loại theo điều kiện. "Context" trong tiếng Anh có nghĩa là "ngữ cảnh" hoặc "tình hình".

Ở đây, chúng ta đã chia thành hai nhóm điều kiện: "Dưới 12 tuổi" và "Từ 13 tuổi trở lên".

RSpec.describe User do
  describe '#greet' do
    context 'Trường hợp dưới 12 tuổi' do
      it 'Trả lời bằng chữ Hiragana' do
        user = User.new(name: 'たろう', age: 12)
        expect(user.greet).to eq 'ぼくはたろうだよ。'
      end
    end
    context 'Trường hợp từ 13 tuổi trở lên' do
      it 'Trả lời bằng Kanji' do
        user = User.new(name: 'たろう', age: 13)
        expect(user.greet).to eq '僕はたろうです。'
      end
    end
  end
end

Giống như describe, khi nhóm đúng cách bằng context, người đọc mã kiểm tra sẽ dễ dàng hiểu điều kiện đang được kiểm tra trong mỗi block context.

Sử dụng before để chuẩn bị công việc chung

Phần được bao quanh bởi before do ... end sẽ được gọi trước mỗi lần thực thi example. Trong block before, chúng ta thường thiết lập dữ liệu hoặc thực hiện các công việc chung trước khi kiểm tra.

Dù hơi cồng kềnh, nhưng trong mã mẫu, name: 'たろう' đã được lặp lại. Hãy thử viết lại theo phong cách DRY (mình đã có một bài viết về chủ đề này bạn có thể tham khảo).

RSpec.describe User do
  describe '#greet' do
    before do
      @params = { name: 'たろう' }
    end
    context 'Trường hợp dưới 12 tuổi' do
      it 'Trả lời bằng chữ Hiragana' do
        user = User.new(**@params.merge(age: 12))
        expect(user.greet).to eq 'ぼくはたろうだよ。'
      end
    end
    context 'Trường hợp từ 13 tuổi trở lên' do
      it 'Trả lời bằng Kanji' do
        user = User.new(**@params.merge(age: 13))
        expect(user.greet).to eq '僕はたろうです。'
      end
    end
  end
end

Như bạn có thể thấy trong ví dụ trên, thay vì sử dụng biến cục bộ, chúng ta đã đặt dữ liệu vào biến instance. Điều này là do block before và block it có phạm vi biến khác nhau.

(Double splat operator ** trước @params được sử dụng để chuyển đổi đối tượng hash thành tham số từ khóa.)

Sử dụng before trong describe hoặc context lồng nhau

Bạn có thể thiết lập before cho mỗi describe hoặc context. Nếu describe hoặc context lồng nhau, before sẽ được gọi theo thứ tự quan hệ cha con.

Hãy thay đổi ví dụ trước như sau:

RSpec.describe User do
  describe '#greet' do
    before do
      @name = 'たろう'
    end
    context 'Trường hợp dưới 12 tuổi' do
      before do
        @age = 12
      end
      it 'Trả lời bằng chữ Hiragana' do
        user = User.new(name: @name, age: @age)
        expect(user.greet).to eq 'ぼくはたろうだよ。'
      end
    end
    context 'Trường hợp từ 13 tuổi trở lên' do
      before do
        @age = 13
      end
      it 'Trả lời bằng Kanji' do
        user = User.new(name: @name, age: @age)
        expect(user.greet).to eq '僕はたろうです。'
      end
    end
  end
end

Ở đây, chúng ta đã đặt @name ở mức describe@age ở mức context.

Khi bạn cần chia sẻ dữ liệu giữa nhiều example hoặc context, before là một công cụ hữu ích.

Ứng dụng (Kỹ thuật nâng cao)

Đến nay, với những kiến thức bạn đã học, chúng ta hoàn toàn có thể viết test bằng RSpec một cách bình thường.

Tuy nhiên, những người làm chủ RSpec xung quanh bạn có thể đang sử dụng một số kỹ thuật như sau. Vậy nên, chúng ta hãy tìm hiểu thêm một chút về những nội dung sau đây.

Sử dụng let thay vì biến instance

Trong ví dụ code trước đó, chúng ta sử dụng biến instance @params. Nhưng, trong RSpec, chúng ta có thể thay thế biến instance này bằng chức năng let.

Hãy xem ví dụ code đã sử dụng let:

RSpec.describe User do
  describe '#greet' do
    let(:params) { { name: 'たろう' } }
    context 'Khi dưới 12 tuổi' do
      before do
        params.merge!(age: 12)
      end
      it 'Trả lời bằng chữ Hiragana' do
        user = User.new(**params)
        expect(user.greet).to eq 'ぼくはたろうだよ。'
      end
    end
    context 'Khi trên 13 tuổi' do
      before do
        params.merge!(age: 13)
      end
      it 'Trả lời bằng chữ Kanji' do
        user = User.new(**params)
        expect(user.greet).to eq '僕はたろうです。'
      end
    end
  end
end

Khi viết let(:foo) { ... }, chúng ta có thể tham chiếu đến giá trị trong { ... } như là foo. Đây chính là cách sử dụng cơ bản của let.

Nhưng, trong ví dụ trên, { { name: 'たろう' } } có hai cặp dấu {}, điều này có thể gây nhầm lẫn. Dấu {} bên ngoài là block của Ruby, trong khi dấu {} bên trong là một hash.

Nếu bạn cảm thấy khó hiểu, bạn có thể tham khảo cách viết sau:

# Đây là mã có cùng ý nghĩa với let(:params) { { name: 'たろう' } }
let(:params) do
  hash = {}
  hash[:name] = 'たろう'
  hash
end

Chuyển user thành let

Không chỉ với biến instance, việc thay biến cục bộ bằng let cũng là một cách tiếp cận.

Hãy thử thay user bằng let.

RSpec.describe User do
  describe '#greet' do
    let(:user) { User.new(**params) }
    let(:params) { { name: 'たろう' } }
    context 'Trường hợp dưới 12 tuổi' do
      before do
        params.merge!(age: 12)
      end
      it 'Phản hồi bằng Hiragana' do
        expect(user.greet).to eq 'ぼくはたろうだよ。'
      end
    end
    context 'Trường hợp từ 13 tuổi trở lên' do
      before do
        params.merge!(age: 13)
      end
      it 'Phản hồi bằng Kanji' do
        expect(user.greet).to eq '僕はたろうです。'
      end
    end
  end
end

Phần code user = User.new(**params) bị lặp đi lặp lại đã được tối ưu và sử dụng chung.

Tận dụng lợi ích của let để thay thế age bằng let

Trong đoạn code trên, việc sử dụng params.merge!(age: 12) trong block before không thực sự tốt.

Vậy nên, ta nên thay đổi phần này bằng let để code trở nên gọn gàng hơn.

RSpec.describe User do
  describe '#greet' do
    let(:user) { User.new(**params) }
    let(:params) { { name: 'たろう', age: age } }
    context 'Trường hợp dưới 12 tuổi' do
      let(:age) { 12 }
      it 'Phản hồi bằng Hiragana' do
        expect(user.greet).to eq 'ぼくはたろうだよ。'
      end
    end
    context 'Trường hợp từ 13 tuổi trở lên' do
      let(:age) { 13 }
      it 'Phản hồi bằng Kanji' do
        expect(user.greet).to eq '僕はたろうです。'
      end
    end
  end
end

Giờ đây, lợi ích của let được hiển thị rõ ràng.

let có tính chất đánh giá chậm - nghĩa là nó sẽ không được gọi cho đến khi thực sự cần thiết.

Theo thứ tự trong ví dụ code trên:

  1. expect(user.greet).to được gọi => user là gì?
  2. let(:user) { User.new(**params) } được gọi => params là gì?
  3. let(:params) { { name: 'たろう', age: age } } được gọi => age là gì?
  4. let(:age) { 12 } (hoặc 13) được gọi
  5. Kết quả cuối cùng, ta đã gọi expect(User.new(name: 'たろう', age: 12).greet).to.

Nếu chúng ta cố gắng thực hiện điều này bằng "before + biến instance" sẽ phức tạp hơn nhiều. Thực tế, mình cũng không nghĩ ra cách viết ngay lúc này.

Như vậy, nếu tận dụng đúng cách, let có thể giúp chúng ta viết code test hiệu quả hơn.

Sử dụng subject để tổng hợp đối tượng test vào một chỗ

Khi đối tượng cần test (hoặc kết quả thực thi của một phương thức) rõ ràng chỉ có một, ta có thể sử dụng chức năng subject để làm cho mã test trở nên DRY.

Chẳng hạn, trong ví dụ code ở trên, cả hai ví dụ đều test kết quả thực thi của user.greet. Do đó, hãy thử nâng user.greet lên làm subject và xóa nó khỏi các ví dụ.

RSpec.describe User do
  describe '#greet' do
    let(:user) { User.new(**params) }
    let(:params) { { name: 'たろう', age: age } }
    subject { user.greet }
    context 'Trường hợp dưới 12 tuổi' do
      let(:age) { 12 }
      it { is_expected.to eq 'ぼくはたろうだよ。' }
    end
    context 'Trường hợp từ 13 tuổi trở lên' do
      let(:age) { 13 }
      it { is_expected.to eq '僕はたろうです。' }
    end
  end
end

Khi khai báo subject { user.greet }, phần code trước đây là expect(user.greet).to eq 'ぼくはたろうだよ。' giờ đã được thay đổi thành is_expected.to eq 'ぼくはたろうだよ。'.

Thêm vào đó, chúng ta cũng có thể bỏ qua chuỗi truyền vào it.

RSpec.describe User do
  describe '#greet' do
    let(:user) { User.new(**params) }
    let(:params) { { name: 'たろう', age: age } }
    subject { user.greet }
    context 'Trường hợp dưới 12 tuổi' do
      let(:age) { 12 }
      it { is_expected.to eq 'ぼくはたろうだよ。' }
    end
    context 'Trường hợp từ 13 tuổi trở lên' do
      let(:age) { 13 }
      it { is_expected.to eq '僕はたろうです。' }
    end
  end
end

Kết quả mong đợi là 'ぼくはたろうだよ。' thì có thể được diễn đạt một cách tự nhiên trong tiếng Anh như sau: 'it is expected to equal 'ぼくはたろうだよ。''.

Nếu như bạn có ý muốn đề cập đến "subject" bằng tiếng Việt là "Chủ thể" hay "Đối tượng", thì cách diễn đạt này không phù hợp. Tuy vậy, nếu ta suy nghĩ cách này user.greet { is_expected.to eq 'ぼくはたろうだよ。' }, thì cũng có thể hiểu là "subject = 主題 của test case này.

Chỉ với hai dòng lệnh, chúng ta đã mô tả rõ ràng cách test và kết quả mong đợi của nó.

Hoàn thiện code test sau khi tái cấu trúc

Đến thời điểm này, chúng ta đã tách paramsuser. Tuy nhiên, việc tách ra không mang lại lợi ích gì nên hãy inline nội dung của params.

RSpec.describe User do
  describe '#greet' do
    let(:user) { User.new(name: 'たろう', age: age) }
    subject { user.greet }
    context 'Khi dưới 12 tuổi' do
      let(:age) { 12 }
      it { is_expected.to eq 'ぼくはたろうだよ。' }
    end
    context 'Khi từ 13 tuổi trở lên' do
      let(:age) { 13 }
      it { is_expected.to eq '僕はたろうです。' }
    end
  end
end

Với điều này, code test đã hoàn thiện.

(Arg của User.new không phải là hash object nữa mà đã chuyển thành keyword arg, nên ** không cần thiết nữa)

Lưu ý: Hãy tránh viết code test quá phức tạp

Cho đến giờ, chúng ta đã giới thiệu nhiều kỹ thuật khác nhau, nhưng không cần phải sử dụng let hay subject một cách quá mức.

Nếu bạn cố gắng viết code test DRY hoàn toàn và sử dụng các chức năng này quá mức, code có thể trở nên quá phức tạp, khiến việc đọc code test trở nên khó khăn. Vì vậy, với code test, hãy coi trọng tính "dễ đọc" hơn là "DRY". Khác với code ứng dụng, một chút trùng lặp trong code test là có thể chấp nhận được.

Điều này cũng áp dụng cho các kỹ thuật khác như shared_examples hay shared_context.

Viết chuỗi cho it hoặc context bằng tiếng Anh hay tiếng Nhật?

Đến nay, chúng ta đã sử dụng tiếng Nhật nhiều cho người Nhật, nhưng khi bạn viết chuỗi cho it hoặc context bằng tiếng Anh trong RSpec, nó sẽ trở nên giống như tài liệu test tiếng Anh.

Ví dụ như sau:

RSpec.describe User do
  describe '#greet' do
    let(:user) { User.new(**params) }
    let(:params) { { name: 'たろう', age: age } }
    context 'Khi 12 tuổi hoặc dưới đó' do
      let(:age) { 12 }
      it 'chào bằng hiragana' do
        expect(user.greet).to eq 'ぼくはたろうだよ。'
      end
    end
    context 'Khi 13 tuổi trở lên' do
      let(:age) { 13 }
      it 'chào bằng kanji' do
        expect(user.greet).to eq '僕はたろうです。'
      end
    end
  end
end

Khi sử dụng context, bạn có thể chuyển chuỗi mô tả như 'khi ~', 'với ~', 'không có ~', để rõ ràng việc bạn đang phân nhóm theo điều kiện.

Và sau it, hãy kết nối động từ diễn đạt "hành vi" của phương thức bạn đang test và mô tả bằng câu tiếng Anh.

Tuy nhiên, việc viết câu tiếng Anh như bạn nghĩ không phải lúc nào cũng dễ dàng.

Nếu chỉ có người Nhật đọc code test, thì việc viết bằng tiếng Nhật không phải là vấn đề. Thay vào đó, sử dụng thời gian đó để viết code test sẽ hiệu quả hơn. Và, viết tiếng Anh một cách cố gắng có thể tạo ra ngữ pháp lộn xộn mà người khác không thể hiểu.

Vì vậy, nếu bạn không giỏi tiếng Anh, hãy viết bằng tiếng Nhật mà không cần ép buộc.

RSpec.describe User do
  describe '#greet' do
    let(:user) { User.new(**params) }
    let(:params) { { name: 'たろう', age: age } }
    context 'Khi dưới 12 tuổi' do
      let(:age) { 12 }
      it 'Trả lời bằng hiragana' do
        expect(user.greet).to eq 'ぼくはたろうだよ。'
      end
    end
    context 'Khi từ 13 tuổi trở lên' do
      let(:age) { 13 }
      it 'Trả lời bằng kanji' do
        expect(user.greet).to eq '僕はたろうです。'
      end
    end
  end
end

Bí danh của itexamplespecify

Cho đến nay, chúng ta đã viết test dưới dạng it 'xxx', nhưng trong RSpec, ngoài it, còn có examplespecify cùng một chức năng. Ba phương thức này đều có mối quan hệ tương tự, vì vậy code test sau sẽ có cùng ý nghĩa:

it '1 + 1 sẽ là 2' do
  expect(1 + 1).to eq 2
end

specify '1 + 1 sẽ là 2' do
  expect(1 + 1

).to eq 2
end

example '1 + 1 sẽ là 2' do
  expect(1 + 1).to eq 2
end

Tuy nhiên, việc sử dụng specify hoặc example không phổ biến bằng it, vì vậy chúng ta sẽ tiếp tục sử dụng it cho những ví dụ sau.

Sử dụng "should" thay vì "expect"

Trong RSpec, có cả 2 cách viết "should" và "expect". Tuy nhiên, từ RSpec 3 trở đi, "expect" đã trở thành cách viết tiêu chuẩn. Cách viết "should" cũng vẫn được hỗ trợ, nhưng nó không được khuyến nghị.

Vì vậy, trừ khi bạn có lý do đặc biệt, bạn nên sử dụng cách viết "expect".

Dưới đây là bản dịch của đoạn nội dung bạn cung cấp:

Chức năng cao cấp của RSpec

Nếu bạn đã sử dụng thành thạo letsubject, bạn có thể coi như đã vượt qua cấp độ cơ bản. Tuy nhiên, thêm vào đó, việc biết thêm vài kỹ thuật khác có thể hữu ích trong một số trường hợp.

Tái sử dụng example: shared_examples và it_behaves_like

Hãy thử bổ sung thêm một số mẫu test vào đoạn mã test mà chúng ta đã viết phía trên. Không chỉ là tuổi 12 và 13, chúng ta cũng sẽ thử viết lời chào cho trẻ con (0 tuổi) và người lớn hơn nữa (100 tuổi).

RSpec.describe User do
  describe '#greet' do
    let(:user) { User.new(name: 'たろう', age: age) }
    subject { user.greet }

    context 'Trường hợp 0 tuổi' do
      let(:age) { 0 }
      it { is_expected.to eq 'ぼくはたろうだよ。' }
    end
    context 'Trường hợp 12 tuổi' do
      let(:age) { 12 }
      it { is_expected.to eq 'ぼくはたろうだよ。' }
    end
    context 'Trường hợp 13 tuổi' do
      let(:age) { 13 }
      it { is_expected.to eq '僕はたろうです。' }
    end
    context 'Trường hợp 100 tuổi' do
      let(:age) { 100 }
      it { is_expected.to eq '僕はたろうです。' }
    end
  end
end

Như bạn có thể thấy, chúng ta có một số example bị lặp lại. Trong trường hợp này, chúng ta có thể sử dụng shared_examplesit_behaves_like để tái sử dụng example.

RSpec.describe User do
  describe '#greet' do
    let(:user) { User.new(name: 'たろう', age: age) }
    subject { user.greet }

    shared_examples 'Lời chào của trẻ con' do
      it { is_expected.to eq 'ぼくはたろうだよ。' }
    end
    context 'Trường hợp 0 tuổi' do
      let(:age) { 0 }
      it_behaves_like 'Lời chào của trẻ con'
    end
    context 'Trường hợp 12 tuổi' do
      let(:age) { 12 }
      it_behaves_like 'Lời chào của trẻ con'
    end

    shared_examples 'Lời chào của người lớn' do
      it { is_expected.to eq '僕はたろうです。' }
    end
    context 'Trường hợp 13 tuổi' do
      let(:age) { 13 }
      it_behaves_like 'Lời chào của người lớn'
    end
    context 'Trường hợp 100 tuổi' do
      let(:age) { 100 }
      it_behaves_like 'Lời chào của người lớn'
    end
  end
end

Bạn có thể tưởng tượng shared_examples 'foo' do ... end để định nghĩa example muốn tái sử dụng và it_behaves_like 'foo' để gọi example đã định nghĩa đó.

Tiếp theo là shared_contextinclude_context

Chúng ta sẽ thêm một phương thức mới, child?, vào lớp User.

class User
  def initialize(name:, age:)
    @name = name
    @age = age
  end
  def greet
    if child?
      "ぼくは#{@name}だよ。"
    else
      "僕は#{@name}です。"
    end
  end
  def child?
    @age <= 12
  end
end

Không chỉ viết mã test cho phương thức greet, chúng ta cũng nên viết cho phương thức child?.

RSpec.describe User do
  describe '#greet' do
    let(:user) { User.new(name: 'たろう', age: age) }
    subject { user.greet }
    context 'Trường hợp dưới 12 tuổi' do
      let(:age) { 12 }
      it { is_expected.to eq 'ぼくはたろうだよ。' }
    end
    context 'Trường hợp từ 13 tuổi trở lên' do
      let(:age) { 13 }
      it { is_expected.to eq '僕はたろうです。' }
    end
  end

  describe '#child?' do
    let(:user) { User.new(name: 'たろう', age: age) }
    subject { user.child? }
    context 'Trường hợp dưới 12 tuổi' do
      let(:age) { 12 }
      it { is_expected.to eq true }
    end
    context 'Trường hợp từ 13 tuổi trở lên' do
      let(:age) { 13 }
      it { is_expected.to eq false }
    end
  end
end

Khi viết mã test, chúng ta có thể thấy rằng cả hai bài test đều sử dụng những context giống nhau. Trong trường hợp này, chúng ta có thể sử dụng shared_contextinclude_context để tái sử dụng context.

RSpec.describe User do
  let(:user) { User.new(name: 'たろう', age: age) }
  shared_context 'Trường hợp 12 tuổi' do
    let(:age

) { 12 }
  end
  shared_context 'Trường hợp 13 tuổi' do
    let(:age) { 13 }
  end

  describe '#greet' do
    subject { user.greet }
    include_context 'Trường hợp 12 tuổi'
    it { is_expected.to eq 'ぼくはたろうだよ。' }
    include_context 'Trường hợp 13 tuổi'
    it { is_expected.to eq '僕はたろうです。' }
  end

  describe '#child?' do
    subject { user.child? }
    include_context 'Trường hợp 12 tuổi'
    it { is_expected.to eq true }
    include_context 'Trường hợp 13 tuổi'
    it { is_expected.to eq false }
  end
end

Tương tự như với shared_examples, bạn có thể sử dụng shared_context 'foo' do ... end để định nghĩa context bạn muốn tái sử dụng và sử dụng include_context 'foo' để gọi lại context đã định nghĩa.

Lưu ý: Khi viết code test, ta nên ưu tiên độ dễ đọc hơn là việc áp dụng DRY (Don't Repeat Yourself). Mặc dù đã nói trước đó nhưng muốn nhắc lại là, nếu sử dụng quá nhiều shared_examplesshared_context, có thể gây rối rắm cho người đọc sau này hoặc cho chính bạn khi phải đọc lại code.

Khi viết code test, việc ưu tiên độ dễ đọc và chấp nhận một số trùng lặp sẽ mang lại kết quả tốt hơn so với việc áp dụng quá mức nguyên tắc DRY.

Tham khảo: Cũng có cách viết là be_truthy / be_falsey Thay vì viết it { is_expected.to eq true }it { is_expected.to eq false }, chúng ta có thể viết lại là it { is_expected.to be_truthy }it { is_expected.to be_falsey }.

Chủ đề này liên quan đến matcher nên trong bài viết này, chúng ta sẽ không đi sâu vào chi tiết mà sẽ sử dụng eq trueeq false.

Dựa vào những kỹ thuật này, chúng ta có thể viết mã test hiệu quả hơn và giảm lượng mã bị lặp lại.

let có đánh giá trễ và let! được thực thi trước

let thật sự là một tính năng tiện lợi khi bạn biết cách sử dụng nó. Tuy nhiên, tính chất "đánh giá trễ" của nó đôi khi khiến cho việc debug các test thất bại trở nên khó khăn.

Ví dụ, khi bạn viết test cho một model Blog trong Rails như sau, test sẽ thất bại. (Xin lỗi, chỉ ở đây mình sử dụng test của Rails)

RSpec.describe Blog do
  let(:blog) { Blog.create(title: 'Kiểm thử với với RSpec', content: 'Sẽ viết sau') }
  it 'Có thể truy xuất blog' do
    expect(Blog.first).to eq blog
  end
end

Bạn có biết lí do test này thất bại không?

Hãy chú ý đến phần expect(Blog.first).to eq blog. Khi gọi Blog.first, let(:blog) vẫn chưa được thực thi, nghĩa là bản ghi chưa được lưu vào cơ sở dữ liệu. Do đó, Blog.first sẽ trả về nil và test sẽ thất bại. (Chỉ trong khoảnh khắc so sánh nilblog, bản ghi mới được lưu vào cơ sở dữ liệu).

Một cách để tránh vấn đề này là gọi let(:blog) một cách rõ ràng trong block before. Khi làm như vậy, bản ghi sẽ được lưu vào cơ sở dữ liệu trước khi example được thực thi.

RSpec.describe Blog do
  let(:blog) { Blog.create(title: 'Kiểm thử với với RSpec', content: 'Sẽ viết sau') }
  before do
    blog # Lưu bản ghi vào cơ sở dữ liệu ở đây
  end
  it 'Có thể truy xuất blog' do
    expect(Blog.first).to eq blog
  end
end

Tuy nhiên, thay vì viết như trên, chúng ta có thể sử dụng let!. Khi dùng let!, giá trị được định nghĩa sẽ được tạo ra trước khi example thực thi.

RSpec.describe Blog do
  let!(:blog) { Blog.create(title: 'Kiểm thử với với RSpec', content: 'Sẽ viết sau') }
  it 'Có thể truy xuất blog' do
    expect(Blog.first).to eq blog
  end
end

Vì vậy, khi đánh giá trễ của let gây ra lỗi trong test, sử dụng let! sẽ rất tiện lợi.

Đánh dấu test không thực thi với: pending

Khi một test cần phải pass nhưng vì một lý do nào đó mà nó không thể pass, hãy dùng pending để đánh dấu nó.

RSpec.describe 'Lớp nhạy cảm' do
  it 'Test nhạy cảm' do
    expect(1 + 2).to eq 3

    pending 'Phần tiếp theo có gì đó sai, sẽ sửa sau'
    # Một expectation không pass (được thực thi)
    expect(foo).to eq bar
  end
end

Khác với pending thông thường, code vẫn tiếp tục chạy. Nếu test thất bại, nó sẽ được đánh dấu là "pending". Nếu test pass, RSpec sẽ phản ứng với thông báo "Sao lại pass!?" và test sẽ bị coi là thất bại.

Dù có vẻ khá kỳ cục, nhưng tính năng này có thể giúp tránh được tình huống "lạc lõng" khi một test bất ngờ pass.

Dừng thực thi test mà không cần lý do: skip

Nếu bạn thực sự muốn dừng việc thực thi test tại một điểm nào đó, hãy sử dụng skip.

RSpec.describe 'Không muốn thực thi vì lý do nào đó' do
  it 'Test không muốn thực thi' do
    expect(1 + 2).to eq 3

    skip 'Dừng thực thi tại đây'
    # Code từ đây trở đi sẽ không được thực thi
    expect(foo).to eq bar
  end
end

Khác với pending, skip sẽ dừng việc thực thi và đánh dấu test là "pending". Cá nhân mình nghĩ rằng việc sử dụng skip không phổ biến bằng pending.

Dừng thực thi toàn bộ example một cách nhanh chóng: xit

Khi bạn muốn dừng thực thi toàn bộ example một cách nhanh chóng, hãy thay it bằng xitexample đó sẽ không được thực thi (được xem như "pending").

RSpec.describe 'Không muốn thực thi vì lý do nào đó' do
  xit 'Test không muốn thực thi' do
    expect(1 + 2).to eq 3

    expect(foo).to eq bar
  end
end

Không chỉ xit, chúng ta cũng có thể sử dụng xdescribexcontext để ngừng thực thi một nhóm example cùng một lúc.

Điều này rất hữu ích khi bạn muốn tạm thời dừng thực thi một số example mà không muốn xoá chúng.

Sử dụng focus để chỉ thực thi một số example

Khi bạn muốn thực thi chỉ một số example trong một file test lớn, bạn có thể sử dụng focus để tập trung vào những example đó.

Đầu tiên, hãy thêm cấu hình sau vào RSpec.configure:

RSpec.configure do |config|
  config.filter_run_when_matching :focus
end

Tiếp theo, bạn thêm focus: true vào example, describe hoặc context mà bạn muốn thực thi.

RSpec.describe 'Lớp với nhiều example' do
  it 'Test thông thường', focus: true do
    expect(1 + 2).to eq 3
  end

  it 'Test khác' do
    expect(4 + 5).to eq 9
  end
end

Khi thực thi test, chỉ examplefocus: true sẽ được thực thi.

Lưu ý rằng sau khi hoàn thành việc debug, bạn nên xoá focus: true để đảm bảo tất cả các example đều được thực thi.

Sử dụng tag để phân loại và thực thi test

Trong RSpec, bạn có thể gán các tag cho example và thực thi test dựa trên tag.

Ví dụ:

RSpec.describe 'Lớp với tag' do
  it 'Test dành cho trình duyệt Chrome', browser: :chrome do
    # Mã cho test Chrome
  end

  it 'Test dành cho trình duyệt Firefox', browser: :firefox do
    # Mã cho test Firefox
  end
end

Khi bạn muốn thực thi chỉ những test dành cho Chrome, bạn chỉ cần chạy:

rspec --tag browser:chrome

Sử dụng tag giúp bạn có thể dễ dàng quản lý và thực thi những test cụ thể trong một file test lớn.

Bỏ qua toàn bộ nhóm: xdescribe / xcontext

Không chỉ với "it", bạn cũng có thể thêm "x" vào trước "describe" và "context".

# Bỏ qua toàn bộ nhóm
xdescribe 'Phép toán cộng trừ nhân chia' do
  it '1 + 1 sẽ bằng 2' do
    expect(1 + 1).to eq 2
  end
  it '10 - 1 sẽ bằng 9' do
    expect(10 - 1).to eq 9
  end
end

# Bỏ qua toàn bộ nhóm
xcontext 'Trong trường hợp quản trị viên' do
  it 'có thể chỉnh sửa thông tin nhân viên' do
    # ...
  end
  it 'có thể xoá thông tin nhân viên' do
    # ...
  end
end

Viết test sau: it không chứa nội dung

Khi bạn bỏ qua phần do ... end trong it 'something' do ... end, trường hợp đó sẽ được đánh dấu như một test đang chờ xử lý (pending).

Điều này rất tiện khi chúng ta muốn triển khai một phương thức và muốn liệt kê các trường hợp test cần xem xét ngay trong code.

Ví dụ, nếu chúng ta muốn thêm phương thức good_bye vào class User, trước khi triển khai, chúng ta có thể đặc tả như sau trong RSpec (có thể xem như một danh sách công việc trước khi thực hiện):

RSpec.describe User do
  describe '#good_bye' do
    context 'Khi dưới 12 tuổi' do
      it 'nói lời tạm biệt bằng chữ Hiragana'
    end
    context 'Khi trên 13 tuổi' do
      it 'nói lời tạm biệt bằng chữ Kanji'
    end
  end
end

Khi không còn test nào ở trạng thái pending, đó là lúc phương thức good_bye đã được triển khai xong.

Tham khảo: Đây có phải là TDD? Việc viết test trước khi triển khai giống như phương pháp TDD (Phát triển Dựa trên Test), nhưng nói một cách chính xác thì chưa chắc đã đúng. Trước hết, các test này đang ở trạng thái pending nên không bị fail (không màu đỏ mà là màu vàng). Với TDD, bạn cần viết code test trước, nhưng ở đây chúng ta không quy định viết cái nào trước. Vì vậy, nó hơi khác với quy trình TDD thông thường.

Còn đối với mình, mình chỉ sử dụng TDD khi cảm thấy nó phù hợp. Khi viết code trong thực tế, mình thường tuân theo một trong ba hướng sau:

  • Triển khai trước, viết test sau.
  • Viết test trước rồi triển khai (TDD).
  • Kiểm tra bằng tay sau khi triển khai và không viết code test. Theo tư duy chủ nghĩa TDD thuần túy có thể khiến hiệu suất phát triển giảm, vì vậy mình luôn cân nhắc lựa chọn phương pháp có hiệu quả nhất trong mỗi tình huống.

Tóm tắt

Trong bài viết này, chúng ta đã đi qua cơ bản về cú pháp RSpec và một số tính năng tiện ích thường dùng. Mặc dù nội dung bao gồm cả cho người mới học và người đã có kinh nghiệm, hy vọng rằng mọi người đọc cũng đã có cái nhìn tổng quan về RSpec.

RSpec có rất nhiều tính năng và một số cú pháp có thể trở nên phức tạp và khó hiểu. Tuy nhiên, bằng việc sử dụng đúng tính năng ở đúng nơi và tránh viết code test quá phức tạp, chúng ta có thể tận dụng những ưu điểm của RSpec và tạo ra "code test dễ đọc và dễ bảo trì".

Hy vọng rằng những ai mới tiếp xúc với RSpec hoặc đã sử dụng RSpec trước đây có thể áp dụng những kiến thức từ bài viết này.

Mình hy vọng bạn thích bài viết này và học thêm được điều gì đó mới.

Donate mình một ly cafe hoặc 1 cây bút bi để mình có thêm động lực cho ra nhiều bài viết hay và chất lượng hơn trong tương lai nhé. À mà nếu bạn có bất kỳ câu hỏi nào thì đừng ngại comment hoặc liên hệ mình qua: Zalo - 0374226770 hoặc Facebook. Mình xin cảm ơn.

Momo: NGUYỄN ANH TUẤN - 0374226770

TPBank: NGUYỄN ANH TUẤN - 0374226770 (hoặc 01681423001)

image.png

Original Article


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí