06 任务B 验证和单元测试

优质
小牛编辑
129浏览
2023-12-01

现在我们有一个初始的 prodcut model,我们也可以正常地通过 Rails 脚手架提供的数据完整维护应用。在这章中,我们将专注于使 model 更加安全,在我们进行 Depot 应用其他方面的流程前要确认提交给数据库的数据没有错误。

迭代 B1:验证

当操作迭代 A1 完成的应用时,我们的客户注意到一些不同寻常的东西。就算她输入了一个无效的价格或者忘记输入描述,应用也会正常接收数据并向数据库添加数据。如果说缺少描述只是让人有些尴尬,那么 $0.00 的价格就是在让她真实地损失金钱了,因此她让我们给应用添加一些验证。如果产品的标题或描述为空的话就不应该存在于数据库中,还有无效的图片 URL 及无效的价格的数据也一样不应该存在。

所以,我们要将验证放置在哪里合适呢?model 层是代码世界和数据库之间的守门员。无论是应用需要从数据库取出数据,还是将数据存入数据库都需要经过 model。这个原因让 model 成为放置验证的理想场所,无论数据来自表单还是来自应用中的代码维护,都需要经过 model 的验证。如果在将数据写入数据库前 model 已经检查了数据,数据库就不会受到不良数据的侵扰。

让我们看看 model 类的源代码(在 app/models/product.rb 中):

class Product < ActiveRecord::Base
end

我们添加的验证要简洁。让我们开始限制被存入数据库的文本域都应该不为空。我们可以通过添加如下代码完成此验证。我们可以通过添加如下代码完成此验证。

validates :title, :description, :image_url, presence: true

validate() 方法是标准的 Rails 验证器。它将检测一至多个 model 的域是否满足一至多个条件。

presence: true 告诉验证器检测每个命名的域是否已经存在以及它们的内容是否为空。下面的图例中,我们可以看见如果创建商品时提交了一个没有填写任何信息的表单的结果。这个结果是如此让人印象深刻,相应的字段伴随着错误进行了高亮显示,而且错误信息都在表单的顶部汇总显示。对于一行代码就能做到如此功能相当满意。你也许已经注意到在编辑和保存 product.rb 文件后你并不需要重启应用以测试你的修改,自动重载是因为 Rails 感知到了之前我们对 schema 的修改,也就意味着它总是使用我们最新版本的代码。

validating-that-fields-are-present.png

我们还需要验证价格是否为有效的正数。我们欣然使用 numericality() 选项验证价格是否为有效数字。而且再加上冗长的 :greater_than_or_equal_to 并跟随参数 0.01 完成此功能。

validates :price, numbericality: {greater_than_or_equal_to: 0.01}

现在,如果我们添加的商品没有使用有效的数字,相应的错误信息就会显示,就像下图一样:

the-price-fails-validation.png

为什么验证条件使用 1 美分而不是 0 呢?因为也可能在价格字段输入 0.0001。其实由于数据库存储的价格最多只容纳两位小数,那 0.0001 在数据库存储的值将为 0,即使我们验证条件填写是 0 它也将通过验证。检测数值最少为 0.01 是为了确保存储正确的值。

我们还有两个规则需要验证。第一,我们要检测每个商品有唯一的标题。model Product 的下一行就是干这个的。唯一性校验会执行一个简单的核验,也就是确认 products 表中是否还有其他数据已经有相同的标题。

validates :title, uniqueness: true

最后,我们还要验证图片 URL 是否有效。我们要使用 format 参数完成,它将会检测字段是否与一个正则表达式匹配。现在我们只检测 URL 是否与 .gif,.jpg 或 .png 结尾。

validates :image_url, allow_blank: true, format: {
  with: %r{\.(gif|jpg|png)\Z}i,
  message: 'must be a URL for GIF, JPG or PNG image.'
}

要注意,我们使用了 allow_blank 选项避免当字段为空时返回多个错误信息。

