加载中

One of the primary reasons people end up being lax in letting specifications drive the development of their Rails applications is the time it takes to get feedback from running the suite of specifications. A number of tools have been built to help alleviate this pain like Spork, Zeus, and Spring. In fact Rails 4.1 will now come with Spring standard. Unfortunately, these tools are just crutches that tackle the symptoms of the problem rather than the problem itself. The actual problem is writing tightly coupled code that expects to have the full Rails framework always present, which is slow to start up.

人们疏于在Rails开发应用中去驾驭规范的一个基本的原因是运行的规范套件所需要的时间。很多工具可以用来缓和这个麻烦,比如 Spork Zeus 和  Spring。事实上,Rails 4.1将会在春季推出标准。不幸的是,这些工具仅仅是解决问题症状的 一个拐杖,而不是解决问题本身。实际的问题是书写耦合度高的代码需要有一个完整的Rails的架构支撑,这个架构会缓慢启动。

Developing Decoupled Code

The solution is to write code that is isolated and decouple your components from as much of the system as possible. In other words, write SOLID Rails code. As a specific example, one might typically directly use a model class to create an instance. Instead we can use dependency injection to remove hard coded references to classes. We just need to make sure we safely reference the defaults using either block notation or a lazy evaluating ||=. Below we have a service that needs to create Widgets which happen to be ActiveRecord models. Instead of directly referencing theWidget class, we use lazy evalutation in our chosen injection method. This allows us to decouple our code and not need ActiveRecord loaded.

# A tightly coupled class. We don't want this.
class MyService
  def create_widget(params)
    Widget.create(params)
  end
end

# We can inject in the initializer
class MyService
  attr_reader :widget_factory

  def initialize(dependencies={})
    @widget_factory = dependencies.fetch(:widget_factory) { Widget }
  end

  def create_widget(params)
    widget_factory.create(params)
  end
end

# Or we can explictly inject via a setter with a lazy reader
class MyService
  attr_writer :widget_factory

  def widget_factory
    @widget_factory ||= Widget
  end

  def create_widget(params)
    widget_factory.create(params)
  end
end

# A specification injecting the dependency using the second method
describe MyService do
  subject(:service) { MyService.new }
  let(:widget_factory) { double 'widget_factory', create: nil }
  before { service.widget_factory = widget_factory }

  it 'creates a widget with the factory' do
    service.create_widget({name: 'sprocket'})
    expect(widget_factory).to have_received(:create).with({name: 'sprocket'})
  end
end

开发解耦代码

一种解决方法是:书写的代码是独立的,元件尽可能的与系统分离。用另外的话说,就是写SOLID Rails 代码。举一个特殊的例子,可以直接写一个类模块去创建一个事例。而不是使用依赖的插入的方法去去除涉及到类的硬编码。我们仅仅需要去保证:我们安全的采用模块符号或者懒惰的评价去得到默认的引用。以下是一个服务,它需要在ActiveRecord模块中创建一个小工具。我们采用懒惰的评价去介入的方法来替换直接的引用工具类。这可以解耦我们的代码,同时不需要ActiveRecord载入。

# A tightly coupled class. We don't want this.
class MyService
  def create_widget(params)
    Widget.create(params)
  end
end

# We can inject in the initializer
class MyService
  attr_reader :widget_factory

  def initialize(dependencies={})
    @widget_factory = dependencies.fetch(:widget_factory) { Widget }
  end

  def create_widget(params)
    widget_factory.create(params)
  end
end

# Or we can explictly inject via a setter with a lazy reader
class MyService
  attr_writer :widget_factory

  def widget_factory
    @widget_factory ||= Widget
  end

  def create_widget(params)
    widget_factory.create(params)
  end
end

# A specification injecting the dependency using the second method
describe MyService do
  subject(:service) { MyService.new }
  let(:widget_factory) { double 'widget_factory', create: nil }
  before { service.widget_factory = widget_factory }

  it 'creates a widget with the factory' do
    service.create_widget({name: 'sprocket'})
    expect(widget_factory).to have_received(:create).with({name: 'sprocket'})
  end
