原文出自about.futurelearn.com,感谢作者Chris Zetter

Chris Zetter是FutureLearn产品组的一名开发者,他为我们讲述了自己的小组为了使功能测试兼具可维护性与可读性,在把Cucumber替换为RSpec之后是如何来编写测试的。

测试是建立与维护一个大型平台不可或缺的一部分。每当我们为FutureLearn这个平台增添新功能时,我们都会编写自动化的功能测试来记录这些新功能是如何运作的,并确保他们不运转时我们也能知晓。

令人爱恨交加的Cucumber

Cucumber是一款用来编写功能测试的常用工具,每当我们开启项目时它都是我们的不二选择。它可以让我们以用户的视角编写出高层级的行为驱动测试。

1
2
3
4
5
6
Feature: Enrolment
  Scenario: Enrolling in a course
    Given there is a course
    And I am logged in as a learner
    When I enrol on a course
    Then the course should appear in 'my courses'

我们乐于使用Cucumber因为它可以使根据用户故事编写测试变得简单易行,而且写完的测试通俗易懂。然而使用Cucumber也有些许不足之处。首先,我们已经在项目里使用了RSpec,再引入Cucumber意味着又要多维护一个测试框架;其次,由于两者的DSLs和测试运行器不同,在他们之间进行脑筋切换又会带来额外开销;最后,我们特别不喜欢Cucumber所使用的正则表达式,因为同Ruby的标准方法调用相比,它们使测试变得更加晦涩难懂。

编写更好的RSpec features

那么,我么该如何在不失测试可读性的前提下停用Cucumber呢?

我们已经开始使用RSpec features来替代Cucumber,它们通常看起来会是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
feature 'Enrolment' do
  scenario 'Enrolling in a course' do
    course = FactoryGirl.create(:course)

    learner = FactoryGirl.create(:learner)
    login_as learner

    visit course_path(course)
    find('.join').click
    expect(page).to have_content('Thanks for joining!')

    visit '/'
    expect(page).to have_main_header('My Courses')
    expect(page).to have_content(course.full_title)
  end
end

它们总是趋于变得很长,使得难以辨明其究竟在测试些什么。而且难以区分诸如Arrange, Act, Assert(在Cucumber里又被称为’Given’、’When’和’Then’)这些部分。我们试过在代码中这些步骤里添加注释,但它们就和通常那些程序代码里的注释一样不尽如人意:一段时间之后这些注释就变得与实际代码不同步了。

一般来说,如果是在程序里别的地方写出这么长的方法,我们就会有所警觉,并且通常会采用提取方法的办法进行重构。何不也这么做呢?让我们依据Cucumber步骤的风格,把这些代码也提取成一个个方法吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
feature 'Enrolment' do
  scenario 'Enrolling in a course' do
    given_there_is_a_course
    and_i_am_logged_in_as_a_learner
    when_i_enrol_on_a_course
    then_the_course_should_appear_in_my_courses
  end

  def given_there_is_a_course
    @course = FactoryGirl.create(:course)
  end

  def and_i_am_logged_in_as_a_learner
    @learner = FactoryGirl.create(:learner)
    login_as @learner
  end

  def when_i_enrol_on_a_course
    visit course_path(@course)
    find('.join').click
    expect(page).to have_content('Thanks for joining!')
  end

  def then_the_course_should_appear_in_my_courses
    visit '/'
    expect(page).to have_main_header('My Courses')
    expect(page).to have_content(@course.full_title)
  end
end

我们有何发现

我们移除了全部的Cucumber功能测试,并把它们中大部分用新式的RSpec features加以重写。这样一来即可保证拥有Cucumber所提供的优秀的可读性,又使得测试变得更加便于编写和维护。

我们做了一个慎重的决定,不把各个features文件里提取的方法进行复用,因为担心这么做会使得测试难于理解。我们发现在编写一个feature下的多条scenario时,会不自觉的就想要进行代码复用。