稍后,我们还可能要修改一下表单,让用户可以从可用的图片中进行选择,不过我们还是先进行验证,避免人们直接提交有问题的数据。

所以,经过了几分钟后我们添加了下列几项验证:

  • 标题,描述和图片 URL 字段不能为空

  • 价格只能填写最少为 $0.01 的有效数字

  • 所有商品的价格都是唯一的

  • 图片 URL 看起来要合理

你修改后的 Product model 应该如下面一样:

class Product < ActiveRecord::Base
  validates :title, :description, :image_url, presence: true
  validates :price, numericality: {greater_than_or_equal_to: 0.01}
  validates :title, uniqueness: true
  validates :image_url, allow_blank: true, format: {
    with: %r{\.{gif|jpg|png}\Z}i,
    message: 'must be a URL for GIF, JPG or PNG image.'
  }
end

临近此迭代的结尾,我们让客户操作一下应用,她也乐意之至。只花费了几分钟,简单地添加了一些验证就让商品维护界面更加可靠了。

在我们继续之前再运行一下测试。

哎呀!这次我们的测试失败了。有两个失败的测试,一个是 shoud create product,另一个是 should update product。肯定是刚才我们做的什么事情引起了创建和更新商品失败。这在意料之中。毕竟,你思考一下,这不正是验证的真正意义吗?

解决方法就是在 test/controllers/products_controller_test.rb 中提供有效的测试数据。

require 'test_helper'

class ProductsControllerTest < ActionController::TestCase
  setup do
    @product = products(:one)
    @update = {
      title: 'Lorem Ipsum',
      description: 'Wibbles are fun!',
      price: 19.95,
      image_url: 'lorem.jpg'
    }
  end

  test "should get index" do
    get :index
    assert_response :success
    assert_not_nil assigns(:products)
  end

  test "should get new" do
    get :new
    assert_response :success
  end

  test "should create product" do
    assert_difference('Product.count') do
      post :create, product: @update
    end

    assert_redirected_to product_path(assigns(:product))
  end

  test "should show product" do
    get :show, id: @product
    assert_response :success
  end

  test "should get edit" do
    get :edit, id: @product
    assert_response :success
  end

  test "should update product" do
    patch :update, id: @product, product: @update
    assert_redirected_to product_path(assigns(:product))
  end

  test "should destroy product" do
    assert_difference('Product.count', -1) do
      delete :destroy, id: @product
    end

    assert_redirected_to products_path
  end
end

在完成这个修改之后,我们回到测试,结果报告一切正常。但这只意味着我们没有破坏任何东西。我们还需要做更多。我们需要确认我们添加的验证条件不止当前是正常运转的,在我们后面的改动中它也能正常运转。在 8.4 节还会更多地谈到 controller 测试的详情。现在,我们是时候写一些单元测试了。

迭代 B2:Model 的单元测试

Rails 框架最有趣的一点是它从项目的一开始就支持测试。就如我们看到的,当你通过 rails 命令创建应用时,Rails 就已经生成了相应的测试基础设施。

让我们看看在 models 子路径下已经存在了哪些东西。

product_test.rb 是 Rails 创建用以处理我们创建的 model 的单元测试。这是个不错的开端,但 Rails 也只能帮我们这么多了。

让我们看看,当我们生成 model 时, Rails 在 test/models/product_test.rb 内生成了哪些测试。

require 'test_helper'

class ProductTest < ActiveSupport::TestCase
  # test "the truth" do
  #   assert true
  # end
end

生成的 ProductTest 是 ActiveSupport::TestCase 的子类。实际上,ActiveSupport::TestCase 是 MiniTest::Unit::TestCase 的一个子类,这个事情告诉我们 Rails 生成的测试都是基于跟随 Ruby 一同预先安装的 MiniTest 框架生成的。这其实是个好消息,它表示如果我们的 Ruby 程序已经通过了 MiniTest 的测试,那我们的这些程序同样可以通过 Rails 应用的测试。如果你是刚了解 MiniTest 还有些许担心,不过我们稍后会讲述它。