end

A Base Rails-free Configuration

When writing your applications in this way you can then start to restructure how you setup your specifications and minimize the required environment to run both your specification and your code fulfilling the specification. The typical spec_helper.rb will have a line like this:

require File.expand_path("../../config/environment", __FILE__)

This is what loads your entire Rails application and slows down the running of your tests. To make your specifications faster, you need to use a configuration file that does not contain this line. So let’s start by creating a very light weight base_spec_helper.rb:

ENV["RAILS_ENV"] ||= 'test'
require 'rubygems'

RAILS_ROOT = File.expand_path('../..', __FILE__)
Dir[File.join(RAILS_ROOT, 'spec/support/**/*.rb')].each {|f| require f}

RSpec.configure do |config|
  config.mock_with :rspec
  config.order = 'random'
  # Your prefered config options go here
end

require 'active_support'
require 'active_support/dependencies'

We are requiring active_support and active_support/dependencies so we can have access to the autoloader Rails uses without actually loading up all of Rails. It is fairly light weight and the convienence outweighs the cost. In each spec helper which requires this base we will add the relevant portions of our app into the ActiveSupport::Dependencies.autoload_paths.

当你采用这种方式写代码时,你可以开始重新组织怎么建立自己的规范和最小化环境需求来运行这些规范和满足规则需求的代码。典型spec_helper.rb会有一个如下的一行代码:

require File.expand_path("../../config/environment", __FILE__)

这个将会载入整个的Rails程序且降低测试运行速度。为了让规范达到更快的速度,可以使用一个不含有上面那行代码的配置文件。那么让我们开始创建一个轻量级的rb包:base_sepc_helper.rb:

ENV["RAILS_ENV"] ||= 'test'
require 'rubygems'

RAILS_ROOT = File.expand_path('../..', __FILE__)
Dir[File.join(RAILS_ROOT, 'spec/support/**/*.rb')].each {|f| require f}

RSpec.configure do |config|
  config.mock_with :rspec
  config.order = 'random'
  # Your prefered config options go here
end

require 'active_support'
require 'active_support/dependencies'

我们通过请求active_support和active_support/dependencies包来访问Rails使用的自动装载机,实际上并没有导入所有的Rails。它是相当的轻量级并且方便性超过了损耗。在每个需要这个base包的helper里,我们将会添加我们程序相对应的部分到ActiveSupport::Dependencies.autoload_paths中。

Plain Ruby Object Specifications

Depending on the part of application that you are specifying, you can create spec helpers specific to what you need in any one context. For example, the simplest would be one for specifying any type of pure Ruby class such as a service class. A sample services_spec_helper.rb might be:

require 'base_spec_helper'
Dir[File.join(RAILS_ROOT, "spec/support_services/**/*.rb")].each {|f| require f}
ActiveSupport::Dependencies.autoload_paths << "#{RAILS_ROOT}/app/services"

Decorator Specifications

For your decorators, you might choose to use Draper and your decorators_spec_helper.rb might look like:

require 'base_spec_helper'
require 'draper'
Draper::ViewContext.test_strategy :fast
Dir[File.join(RAILS_ROOT, "spec/support_decorators/**/*.rb")].each {|f| require f}
ActiveSupport::Dependencies.autoload_paths << "#{RAILS_ROOT}/app/decorators"

简单的Ruby对象说明

取决于你指定的应用程序部分,你可以在任意一个上下文中创建一个你所需要的辅助细则。例如,最简单的是指定一个任意类型的Ruby纯类作为服务类。如下面services_spec_helper.rb例子

require 'base_spec_helper'
Dir[File.join(RAILS_ROOT, "spec/support_services/**/*.rb")].each {|f| require f}
ActiveSupport::Dependencies.autoload_paths << "#{RAILS_ROOT}/app/services"

装饰说明

于你的装饰而言,你可能会选择布商,你的decorators_spec_helper.rb就如以下所看到的。

