Gem Version Dependency Status Build Status Coverage Status Code Climate

Transpec

Transpec automatically converts your specs into latest RSpec syntax with static and dynamic code analysis.

This aims to facilitate smooth transition to RSpec 3.

See the following pages for the new RSpec syntax and the plan for RSpec 3:

Transpec now supports almost all conversions for the RSpec changes, but the changes for RSpec 3 is not fixed and may vary in the future. So it's recommended to follow updates of both RSpec and Transpec.

Examples

Here's an example spec:

describe Account do
  subject(:account) { Account.new(logger) }
  let(:logger) { mock('logger') }

  describe '#balance' do
    context 'initially' do
      it 'is zero' do
        .balance.should == 0
      end
    end
  end

  describe '#close' do
    it 'logs an account closed message' do
      logger.should_receive(:account_closed).with()
      .close
    end
  end

  describe '#renew' do
    context 'when the account is renewable and not closed' do
      before do
        .stub(:renewable? => true, :closed? => false)
      end

      it 'does not raise error' do
        lambda { .renew }.should_not raise_error(Account::RenewalError)
      end
    end
  end
end

Transpec would convert it to the following form:

describe Account do
  subject(:account) { Account.new(logger) }
  let(:logger) { double('logger') }

  describe '#balance' do
    context 'initially' do
      it 'is zero' do
        expect(.balance).to eq(0)
      end
    end
  end

  describe '#close' do
    it 'logs an account closed message' do
      expect(logger).to receive(:account_closed).with()
      .close
    end
  end

  describe '#renew' do
    context 'when the account is renewable and not closed' do
      before do
        allow().to receive(:renewable?).and_return(true)
        allow().to receive(:closed?).and_return(false)
      end

      it 'does not raise error' do
        expect { .renew }.not_to raise_error
      end
    end
  end
end

Actual examples

You can see actual conversion examples below:

Installation

$ gem install transpec

Basic Usage

Before converting your specs:

  • Make sure your project has rspec gem dependency 2.14 or later. If not, change your *.gemspec or Gemfile to do so.
  • Run rspec and check if all the specs pass.
  • Ensure the Git repository is clean. (You don't want to mix up your changes and Transpec's changes, right?)

Then, run transpec (using --generate-commit-message is recommended) in the project root directory:

$ cd some-project
$ transpec --generate-commit-message
Copying project for dynamic analysis...
Running dynamic analysis with command "bundle exec rspec"...
...............................................................................
...................

Finished in 13.07 seconds
100 examples, 0 failures

Converting spec/spec_helper.rb
Converting spec/support/cache_helper.rb
Converting spec/support/file_helper.rb
Converting spec/support/shared_context.rb
Converting spec/transpec/ast/node_spec.rb

This will run the specs, convert them, and overwrite all spec files in the spec directory.

After the conversion, run rspec again and check whether all pass:

$ bundle exec rspec

If all pass, commit the changes with auto-generated message:

$ git add -u
$ git commit -eF .git/COMMIT_EDITMSG

And you are done!

Options

-f/--force

Force processing even if the current Git repository is not clean.

$ git status --short
 M spec/spec_helper.rb
$ transpec
The current Git repository is not clean. Aborting.
$ transpec --force
Copying project for dynamic analysis...
Running dynamic analysis with command "bundle exec rspec"...

-s/--skip-dynamic-analysis

Skip dynamic analysis and convert with only static analysis. Note that specifying this option decreases the conversion accuracy.

-c/--rspec-command

Specify command to run RSpec that is used for dynamic analysis.

Transpec needs to run your specs in copied project directory for dynamic analysis. If your project requires some special setup or commands to run specs, use this option. bundle exec rspec is used by default.

$ transpec --rspec-command "./some_special_setup.sh && bundle exec rspec"

-m/--generate-commit-message

Generate commit message that describes conversion summary. Currently only Git is supported.

When you commit, you need to run the following command to use the generated message:

$ git commit -eF .git/COMMIT_EDITMSG

-k/--keep