在测试用例内部,Rails 生成了一个被注释的测试,叫做「the truth」。test...do 的语法在初看时可能让人惊讶,但 Active Support 是通过结合类方法,选项小括号和代码块来定义一个测试方法的,这里只是展示了一个小小的例子。有时,真正的单元测试和这个有些不一样。

单元测试中的 assert 行是一个实实在在的测试。断言一般不会多于一个,它只需要保证测试的事实是正确的即可。再说得清楚点,这个例子就是个占位符,表示将会由你自己的测试替换。

一个真正的单元测试

让我们走进验证业务的测试。首先,如果我们创建一个不附带任何属性的商品,我们就会期望它是无效的,而且相应的字段还会附带错误信息。我们可以通过 model 的 errors()invalid?() 方法查看是否进行了验证,并且我们通过错误列表的 any?() 方法查看指定属性是否已经附带了相应的错误信息。

现在我们已经知道什么是测试了,我们需要了解怎样告诉测试框架测试代码是成功或者失败。我们可以通过 assertions(断言) 完成。断言是一种告诉框架我希望是 true 的简洁方式。最简单的断言就是 assert() 方法,它将断言参数是 true。如果符合预期,一切正常运行。不过,如果 assert() 的参数是 false,那么断言将会失败。框架将输出相关信息并且停止继续执行失败的测试方法。在我们的示例中,我们希望空 Product model 将不会通过验证,因此我们可以通过断言它是无效的表达自己的期望。

assert product.invalid?

我们要用下面的代码替换 the truth

test "product attributes must not be empty" do
  product = Product.new
  assert product.invalid?
  assert product.errors[:title].any?
  assert product.errors[:description].any?
  assert product.errors[:image_url].any?
  assert product.errors[:price].any?
end

我们可以使用 rake test:models 命令只显示 model 单元测试的结果。此时,当我们运行这个命令,我们会看见测试执行成功。

这个结果足以确证测试已经生效,并且我们的断言也已经全部通过。

我们可以继续深入探索,以及练习一下独特的测试。让我们看看大量可能发生的测试中的三个。

首先,我们要检测价格如我们期望的那样运转。

test "product price must be postive" do
  product = Product.new(title: 'My Book Title',
                       description: 'yyy',
                       image_url: 'zzz.jpg')
  product.price = -1
  assert product.invalid?
  assert_equal ['must be greater than or equal to 0.01'],
    product.errors[:price]

  product.price = 0
  assert product.invalid?
  assert_equal ['must be greater than or equal to 0.01'],
    product.errors[:price]

  product.price = 1
  assert product.valid?
end

在这段代码中,我们创建了一个新商品并试图将价格设置为 -1,0 和 +1,并每次都对商品进行验证。如果我们的 model 是正常运转的,前两个价格应该是无效的,并且 price 属性的验证错误信息也如同我们期望的一样。

最后一个价格是合理的,所以我们断言 model 是有效的。(有些人可以会将这个测试分隔为三个测试,那再好不过)。

接下来我们要检测图片 URL 是以 .gif,.jpg 或 .png 结尾的。

def new_product(image_url)
  Product.new(title: 'My Book Title',
             description: 'yyy',
             price: 1,
             image_url: image_url)
end

