Why isn't "srb tc" finding the "expect" and "eq" methods for my RSpec tests?
Asked Answered
F

2

14

I'm trying out Sorbet in an experimental open-source project (ruby_crystal_codemod). I can't figure out how to get the type-checking to work with some RSpec tests in a nested test project. When I run srb tc, I am seeing some type-checking errors like this:

spec/src/example_class_annotated_spec.rb:6: Method it does not exist on T.class_of(<root>) https://srb.help/7003
     6 |  it 'should add @foo and @bar' do
     7 |    instance = ExampleClass.new(2, 3, 4)
     8 |    expect(instance.add).to eq 5
     9 |  end

spec/src/example_class_annotated_spec.rb:8: Method expect does not exist on T.class_of(<root>) https://srb.help/7003
     8 |    expect(instance.add).to eq 5
            ^^^^^^^^^^^^^^^^^^^^
    https://github.com/sorbet/sorbet/tree/67cd17f5168252fdec1ad04839b31fdda8bc6155/rbi/core/kernel.rbi#L2662: Did you mean: Kernel#exec?
    2662 |  def exec(*args); end
            ^^^^^^^^^^^^^^^

spec/src/example_class_annotated_spec.rb:8: Method eq does not exist on T.class_of(<root>) https://srb.help/7003
     8 |    expect(instance.add).to eq 5
                                    ^^^^

# etc.

Here is the source directory for the nested project on GitHub.

You should be able to run the following commands to reproduce the type error:

cd /tmp
git clone https://github.com/DocSpring/ruby_crystal_codemod.git
cd ruby_crystal_codemod
git checkout sorbet-rspec-type-checking-error
cd spec/fixtures/rspec_project/
bundle install
bundle exec srb tc

You should see these type errors:

spec/src/example_class_annotated_spec.rb:6: Method it does not exist on T.class_of(<root>) https://srb.help/7003
     6 |  it 'should add @foo and @bar' do
     7 |    instance = ExampleClass.new(2, 3, 4)
     8 |    expect(instance.add).to eq 5
     9 |  end

# etc.

Is there something wrong with the RBI files at spec/fixtures/rspec_project/sorbet/rbi/gems/rspec-core.rbi, etc.?

Fastigiate answered 8/12, 2019 at 10:13 Comment(2)
I think it does not make sense to type check spec files. Methods which comes from RSpec have nothing to do with your actual code which you probably want to type check. I would suggest to annotate spec files with # typed: false. Also, few additions: use tapioca gem to create type definitions for your gems and rspec-sorbet which allows to use doubles with typeed checked code.Hyalo
You should check out #74843332 which I had the issue and got something working, albeit not perfect.Howardhowarth
U
1

There's nothing wrong with the generated RBI file.

RSpec is very dependent on DSLs. When you call a method like RSpec.describe, which accepts a block, RSpec will execute that block in a specific scope with specific bindings, allowing you to call the RSpec testing DSL.

For Sorbet to gain knowledge of RSpec methods, it would need to be aware of this behaviour. There are ways in which you can hint Sorbet about the binding in which the code block received by a method will be executed. You can read about it on the Sorbet documentation.

If you look at the RBI file you mention, it doesn't include this information. Automatic RBI generators for gems usually limit themselves to establishing which constants (classes, modules) and which methods are present in a gem. This doesn't include a signature for those methods or their arguments.

If no hint as to the binding of a certain block, Sorbet assumes the block is bound to the block's outer context. For example, if you use RSpec.describe on Ruby's toplevel, that's the context Sorbet will assume for the block passed to describe. Since there's no definition of eq on the global Ruby scope, the typechecking will fail.

To solve this there are many things you can do, with varying levels of effort and reward involved.

  1. Don't use Sorbet in your tests. Some people will argue tests don't need to be typed. After having had to refactor a lot of code in very big monolitic applications I strongly disagree with this but the truth is this is the most effortless solution. Simply put # typed: ignore at the beggining of all your spec files and move on. You'll miss out on all the benefits of static typing in your test suite.
  2. Create a shim hinting Sorbet that the block passed to RSpec.describe should be bound to T.untyped. I've managed to use this solution in the past successfully. You will not get all the benefits of Sorbet, specially on the RSpec methods, but at least you will get typechecking when calling your own code. See below for an example of what this would look like. This will allow you to use # typed: true on your spec files.
  3. Create a shim binding every method in RSpec to the correct class. This would take a long time and no doubt be a really big effort but if you managed to do this, you'd get almost all the benefits of using Sorbet with RSpec. And you could share it with the community. This might also require writing code for a Tapioca compiler that generates appropriate types for things defined using let(:something).

I don't believe in 1 and 3 is a lot of work so now I will detail how to proceed for the second option.

Create a shim file for rspec in sorbet/rbi/shims/rspec.rbi like this one:

# typed: strict

module RSpec
end

In this file we will add the necessary signatures to not assume anything about the binding of the blocks passed to RSpec methods. For example, let's start with describe. If you go to the autogenerated RBI, you'll see it has this declaration:

  def self.describe(*args, &example_group_block); end

Let's copy this to our own RBI file and prepend our own signature.

# typed: strict

module RSpec
  sig do
    params(
      args: T.untyped,
      example_group_block: T.proc.bind(T.untyped).void
    ).void
  end
  def self.describe(*args, &example_group_block); end
end

We've now told Sorbet that the block you pass to describe is bound to T.untyped. This type acts as the escape hatch, allowing any method calls, so Sorbet will not complain about missing methods inside it. You might need to repeat this operation for other methods even in different RSpec classes until you can fully convince the typechecker to leave you alone.

With this you should be able to use # typed: true in your spec files. It's not perfect, actually it's nothing more than a patch, but it will at least grant you some very basic typechecking capabilities.

Unsatisfactory answered 25/6, 2023 at 0:17 Comment(0)
M
0

This is a good solution:

RSpec.describe(MyModel) do
  T.bind(self, T.untyped)
  # add your tests...
end

This allows autocompletion and benefits of type checking within it blocks, etc.. and for it to ignore 'it' and 'describe' blocks not being in sorbet's knowledge.

Maier answered 15/8, 2023 at 18:47 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.