Keep specific syntaxes by disabling conversions.

$ transpec --keep should_receive,stub

Available syntax types

Type Target Syntax Converted Syntax
should obj.should matcher expect(obj).to matcher
should_receive obj.should_receive expect(obj).to receive
stub obj.stub allow(obj).to receive
have_items expect(obj).to have(x).items expect(obj.size).to eq(x)
its its(:attr) { } describe { subject { } it { } }
deprecated obj.stub!, mock('foo'), etc. obj.stub, double('foo')

See Supported Conversions for more details.

-n/--negative-form

Specify negative form of to that is used in expect syntax. Either not_to or to_not. not_to is used by default.

$ transpec --negative-form to_not

-p/--no-parentheses-matcher-arg

Suppress parenthesizing argument of matcher when converting should with operator matcher to expect with non-operator matcher (expect syntax does not directly support the operator matchers). Note that it will be parenthesized even if this option is specified when parentheses are necessary to keep the meaning of the expression.

describe 'original spec' do
  it 'is an example' do
    1.should == 1
    2.should > 1
    'string'.should =~ /^str/
    [1, 2, 3].should =~ [2, 1, 3]
    { key: value }.should == { key: value }
  end
end

describe 'converted spec' do
  it 'is an example' do
    expect(1).to eq(1)
    expect(2).to be > 1
    expect('string').to match(/^str/)
    expect([1, 2, 3]).to match_array([2, 1, 3])
    expect({ key: value }).to eq({ key: value })
  end
end

describe 'converted spec with -p/--no-parentheses-matcher-arg option' do
  it 'is an example' do
    expect(1).to eq 1
    expect(2).to be > 1
    expect('string').to match /^str/
    expect([1, 2, 3]).to match_array [2, 1, 3]
    # With non-operator method, the parentheses are always required
    # to prevent the hash from being interpreted as a block.
    expect({ key: value }).to eq({ key: value })
  end
end

Inconvertible Specs

You might see the following warning while conversion:

Cannot convert #should into #expect since #expect is not available in the context.
spec/awesome_spec.rb:4:      1.should == 1

This message would be shown with specs like this:

describe '#should that cannot be converted to #expect' do
  class MyAwesomeTestRunner
    def run
      1.should == 1
    end
  end

  it 'is 1' do
    test_runner = MyAwesomeTestRunner.new
    test_runner.run
  end
end

Reason

  • should is defined on BasicObject class, so you can use should everywhere.
  • expect is defined on RSpec::Matchers module that is included by RSpec::Core::ExampleGroup class, so you can use expect only where self is an instance of RSpec::Core::ExampleGroup (i.e. in it blocks, :each hook blocks or included module methods) or other classes that include RSpec::Matchers.

With the above example, in the context of 1.should == 1, the self is an instance of MyAwesomeTestRunner. Transpec tracks contexts and skips conversion if the target syntax cannot be converted in a case like this.

Solution

Include or extend RSpec::Matchers module to make expect available in the context:

  class MyAwesomeTestRunner
    include RSpec::Matchers

    def run
      1.should == 1
    end
  end

Then run transpec again.

Supported Conversions

Standard expectations

# Targets
obj.should matcher
obj.should_not matcher

# Converted
expect(obj).to matcher
expect(obj).not_to matcher
expect(obj).to_not matcher # with `--negative-form to_not`

Operator matchers

# Targets
1.should == 1
1.should < 2
Integer.should === 1
'string'.should =~ /^str/
[1, 2, 3].should =~ [2, 1, 3]

# Converted
expect(1).to eq(1)
expect(1).to be < 2
expect(Integer).to be === 1
expect('string').to match(/^str/)
expect([1, 2, 3]).to match_array([2, 1, 3])

be_close matcher

# Targets
expect(1.0 / 3.0).to be_close(0.333, 0.001)

# Converted
expect(1.0 / 3.0).to be_within(0.001).of(0.333)

have(n).items matcher