test "image url" do
  ok = %w{ fred.gif fred.jpg fred.png FRED.JPG
           FRED.Jpg http://a.b.c/x/y/z/fred.gif }
  bad = %w{ fred.doc fred.gif/more fred.gif.more }

  ok.each do |image_url|
    assert new_product(image_url).valid?, "#{image_url} should be valid"
  end
  bad.each do |image_url|
    assert new_product(image_url).invalid?, "#{image_url} should be invalid"
  end
end

这个例子掺杂的东西有点多。相比更好的分离测试我们使用了两个循环,一个检测是我们期望通过验证,另一个检测我们期望是无效的。而且我们还将两个循环中的共用代码进行了剔除。

你应该注意到我们在断言方法中添加了额外的参数。所有的断言方法都可以接收字符串作为可选择的结尾参数。它是作为断言失败时的错误信息存在的,这对确定哪个地方出错了很有帮助。

最后,我们的 model 还包含了一个验证,也就是检测所有商品的标题在数据库中都是唯一的。要测试这一项,我们需要将商品数据存储至数据库。

我们可以通过在一个测试中,创建商品数据,保存它,然而再创建相同标题的另一个商品进行存储这样的方式测试。这种方式可能已经足够清晰了,不过还有更加简洁的方式,我们通过 Rails fixtures(夹具) 实现。

测试夹具

在测试的世界中,夹具是你运行测试的环境。比如,你正在测试电路板,你应该需要将它挂载至一个可以提供能量和驱动测试方法的夹具上。

在 Rails 的世界里,一个测试夹具就是测试中一个或多个 model 的初始内容的详情。例如,如果我们想确认 products 表在每个单元测试开始时都已经被已知数据填充,我们就可以将这些内容放置在夹具中,剩下的交给 Rails 即可。

你可以将指定的夹具数据放置在 test/fixture 文件夹中。这些文件都将数据以 YAML 格式存储。每个夹具文件包含一个独立 model 的数据。夹具文件的名称格外重要,文件的基础名称必须匹配数据库表名。因为我们需要一些 Product model 的数据,这些数据需要存储至 products 表中,我们需要将这些数据添加至 products.yml 中。

当我们一开始创建 model 时 Rails 就创建了夹具文件。

# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html

one:
  title: MyString
  description: MyText
  image_url: MyString
  price: 9.99

two:
  title: MyString
  description: MyText
  image_url: MyString
  price: 9.99

夹具包含我们想要插入数据库的每行数据的条目。每行数据都赋予了一个名字。在 Rails 生成的夹具示例中,这些数据分别被命名为了 one 和 two。由于这个名字并不会被插入数据库,所以它对于相关的数据库也没有什么特别的意义。实际上,它是为了方便我们查看,名字可以帮助我们在测试代码中方便地关联相应数据。在生成集成测试的时候这些名字也有所帮助,不过现在我们暂时不关心。

在每个条目中,你会看见整齐排列的键值对。就如同在 config/database.yml 中看到的一样,在每个数据行的开头处你必须使用空格,而不是 tab,并且每行也要有相同的缩进。进行修改时要格外小心,你必须要保证每个条目的列名没有问题,如果与数据库的列名没有匹配上可能导致难以追踪的异常。

让我们添加一些用来测试 Product model 的数据。

ruby:
  title: Programming Ruby 1.9
  description:
    Ruby is the fastest growing and most exciting dynamic
    language out there. If you need to get working programs
    delivered fast, you should add Ruby to your toolbox.
  image_url: ruby.png
  price: 49.50

现在我们已经拥有了一个夹具文件,我们希望当我们运行单元测试时 Rails 已经将测试数据加载到数据库中。事实上,Rails 就是这样干的(这是约定大于配置的胜利!),不过你也可以通过 test/models/product_test.rb 中的这行代码指定需要加载的夹具数据。

class ProductTest < ActiveSupport::TestCase
  fixtures :products
  #...
end

在每个测试用例运行之前,fixtures() 会加载传递的夹具数据到与 model 名称一致的数据库表。夹具的文件名称已经区分了加载数据的表,所以使用 :products 将使 products.yml 夹具文件被使用。

让我们换种方式说说。在 ProductTest 类中,直接添加 fixtures 意味着 products 表将被置空,再将之前在夹具文件中定义的三行数据在测试方法运行前插入。

要注意的是,Rails 的脚手架生成的测试并不包含调用 fixtures 方法。这是因为默认情况下在运行测试前会加载所有夹具。因为默认情况一般就是你需要的,所以也没有必要去修改它。再说了,约定本身就是用来消除不必要的配置的。

现在我们已经对开发数据库做了所有工作了。尽管我们已经可以运行测试了,不过 Rails 还是需要使用测试数据库。如果你仔细看 config 路径下的 database.yml 文件,你就会注意到 Rails 实际已经创建了三个分离的数据库的配置。

  • db/development.sqlite3 是作为开发数据库。我们的程序要在这个数据库上正常运行。

  • db/test.sqlite3 是一个测试数据库

  • db/production.sqlite3 是生产数据库。当我们将程序上线时将使用这个数据库。

每个测试方法都应该使用由我们提供的夹具生成的测试数据库,并且最新被初始化的表。这个工作会由 rake test 命令自动完成,不过也可以通过 rake db:test:prepare 独立完成此步骤。

使用夹具数据

现在我们已经知道怎样在数据库中获取夹具数据了,我们要在测试中看看使用它的方式。

很显然,一种方式是通过 model 中的查询方法读取数据。不过,Rails 还提供了更加简便的方式。加载至测试中的每个夹具 Rails 都定义了和夹具名一样名字的方法。你可以通过这个方法预加载包含夹具数据的 model 对象,只需要简单地通过 YAML 夹具文件中条目的名字即可获得相应的数据。

在商品数据示例中,调用 product(:ruby) 将返回包含我们在夹具中定义数据对应的 Product model。让我们用这种方式测试一下商品标题唯一性校验。

test "product is invalid without a unique title" do
  product = Product.new(title: products(:ruby).title,
                       description: 'yyy',
                       price: 1,
                       image_url: 'ruby.png')
  assert product.invalid?
  assert_equal ['has already been taken'], product.errors[:title]
end

这个测试假设数据库已经包含了一行关于 Ruby 书籍的数据。通过下列代码获得了已经存在的相应数据条目的标题:

products(:ruby).title

接着创建了一个新的 Product model,并将标题设置为已经存在的标题。测试断言了尝试保存 model 的行为将失败,并且 tilte 属性将附带相应的错误信息。

如果你想避免使用 Active Record 错误的硬编码字符串,你可以将错误信息与已经构建好的错误信息表中内容比对。

test "product is invalid without a unique title" do
  product = Product.new(title: products(:ruby).title,
                       description: 'yyy',
                       price: 1,
                       image_url: 'ruby.png')
  assert product.invalid?
  assert_equal [I18n.translate('errors.messages.taken')], product.errors[:title]
end

我们会在 15 章具体讲述 I18n 方法。现在我们对我们的验证代码将持续正常运转保持自信了。我们的商品已经拥有了一个 model,一组 view,一个 controller 和 一组单元测试。它将作为这个应用接下来功能的良好基础。

小结

大约只用了十多行代码,我们便扩展了验证。

  • 我们确保了必须的字段都存在

  • 我们确保价格字段为数字,并且不小于一美分

  • 我们确保了标题是唯一的

  • 我们确保了只能提供相应格式的图片

  • 我们更新了 Rails 提供的单元测试,包括我们在 model 中添加的约束条件,也验证了我们新添加的代码

我们向客户展示成果,她同意现在的应用可以给管理员使用了,她说轻视她的客户并不是一件让她感到舒服的事情。所以,在下个迭代中我们必须关注普通用户的入口了。

自习天地

下面的知识需要你自己尝试了。

  • 如果你正在使用 Git,现在是一个提交劳动成果的好时机。你可以通过 git status 命令查看一下我们修改了哪些文件。

    当我们只是修改了已经存在的文件而没有添加新文件时,我们可以结合 git addgit commit 命令或者单独使用 git commit 加 -a 参数进行提交。

    git commit -a -m 'Validation!'

    这样我们就可以继续自由,安全地实验了,如果要返回原先的状态使用 git checkout 命令即可。

  • 验证参数 :length 可以检测 model 属性的长度。添加至 model Product 中对 title 校验是否满足最低 10 个字符的要求。

  • 修改你其中一个验证的错误信息