require 'base_spec_helper'
require 'draper'
Draper::ViewContext.test_strategy :fast
Dir[File.join(RAILS_ROOT, "spec/support_decorators/**/*.rb")].each {|f| require f}
ActiveSupport::Dependencies.autoload_paths << "#{RAILS_ROOT}/app/decorators"

Model Specifications

Testing models needs a little bit more. Assuming you are using ActiveRecord you’ll need to include that as well as establish a connection to your database. We won’t include factory_girl or database_cleaner as most of your tests should not be actually creating database objects. In fact, the only place you really need to actually create an object in the database is when testing uniqueness validations. When you do need to create something you can just manually clean it up or use a transaction. So a sample models_spec_helper.rb can look like this:

require 'base_spec_helper'
require 'active_record'
# RSpec has some nice matchers for models so we'll pull them in
require 'rspec/rails/extensions/active_record/base'
Dir[File.join(RAILS_ROOT, "spec/support_models/**/*.rb")].each {|f| require f}

# Manually connect to the database
ActiveRecord::Base.establish_connection(
  YAML.load(File.read(RAILS_ROOT + '/config/database.yml'))['test']
)

ActiveSupport::Dependencies.autoload_paths << "#{RAILS_ROOT}/app/models"

Feature Specifications

Finally, when creating feature specs, we do need our full Rails stack and ourfeature_spec_helper.rb is going to look very similar to what your currentspec_helper.rb looks like.

模块规范

测试模块还需要做一点事情. 假设你现在正在用ActiveRecord你会需要建立一个和数据库的连接. 我们并不需要将defactory_girl或者database_cleaner加入你的测试中,而且并不会真的创建对象. 实际上,唯一需要进行创建数据库对象的地方就是当你进行特定对象测试的时候.当你确实需要创建一些对象的时候,你只需要手动的进行清理和转换. 这就是一个样例models_spec_helper.rb:

require 'base_spec_helper'
require 'active_record'
# RSpec has some nice matchers for models so we'll pull them in
require 'rspec/rails/extensions/active_record/base'
Dir[File.join(RAILS_ROOT, "spec/support_models/**/*.rb")].each {|f| require f}

# Manually connect to the database
ActiveRecord::Base.establish_connection(
  YAML.load(File.read(RAILS_ROOT + '/config/database.yml'))['test']
)

ActiveSupport::Dependencies.autoload_paths << "#{RAILS_ROOT}/app/models"

特点说明

最后, 当我们创建特色应用时, 我们会需要Rails全套知识并且feature_spec_helper.rb看起来就和spec_helper.rb差不多了.

Summary

I found myself using varitions on the above spec helpers in projects I work on and decided I would write a set of generators to make it easier to bootstrap the project. The gem can be found at https://github.com/Originate/rails_spec_harness

While introducing these changes into existing projects I have found speed increases of 8-12 times. The worst project experienced a 27x increase once these changes and the corresponding changes in coding habits where applied. As an example I made a specification with 4 examples for a plain Ruby class. I then used the time command line utility to measure running rspec with the minimal spec helper as well as the full Rails spec helper and found the following:

Spec Helper Real User Sys RSpec Reported
Full Rails 4.913s 2.521s 1.183s 0.0706s
Minimal 0.492s 0.407s 0.080s 0.0057s

Write SOLID code, isolate your specifications and enjoy a fun and sane development experience.

作为总结

我自己也开始在项目中加入这些改变并且这也让我能用更加简单的代码去完成一个项目. 你们可以在Github上找到:https://github.com/Originate/rails_spec_harness

当在项目中引入这些变化时候,我发现速度至少增长了8-12倍. 变化最大的一个项目竟然增长了27倍同时也包括了这些对应的编程效率上的提高.举个例子,我开始写一个含有4个简单例子的Ruby类. 然后我使用time命令行工具去衡量运行的效率,并且之后我能得到如下的结果,FULL Rails VS MINIMAL:

Spec Helper Real User Sys RSpec Reported
Full Rails 4.913s 2.521s 1.183s 0.0706s
Minimal 0.492s 0.407s 0.080s 0.0057s

写牛逼的代码,隔离你的单独模块,然后,享受编码的乐趣吧。

返回顶部
顶部