Shared Contexts with RSpec

At Poll Everywhere, we test our Rails application with RSpec, a popular framework for behavior-driven development (BDD) in Ruby. We make extensive use of RSpec’s DSLs for test setup and expectations to keep our tests understandable and maintainable, but only rarely did we use RSpec’s shared contexts. On a recent project, I experimented with shared contexts to see if they could reduce boilerplate in the tests and was generally satisfied with the results, but also found a sneaky gotcha!

Shared Contexts in RSpec

In RSpec, every example runs in a context: data and configuration for the example to draw on, including lifecycle hooks, let and subject declarations, and helper methods. These contexts are inherited (and can be overridden) by nested example groups. However, just as object inheritance is not always the right model for sharing code, context inheritance is not always the right model for sharing context. RSpec’s shared contexts act like Ruby modules, allowing you to reuse a context between example groups that don’t form a parent-child relationship.

Like shared examples, shared contexts are extremely useful for cutting down clutter and duplication in specs. For example, consider a Rails controller that has several different actions (say, show, and update), but always needs a user to be logged in, and may respond differently based on that user’s permissions. In general, these actions do not behave similarly, so shared examples are inappropriate, but it would be a pain to recreate each different type of user for each different action. This is a perfect case for shared contexts:

Sharing contexts by metadata

As shown in the above example, shared contexts are defined using shared_context and added to an example group’s context with include_context. However, shared contexts can also be added to a context using metadata. When defining the shared context, pass a symbol as the second argument to shared_context (after the name of the context). Any example or example group that has the same symbol as its second argument will automatically include the shared context.

(To be more precise, RSpec actually expects and stores metadata as a hash, so you can pass a hash and store multiple key-value pairs in the metadata for both shared_context definitions and examples and example groups. As a convenience, RSpec interprets symbols on their own as keys with a default value of true. However you define the metadata, RSpec will automatically include a shared context if any of the key-value pairs in an example’s metadata matches any of the key-value pairs in the shared context’s metadata.)

Tradeoffs and recommendations

Sharing context via metadata is convenient because it’s so concise: minimal keystrokes, no need for additional nesting, and very modular. However, it has one big drawback: the metadata keys are shared globally. If two different shared contexts use the same metadata key and define the same let, for example, as two different values, then the actual value of that let will depend on the order of execution.

We ran into trouble with this when two different spec files each defined their own shared contexts with the same lets. When these two specs were run in the same process (we use parallel-rspec to parallelize our test suite), one of them would fail, apparently without reason. This happened sporadically enough that it actually took almost two months for us to realize what was happening.

As a result, Poll Everywhere has adopted the convention that we only use metadata to share contexts that are needed in multiple different specs (for example, request mocking for our payment processor, or anything else in spec/support). For shared contexts limited in scope to a single spec file, we explicitly add the context with include_context.

Conclusion

Shared contexts are a nice way to clean up boilerplate setup in RSpec tests, especially when that setup cuts across multiple contexts. Sharing contexts with metadata is convenient for test writing and can make for test descriptions that are easy to read and understand, but beware their global scope. As with any tool, your mileage may vary, but I hope that with shared contexts and metadata in hand you can write shorter, clearer tests without the headache of mysteriously inconsistent CI.