单元测试

Posted by 排球混子 on July 7, 2025

入门单元测试

单元测试基本介绍

单元测试(UT)是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作。 比如对函数abs(),我们可以编写出以下几个测试用例: 输入正数,比如1、1.2、0.99,期待返回值与输入相同; 输入负数,比如-1、-1.2、-0.99,期待返回值与输入相反; 输入0,期待返回0; 输入非数值类型,比如None、[]、{},期待抛出TypeError。 把上面的测试用例放到一个测试模块里,就是一个完整的单元测试。

如果单元测试通过,说明我们测试的这个函数能够正常工作。如果单元测试不通过,要么函数有bug,要么测试条件输入不正确,总之,需要修复使单元测试能够通过。

单元测试通过后有什么意义呢?如果我们对abs()函数代码做了修改,只需要再跑一遍单元测试,如果通过,说明我们的修改不会对abs()函数原有的行为造成影响,如果测试不通过,说明我们的修改与原有行为不一致,要么修改代码,要么修改测试。

这种以测试为驱动的开发模式最大的好处就是确保一个程序模块的行为符合我们设计的测试用例。在将来修改的时候,可以极大程度地保证该模块行为仍然是正确的。

Rspec

Behaviour Driven Development for Ruby. (Ruby的行为驱动开发) RSpec是一种DSL,用于创建代码期望行为的一组组示例。它使用了“describe”和“it”。

# BankAccount,提供 存钱、取钱、查看余额方法
class BankAccount
  def initialize(amount)
    @amount = amount
  end

  # 存钱
  def deposit(amount)
    @amount += amount if amount > 0
  end

  # 取钱
  def withdraw(amount)
    @amount -= amount if amount > 0 && amount <= @amount
  end

  # 余额
  def balance
    @amount
  end
end

基本方法介绍

before :each  # 在文件中的每个rspec之前运行该块一次
before :all  # 在运行所有examples之前运行一次
let  # let是惰性求值的:直到它定义的方法第一次被调用时才求值
let!  # 在每个示例之前强制调用方法。
describe
it

Rspec使用 describe和it这两个词,使开发人员可以像对话一样写出单元测试 针对BankAccount,示范几个基本的测试用例。

# 这个describe主要在解释我们要测试的主要目标是什么?(我们要对BankAccount进行测试)
RSpec.describe BankAccount do

  before :each do
    @account = BankAccount.new(10)
  end

  describe '取钱功能' do
    # 这个it方法最大的价值就在于,他能够让一个测试变得易读,然后清楚的描述这个行为是什么
    it '原本账户有10元,取走10元后,账户剩余0元(不能取走小于等于0的金额)' do
      @account.withdraw 10
      # expect这个方法接收的参数是我们要测试的对象, 我们在后面写出我们希望得到的答案。
      expect(@account.balance).to be 0
    end
  ...
  ...
  end
end
# https://rubydoc.info/gems/rspec-expectations/frames
# eq:单纯比较值,不在意类型
# eql:比较值以及类型
# equal 以及 be: 比较值,型别,是否为同一个对象
describe "expectations api" do
  it '比较 测试组' do
    expect(3).to be 3
    expect(3).not_to be 4
    expect(3).to be > 2 # < <= >= 就不作示例了
    expect([1, 2, 3]).to include(1)
    expect([1, 2, 3]).to start_with(1, 2)
    expect("www.baidu.com").to start_with('www')
    expect("www.baidu.com").to end_with('com')
    expect(28.274363576453).to be_within(0.1).of(28.3)
    # be_winhin用于比较浮点数 0.1代表小数点后保留两位 of 是你的期望值, 下面是保留两位数示例
    expect(28.274363576453).to be_within(0.2).of(28.27)
  end

  it '类型/类 测试组' do
    expect('String').to be_a(String)
    expect('String').not_to be_a(Hash)
  end

  it '错误类型 测试组' do
    # 这里是方括号
    expect { 1+'xx' }.to raise_error(TypeError)
    expect { 1+'xx' }.to raise_error("String can't be coerced into Integer")
    expect { 1+'xx' }.to raise_error(TypeError, "String can't be coerced into Integer")
  end
end

factory_bot

Factory Bot是为 Ruby 测试编写工厂的助手。 语法介绍 每个factory都有自己的名称及属性,下文的factory的name是user, 显式了指定对应的class

FactoryBot.define do
  factory :user, class: "User" do
    first_name { "John" }
    last_name  { "Doe" }
  end
end

最佳实践 建议项目下每个model class都有对应的一个factory, 用于提供一些最简单的一组属性。 如果定义多个具有相同名称的工厂将引发错误。

# 用于生成一个´不会被保存的实例
user = build(:user)
# 用于生成一个会被保存的实例
user = create(:user)
# 无论通过上面哪种构造方式,都可以传递值来覆盖属性
user = build(:user)
user.firt_name = 'vic'

Traits traits允许你把属性组合在一起, 然后把它们应用于任何工厂 假设我们的 User 有不同的角色,有管理员,有销售,有开发 最原始的写法是

user = build(:user)
user.role = "admin"

那如果我们需要使用traits的话,需要在factory里设定好

FactoryBot.define do
  factory :user do
    trait :admin do
      role { 'Admin' }
    end

    trait :it do
      role { 'IT' }
    end
  end
end
user = build(:user, :admin)
p user.role
# output: Admin

rails单元测试

模型关系

外部gem: shoulda-matchers 地址: https://github.com/thoughtbot/shoulda-matchers

测试关联关系。

module Base
  #
  # 区域
  #
  # @author shawn.han <shawn.han@rccchina.com>
  #
  class Region < ApplicationRecord
    has_many :provinces, class_name: 'Base::Province'
  end