# Targets
expect(collection).to have(3).items
expect(collection).to have_exactly(3).items
expect(collection).to have_at_least(3).items
expect(collection).to have_at_most(3).items
collection.should have(3).items

expect(team).to have(3).players

# Assume #players is a private method.
expect(team).to have(3).players

# Converted
expect(collection.size).to eq(3)
expect(collection.size).to be >= 3
expect(collection.size).to be <= 3
collection.size.should == 3  # with `--keep should`

expect(team.players.size).to eq(3)

# have(n).items matcher invokes #players even if it's a private method.
expect(team.send(:players).size).to eq(3)

There's the option to continue using have(n).items matcher with rspec-collection_matchers that is an external gem extracted from rspec-expectations. If you choose so, disable this conversion with --keep have_items.

Expectations on block

# Targets
lambda { do_something }.should raise_error
proc { do_something }.should raise_error
-> { do_something }.should raise_error

# Converted
expect { do_something }.to raise_error

Negative error expectations with specific error

# Targets
expect { do_something }.not_to raise_error(SomeErrorClass)
expect { do_something }.not_to raise_error('message')
expect { do_something }.not_to raise_error(SomeErrorClass, 'message')
lambda { do_something }.should_not raise_error(SomeErrorClass)

# Converted
expect { do_something }.not_to raise_error
lambda { do_something }.should_not raise_error # with `--keep should`

Message expectations

# Targets
obj.should_receive(:foo)
SomeClass.any_instance.should_receive(:foo)

# Converted
expect(obj).to receive(:foo)
expect_any_instance_of(SomeClass).to receive(:foo)

Message expectations that are actually method stubs

# Targets
obj.should_receive(:foo).any_number_of_times
obj.should_receive(:foo).at_least(0)

SomeClass.any_instance.should_receive(:foo).any_number_of_times
SomeClass.any_instance.should_receive(:foo).at_least(0)

# Converted
allow(obj).to receive(:foo)
obj.stub(:foo) # with `--keep stub`

allow_any_instance_of(SomeClass).to receive(:foo)
SomeClass.any_instance.stub(:foo) # with `--keep stub`

Method stubs

# Targets
obj.stub(:foo)

obj.stub!(:foo)

obj.stub(:foo => 1, :bar => 2)

SomeClass.any_instance.stub(:foo)

# Converted
allow(obj).to receive(:foo)

allow(obj).to receive(:foo)

allow(obj).to receive(:foo).and_return(1)
allow(obj).to receive(:bar).and_return(2)

allow_any_instance_of(SomeClass).to receive(:foo)

Deprecated method stub aliases

# Targets
obj.stub!(:foo)
obj.unstub!(:foo)

# Converted
obj.stub(:foo) # with `--keep stub`
obj.unstub(:foo)

Method stubs with deprecated specification of number of times

# Targets
obj.stub(:foo).any_number_of_times
obj.stub(:foo).at_least(0)

# Converted
allow(obj).to receive(:foo)
obj.stub(:foo) # with `--keep stub`

Deprecated test double aliases

# Targets
stub('something')
mock('something')

# Converted
double('something')

Expectations on attribute of subject with its

# Targets
describe 'example' do
  subject { { foo: 1, bar: 2 } }
  its(:size) { should == 2 }
  its([:foo]) { should == 1 }
  its('keys.first') { should == :foo }
end

# Converted
describe 'example' do
  subject { { foo: 1, bar: 2 } }

  describe '#size' do
    subject { super().size }
    it { should == 2 }
  end

  describe '[:foo]' do
    subject { super()[:foo] }
    it { should == 1 }
  end

  describe '#keys' do
    subject { super().keys }
    describe '#first' do
      subject { super().first }
      it { should == :foo }
    end
  end
end

There's the option to continue using its with rspec-its that is an external gem extracted from rspec-core. If you choose so, disable this conversion with --keep its.

Compatibility

Tested on MRI 1.9, MRI 2.0 and JRuby in 1.9 mode.

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request

License

Copyright (c) 2013 Yuji Nakayama

See the LICENSE.txt for details.