通过iframe在子页面里加载scorm课件, utf-8编码的可正常显示, shift_jis的乱码
可是子页面里已经通过meta设置了charset

怀疑是被其他地方的charset覆盖掉, 后来找到篇文章说可能有apache的AddDefaultCharset设置有关

本地测试验证了怀疑:

不加AddDefaultCharset 正常显示
加上AddDefaultCharset utf-8 乱码
改成AddDefaultCharset shift_jis 恢复正常

原文出自gotealeaf.com, 感谢作者Steve Turczyn

作为一个Rails开发者, 与ActiveRecord associations打交道实属家常便饭. 但其中若干特性, 却非尽人皆知.

自定义查询

假如你在开发一个允许发表评论的博客, 保不齐会遇到各种不和谐的言论充斥于评论间(这里毕竟是互联网). 为了保证只显示经核准的评论, 你要在comments表里加一个boolean型字段:approved.

对于每篇博客, 由于只想取出审核过的评论(即那些被管理员标记为通过的), 所以需要自定义查询.

1
2
3
4
# app/models/post.rb
class Post < ActiveRecord::Base
  has_many :approved_comments, -> { where approved: true }, class_name: 'Comment'
  ...

现在, 当你需要取出某篇博客里审核过的评论时, 只需要使用my_post.approved_comments.

扩展

有可能你还是需要有个选项能调出一篇博客的全部评论, 或者只看在今天发的那些. 这时你可以利用扩展功能, 在某个特定的关系上追加一个自定义方法.

1
2
3
4
5
6
7
8
# app/models/post.rb
class Post < ActiveRecord::Base
  has_many :comments do
    def today
      where("created_at >= ?", Time.zone.now.beginning_of_day)
    end
  end
  ...

这样一来, 除了原本的my_post.comments, 你还能通过诸如my_post.comments.today这种调用让检索更确切.

回调

在记录被添加或移除之前与之后, 自动的调用方法, 这个可以有.

假如你有一个建筑项目(BuildingProject), 里面有许多工人(Worker). 每次增加一个工人, 都需要重新计算项目预算和完工日期的变化, 而这些有又可能与工人类型工作经验等等息息相关…好个麻烦的方法, 要能在添加完工人后自动调用就好了.

1
2
3
4
5
6
7
8
# app/models/building_project.rb
class BuildingProject < ActiveRecord::Base
  has_many :workers, after_add: :recalculate_project_status
  ...
  def recalculate_project_status(newly_added_worker)
    ...
  end
  ...

inverse_of

inverse_of可以很方便的使相互关联的对象从任意方向被访问到. my_post.commentsmy_comment.post. 当然, 在Post里指定has_many :comments并且在Comment里指定belongs_to :post之后, 便已经可以得到上述2个关系. 但是加上inverse_of之后, 会让rails确保关系里的对象乃是相同的对象.

inverse_of指定方法如下:

1
2
3
4
# app/models/post.rb
class Post < ActiveRecord.Base
 has_many :comments, inverse_of: post
 ...

现在先去掉inverse_of, 如果你这样这样这样…

1
2
3
4
5
6
7
post = Post.first
post.update_attribute(:importance, false)
comment = post.comments.first
working_post = comment.post
working_post.update_attribute(:importance, true)
post.importance?
=> false

喔! 系统咋还是觉得你的blog不给力…这是因为comment.post执行了一次单独的DB查询, 得到的对象虽然对应DB里同一条Post记录,但却是不同的实例. 而原本那条post记录的对象还是不知道自己在DB的状态已经被改变了.

那么让我们把inverse_of加回去, 重新执行刚才的代码:

1
2
3
4
5
6
7
post = Post.first
post.update_attribute(:importance, false)
comment = post.comments.first
working_post = comment.post
working_post.update_attribute(:importance, true)
post.importance?
=> true

好多了! 加上inverse_of之后, comment.post不再做DB查询, 而是直接使用已被加到内存里的post对象的实例. 换言之, 这时的working_post就是上面的post, 所以对working_post的改变会直接反应在post上.