end
RSpec.describe Base::Region, type: :model do
  it { should have_many(:provinces).class_name('Base::Province') }
end

sidekiq

https://relishapp.com/rspec/rspec-rails/v/5-1/docs/job-specs/job-spec

def perform(table_name, id, action, raw_htmls_v2_uuid = nil)

  if (table_name.class != String) || (id.class != Integer)
    raise TypeError
  end

  params = { table_name: table_name, id: id, raw_htmls_v2_uuid: raw_htmls_v2_uuid }

  # 生成BidIn::API单例
  client = BidIn::API.instance

  case action
  when 'update'
    response = client.get(:update_solr, params)
  when 'destroy'
    response = client.get(:delete_solr, params)
  else
    raise KeyError
  end
end

job_spec

require 'rails_helper'

RSpec.describe Solr::BidMsSolrJob, type: :job do
  before :each do
    ActiveJob::Base.queue_adapter = :test
  end

  describe 'description' do
    before do
      allow_any_instance_of(BidIn::API).to receive(:get).and_return("null")
    end
    it "update_solr_job不应抛出异常" do
      expect{
        Solr::BidMsSolrJob.perform_now('raw_htmls', 122, 'update')
      }.not_to raise_error
    end
  end
  describe '参数逻辑校验' do
    it "错误的参数类型应抛出异常" do
      expect{
        Solr::BidMsSolrJob.perform_now(1234567, '333faga33', 'destafadaroy')
      }.to raise_error(TypeError)
    end

    it "错误的action应抛出异常" do
      expect{
        Solr::BidMsSolrJob.perform_now('table_name', 33333, 'delete')
      }.to raise_error(KeyError)
    end
  end
end

网络请求

https://github.com/bblimke/webmock。 在测试过程中,应当去模拟网络请求,并不真正的发送网络请求,假设我们测试的功能依赖到另外一个服务的接口,但是对方还没开发好,如果不模拟的话我们的工作进度也会受到影响(这是其中一点)

# frozen_string_literal: true

module BidIn
  #
  # API服务
  #
  # @author vic.sun <vic.sun@rccchina.com>
  #
  class API
    include Singleton
    include BidIn::APIExt::Path

    #
    # 返回招内资源
    #
    # @author vic.sun <vic.sun@rccchina.com>
    #
    # @jira [XFD-275] http://jira.rccchina.com/browse/XFD-275
    #
    # @return [RestClient::Resource] 资源
    #
    def resource
      RestClient::Resource.new(Settings.service.bid_in.host)
    end

    #
    # GET
    #
    # @author vic.sun <vic.sun@rccchina.com>
    #
    # @jira [XFD-275] http://jira.rccchina.com/browse/XFD-275
    #
    # @param [String] path 招内接口后缀 或者传递 BidIn::APIExt::Path::PATH中的symbol key
    # @param [Hash] params 参数
    #
    # @return [Hash] Json消息体
    #
    def get(path, params = {})
      path = path.is_a?(String) ? path : PATH[path]
      JSON.parse(resource[path].get(params: params), symbolize_names: true)
    end

    #
    # POST
    #
    # @author vic.sun <vic.sun@rccchina.com>
    #
    # @jira [XFD-275] http://jira.rccchina.com/browse/XFD-275
    #
    # @param [String] path 招内接口后缀 或者传递 BidIn::APIExt::Path::PATH中的symbol key
    # @param [Hash] params 参数
    #
    # @return [Hash] Json消息体
    #
    def post(path, params = {})
      path = path.is_a?(String) ? path : PATH[path]
      JSON.parse(resource[path].post(params: params, content_type: 'application/json'), symbolize_names: true)
    end
  end
end

api_spec

require 'rails_helper'

RSpec.describe BidIn::API do
  before do
    @client = BidIn::API.instance
  end

  describe '::resource' do
    it 'api地址应一致' do
      expect(@client.resource.url).to eq (Settings.service.bid_in.host)
    end
    it '创建多个resouce对象、内存id应相同' do
      @client_1 = BidIn::API.instance
      @client_2 = BidIn::API.instance
      expect(@client_1.object_id).to be @client_2.object_id
    end

  end
  describe '::GET' do
    it '返回结果类型应为包含code、msg的Hash结构体' do
      stub_request(:get, @client.resource.url).to_return(status: 200,
        body: '{"code": 200, "msg": "it\'s ok!"}', headers: {})
      rsp = @client.get("")
      expect(rsp.keys).to match_array [:code, :msg]
      expect(rsp[:code]).to eq(200)
      expect(rsp[:msg]).to eq("it's ok!")
    end
  end

end

测试覆盖率

覆盖率只是一种保证质量的手段, 并不是我们做UT的目的, UT是为了保证自己代码的健壮与完备。

1.分析未覆盖部分的代码,从而反推在前期测试设计是否充分,没有覆盖到的代码是否是测试设计的盲点,为什么没有考虑到?需求/设计不够清晰,测试设计的理解有误,工程方法应用后的造成的策略性放弃等等,之后进行补充测试用例设计。

2.检测出程序中的废代码,可以逆向反推在代码设计中思维混乱点,提醒设计/开发人员理清代码逻辑关系,提升代码质量。

3.代码覆盖率高不能说明代码质量高,但是反过来看,代码覆盖率低,代码质量不会高到哪里去,可以作为测试自我审视的重要工具之一。 使用 官方 https://github.com/simplecov-ruby/simplecov

SimpleCov 是 Ruby 的代码覆盖率分析工具, 他会在你进行单元测试时自动生成一个coverage目录, 你只需要跑完rspec文件后, 使用浏览器打开目录中的index.html即可看到覆盖率、漏失率等信息。