+2

Ví dụ viết test RSpec và viết code Ruby on Rails

Bài viết này được lấy cảm hứng từ phần thuyết trình của cty S ở Ruby Kaigi 2021.

Xin chào mọi người. Hôm nay sẽ là bài đầu tiên mình viết có code sau 1 khoảng thời gian toàn về học chứng chỉ với best practice các kiểu. Trong năm nay mình sẽ cố gắng làm các bài về code nhiều hơn. Rất mong mọi người ủng hộ.

Và để khởi động thì mình sẽ viết về 1 ví dụ unit test.

Unit test là gì

Mình biết chúng ta không hề thích khái niệm tí nào cả! Bản thân mình đọc đến dòng này cũng thấy muốn skip để thực hành nhanh rồi. Nhưng vẫn phải đưa ra để chúng ta kiểm tra tiêu chí:

Unit Test là một loại kiểm thử phần mềm trong đó các đơn vị hay thành phần riêng lẻ của phần mềm được kiểm thử. Đây là dòng khái niệm nhan nhản ở các giáo trình code tiếng Việt cũng như các blog về unit test. Nhưng cái cần đặt ra ở đây: "Định nghĩa thế nào là ĐƠN VỊ?"

Trong các khái niệm khoa học khác như Toán hay Vật Lý thì đơn vị được dùng ám chỉ về một tiêu chuẩn rất nhỏ không thể chia ra được thêm của 1 sự đo lường nào đó. Với tư cách là ngành khoa học sinh sau đẻ muộn thì Công nghệ thông tin cũng được kế thừa khái niệm ấy. Vì vậy, mình nghĩ là ta phải bổ sung thêm về khái niệm unit code:

Unit code là chỉ 1 phần code đã được chia nhỏ nhất có thể và hoàn toàn độc lập về chức năng. Như vậy, unit test với best practice phải là: Unit test tốt sẽ chỉ test 1 unit code và không bị phụ thuộc vào các unit test khác

Ví dụ

Khái niệm lằng nhằng bên trên xong rồi. Bây giờ chúng ta cùng đi vào 1 ví dụ cụ thể và đơn giản. Chúng ta có 1 Rails cơ bản lưu trữ các Article gồm name và body. Yêu cầu ở đây là path /articles ở đây chỉ xuất ra tên của Article còn path /articles/:id sẽ xuất ra tên và body của article. path /articles sẽ phải pagination 10.

Trong bài này, chúng ta sẽ theo practice viết test trước khi viết code. Đây là best practice nhất bởi phải hiểu yêu cầu thì ta mới có thể viết được code. Và 1 trong những cách để hiểu yêu cầu là diễn tả yêu cầu dưới dạng test code.(Mặc dù thực tế mình vẫn là type code trc test và vì nhiều lý do, dự án cũng như sản phẩm khác nhau mà phần unit test cũng có lúc được coi trọng nhưng cũng có lúc bị xem nhẹ). Các điều kiện cần thêm gồm có gem RSpec, FactoryBot và Kaminari. Ngoài ra với test controller tuy có nhiều loại nhưng theo thói quen code thì mình sẽ chọn request test. Các kiểu test controller khác như controller test hay feature test cũng sẽ viết theo triết lý tương tự.

Lưu ý quan trọng khác: Để đúng như triết lý Không bị phụ thuộc các unit test khác, phải có cài đặt clear database sau khi test case chạy xong để tránh trường hợp data cũ gây lỗi cho test case mới hay ở đây là 1 sự phụ thuộc không ai mong muốn

Trước tiên, phân tích yêu cầu có 2 path tương ứng với method index và method show. Vì vậy chúng ta sẽ có test RSpec bước đầu như sau:

# spec/requests/articles_spec.rb
require 'rails_helper'

RSpec.describe 'Articles', type: :request do
  describe 'GET /articles' do
    it 'can be access' do
      get articles_path
      expect(response).to have_http_status(:ok)
    end    
  end

  describe 'GET /articles/:id' do
    let!(:article) { FactoryBot.create(:article) }

    it "can be access" do
      expect(response).to have_http_status(:ok)
    end
  end
end

Tiếp đó, theo yêu cầu, chúng ta thấy phần method show không có thêm yêu cầu nào phải chia nữa do không có điều kiện. Vì vậy ta sửa lại describe 'GET /articles/:id' lại như sau:

describe 'GET /articles/:id' do
  let!(:article) { FactoryBot.create(:article) }

  it "can be access and have all info of an article" do
    expect(response).to have_http_status(:ok)
    expect(response.body).to include(article.name)
    expect(response.body).to include(article.body)
  end
end

Như vậy thì hàm show ở đây chúng ta sẽ tính là 1 unit bởi không thể chia nhỏ được nữa. Chú ý rằng việc đặt tên các describe, contextit... do ở đây cũng quan trọng. Bạn cần đặt tên làm sao cho test case dễ hiểu được chức năng này để làm gì.

Nhưng liệu index có chia được nhỏ như thế không?

Ở đây index có tận ít nhất 2 điều kiện: có pagination và dữ liệu hiển thị ra ít hơn so với trang chủ. Như vậy chúng ta phải chia 2 case. Và pagination thì cũng có 3 trường hợp: có đủ dữ liệu per page, không đủ dữ liệu per page và cuối cùng là không có. Như vậy lại thêm 3 case. Và tới đây cũng không chia nhỏ thêm được nữa nên chúng ta có tổng cộng 4 unit.

Với case cái gì được hiển thị ở trang chủ, ta viết như sau:

describe 'GET /articles' do
  describe 'only the name of the article is shown' do
    let(:article) { FactoryBot.create(:article) }
    
    it 'correct' do
        get articles_path
        expect(response).to have_http_status(:ok)
        expect(response.body).to include(article.name)
        expect(response.body).not_to include(article.body)
    end
  end
  .....
end

Với Pagination, mình sẽ quyết định chọn số 3 để cho phần test. Nếu làm nguyên phần 10 thì thời gian chạy test sẽ không hề nhanh tí nào. Yên tâm là chúng ta có cách để chọc lại phần code cho đúng 10 sau.

describe 'GET /articles' do
  .....
  describe 'pagination' do
    let!(:articles) { FactoryBot.create_list(:article, 4) }

    context 'page 1' do
      before do
        get articles_path, params: { page: 1 }
      end

# Ở đoạn này các bạn có thể đơn giản count. Việc kiểm tra từng tên thế này là do mình lấy theo code viết ngày xưa khi phải kiểm tra cả ranking của dữ liệu được hiển thị đúng hay không
      it 'only article 1,2 and 3 are shown' do
          expect(response.body).to include(articles[0].name, articles[1].name, articles[2].name)
          expect(response.body).not_to include(articles[3].name)
      end
    end

    context 'page 2' do
      before do
        get articles_path, params: { page: 2 }
      end

      it 'only article 4 is shown' do
        expect(response.body).to include(articles[3].name)
        expect(response.body).not_to include(articles[0].name, articles[1].name, articles[2].name)
      end
    end

    context 'page 3' do
      before do
        get articles_path, params: { page: 3 }
      end

      it 'no articles are shown' do
        articles.each do |article|
          expect(response.body).not_to include(article.name)
        end
      end
    end
  end
end

Bước tiếp đến chúng ta có thể chạy thử test. Tất nhiên sẽ fail hết vì chưa có code đâu.

Dựa theo các yêu cầu trên thì chúng ta sẽ viết code controller:

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  before_action :find_article, only: :show
  PAGE_LIMIT = 10

  def index
    @articles = Article.
               select('name').
               page(params[:page]).
               per(PAGE_LIMIT)
    render :index
  end

  def show; end

  private

  def find_spot
    @article = Article.find_by(id: params[:id])
    return if @article

    flash[:alert] = t('articles.not_found')
    redirect_to articles_url
  end
end

Tới đây thì chắc chắn chúng ta sẽ pass được các test trừ phần pagination. Tuy nhiên code ở đây chạy đúng! Vậy nên chúng ta sẽ sửa test bằng cách stub:

# spec/requests/articles_spec.rb
....
      before do
        stub_const('ArticlesController::PAGE_LIMIT', 3)
      end

Ngoài ra, mình lỡ quên mất cover cả case không tồn tại article rồi. Như vậy lúc này chúng ta lại phải chia tiếp ra method show gồm 2 trường hợp là tồn tại và không tồn tại:

describe 'GET /articles/:id' do
  let!(:article) { FactoryBot.create(:article) }

  describe 'the article exists' do
    it "can be access and have all info of an article" do
      get article_path(article)
      expect(response).to have_http_status(:ok)
      expect(response.body).to include(article.name)
      expect(response.body).to include(article.body)
    end
  end
  
  describe 'the article doesn't exist' do
    it "errors in homepage" do
      get article_path(0)
      expect(response).to redirect_to(articles_url(locale: I18n.locale))
      follow_redirect!

      expect(response.body).to include(I18n.t('articles.not_found'))
    end
  end
end

Và cuối cùng, chúng ta sẽ có test hoàn chỉnh như sau:

# spec/requests/articles_spec.rb
require 'rails_helper'

RSpec.describe 'Articles', type: :request do
  describe 'GET /articles' do
    describe 'only the name of the article is shown' do
      let(:article) { FactoryBot.create(:article) }
    
      it 'correct' do
        get articles_path
        expect(response).to have_http_status(:ok)
        expect(response.body).to include(article.name)
        expect(response.body).not_to include(article.body)
      end
    end
    
    describe 'pagination' do
      let!(:articles) { FactoryBot.create_list(:article, 4) }
      
      before do
        stub_const('ArticlesController::PAGE_LIMIT', 3)
      end

      context 'page 1' do
        before do
          get articles_path, params: { page: 1 }
        end

        it 'only article 1,2 and 3 are shown' do
            expect(response.body).to include(articles[0].name, articles[1].name, articles[2].name)
            expect(response.body).not_to include(articles[3].name)
        end
      end

      context 'page 2' do
        before do
          get articles_path, params: { page: 2 }
        end

        it 'only article 4 is shown' do
          expect(response.body).to include(articles[3].name)
          expect(response.body).not_to include(articles[0].name, articles[1].name, articles[2].name)
        end
      end

      context 'page 3' do
        before do
          get articles_path, params: { page: 3 }
        end

        it 'no articles are shown' do
          articles.each do |article|
            expect(response.body).not_to include(article.name)
          end
        end
      end
    end
  end

  describe 'GET /articles/:id' do
    let!(:article) { FactoryBot.create(:article) }

    describe 'the article exists' do
      it "can be access and have all info of an article" do
        get article_path(article)
        expect(response).to have_http_status(:ok)
        expect(response.body).to include(article.name)
        expect(response.body).to include(article.body)
      end
    end
  
    describe 'the article doesn't exist' do
      it "errors in homepage" do
        get article_path(0)
        expect(response).to redirect_to(articles_url(locale: I18n.locale))
        follow_redirect!

        expect(response.body).to include(I18n.t('articles.not_found'))
      end
    end
  end
end

Bài viết này của mình tới đây là hết. Cảm ơn mọi người đã đọc tới phút cuối


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í