Talks

How RSpec works

RSpec is a much beloved series of libraries, currently holding spots 1, 2, 4, 5, and 6 in terms of most downloaded gems. Many of us use it every day, but the question is, how does it work?

In this talk, you will learn about how RSpec executes your tests, the anatomy of an expect(...).to ... expression, and how stubs and mocks work. You'll learn deeply about the behind the scenes architecture of RSpec. This talk is quite technical, and a fair amount of Ruby knowledge is assumed.

RubyKaigi 2019

00:00:00.030 Hello everyone! I’m Sam Phippen, and this is how RSpec works. Let me start by introducing myself. My name is Sam Phippen, and I can be found as @SamPhippen basically everywhere on the Internet. I am the lead maintainer of RSpec, and I work at Google as a developer advocate for the Google Ads team. I want to clarify that any code shared in this talk, apart from code sourced from RSpec itself, is copyrighted by Google under the Apache License.
00:00:15.269 Yesterday morning, we discovered something surprising: Matt hates tests! As the maintainer of RSpec, this was a pretty significant blow to me, leading to a shocking announcement at RubyKaigi 2019: RSpec is canceled! Thank you very much for listening, I’m Sam Phippen. Now, let’s dive into some actual content. If you visit rubygems.org/stats, you’ll find that RSpec and its external dependency, Diff::LCS, hold the top six spots on the RubyGems page.
00:00:39.360 I’m not sharing this to inflate my ego, although it feels nice. I joined the Ruby community because of the strong emphasis on testing culture that permeates it. When I got involved, there was a lot of work needed on the RSpec tool, and I'm happy to say that it has significantly matured since then. Many of you enjoy using it, and I appreciate your downloads. When you add 'gem rspec' to your Gemfile, you're generally indicating that you want the entire RSpec framework. However, when you run 'bundle install', if you look at the Gemfile.lock generated, you'll discover that RSpec pulls in RSpec Core, RSpec Expectations, and RSpec Mocks. Moreover, scrolling down through the Gemfile will reveal dependencies where 'rspec-support' is quite prevalent.
00:01:55.770 What I find interesting about RSpec is that it isn't packaged as a single monolithic library. Instead, it comprises these three independent components: RSpec Core, RSpec Mocks, and RSpec Expectations. Each is designed to function independently, allowing you to pull in just one of these components if desired. For instance, if you’re using MiniTest and find its mocking capabilities inadequate, you can simply integrate RSpec Mocks without having to adopt the entire framework. This flexibility is something I encounter frequently. RSpec Expectations allow you to use the 'expect' keyword, which provides powerful matchers, as well as the ability to compose them.
00:02:47.550 Let me also mention RSpec Support. It's a sort of internal, private set of APIs shared among the RSpec gems, designed to provide common functionalities. One example of its necessity is that RSpec can’t directly require any files from Ruby’s standard library. If it did and you omitted that 'require' in your code, you could risk introducing broken production code. Therefore, RSpec Support reimplements certain functions from Ruby’s standard library and includes other shared behaviors, which we needed to extract from RSpec.
00:03:40.620 If you're working on a Rails application, you're likely familiar with adding 'gem rspec-rails' to your Gemfile. The dependency diagram for that looks a bit like this: RSpec Rails wraps around many of the Rails testing classes and essentially acts as a conduit for running RSpec tests against Rails code. RSpec Rails pulls in the core components of RSpec, so you don’t need to redundantly state 'gem rspec' when specifying 'gem rspec-rails' in your Gemfile. This brief overview gives you an idea of how dependencies interact, but let's get to the crux: What occurs when you write an RSpec file and execute it?
00:04:53.500 To illustrate that, we’ll use a straightforward example: 'describe calculation do; it adds numbers; expect 1 + 1 to eq 2'. This is as basic as an RSpec file can get. First, you should understand that many RSpec functions you're using at the top level in the DSL are essentially wrappers around constructing objects from the RSpec library. In this case, 'describe' is a keyword in RSpec that acts as a wrapper for creating an object called ExampleGroup.
00:06:21.520 Upon executing 'bundle exec rspec', the first action taken by RSpec is instantiating an object called Runner. The Runner will subsequently instantiate an object called World. RSpec uses a pattern to identify your test files, which defaults to 'spec/**/*_spec.rb'. The Runner evaluates this pattern and loads all files matching the criteria, pulling in all ExampleGroups with their associated describes within them. By this point, the World object comprises a compilation of all loaded ExampleGroups, ready for execution. However, RSpec doesn’t just load files—it provides a variety of options to determine how these examples execute.
00:07:38.070 For instance, you can use tags or specific example names in the execution command to filter your tests. The Runner constructs another object called ConfigurationOptions, which handles command-line flags and works with another entity known as the FilterManager. The FilterManager creates filter objects that recognize which ExampleGroups should be executed. If any ExampleGroups don’t meet the criteria you specified in your command line, they will be excluded from execution.
00:09:04.250 Next, the World object holds several ExampleGroups primed for execution. Within an ExampleGroup, there’s the 'it' keyword, which—similar to 'describe'—is an alias for constructing an individual example. An RSpec example directly corresponds to a unit test, representing a single piece of execution we want to validate. RSpec allows for arbitrary nesting of 'it', 'context', and 'describe' blocks, creating a tree structure that clearly defines how tests execute. When executing an ExampleGroup, remember it always processes its examples before diving into any child ExampleGroups, which also correlates with the ordering process regarding randomization.
00:10:30.620 For example, if there’s a scenario where we have several nested example groups, the valid swaps for randomization are between the examples and their respective groups, with examples never being repositioned to precede their parent groups. RSpec provides hooks that allow you to execute code before, after, and around examples and ExampleGroups. These hooks can be categorized: 'before' and 'after' hooks run once for the entire suite, while 'before' and 'after' context hooks run once per ExampleGroup. There's also the ability to run hooks before and after each individual example.
00:12:22.180 When nesting occurs, for instance, if we have an inner example group declaring its own 'before context' hook, the 'before' hooks from the outer example group execute prior to any of the inner example hooks. RSpec controls the overall structure and execution of your test suite, handling the organization and selection of which tests run.
00:12:55.470 Now, let’s talk about expectations, which play a crucial role in RSpec. When you write a line like 'expect(1 + 1).to eq(2)', you’re actually utilizing the RSpec Expectations library. This expectation can be broken into two parts: 'expect' creates an object called an ExpectationTarget, and 'eq' sets up a specific matcher instance. The ExpectationTarget creates a wrapper for the object under examination, which means it organizes and prepares the process of matching.
00:13:49.330 Moving forward, after storing our expected output in an instance variable, the 'to' method is tasked with calling the matcher that handles expectations. RSpec additionally provides negative expectations via 'expect not to', leading to similar but distinct behavior with matchers. The overall function of the ExpectationTarget is to facilitate the matching of user objects against supplied conditions with the matcher being the mechanism by which the matching occurs.
00:15:20.760 Let's delve into what a matcher does. The 'eq' matcher compares the expected value to the actual value by checking equality. The base matcher class, which 'eq' inherits from, harnesses general functionality for all matchers, including a public method called 'matches' that serves as the main API for RSpec to determine object matching.
00:16:35.640 Expectations in RSpec can also be composed together, allowing you to unpack complex data structures and assert values of specific keys within nested hash objects, for instance. This capability proves useful when working with deeply nested API responses, where you only need to assert the value of a particular key, rather than dealing with the entire hash.
00:17:57.570 To understand how complex match conditions work, the operation of the matcher includes a recursive process over the object structures, permitting it to accept and correctly evaluate various Ruby data types. This nuanced design allows RSpec to perform matches on complex and nested data efficiently. When you execute RSpec with composed expectations, it checks each level of the structure, effectively mapping through arrays and hashes to conduct precise matches.
00:19:58.940 Now that we've explored the specifics of expectations, let’s shift our attention to RSpec Mocks. This powerful mocking framework enables stubbing, allowing you to define behaviors for methods. For instance, you can allow a specific method on an object to receive a certain input, and in return, specify what it should produce. Similar to expectations, these concepts rely heavily on an abstracted process that utilizes underlying components, which handle the processing and execution of the specified behavior.
00:21:56.420 Just like the expectation process, stubbing requires a similar call chain setup that ultimately manages to track method calls, ensuring that assertions can be made at the end of tests. For example, when you allow a method to receive a call, RSpec creates a target for managing and verifying that call, storing necessary information about it. This allows you to confirm that a method is invoked with expected arguments, making it instrumental for ensuring class collaboration during tests.
00:22:55.180 In essence, RSpec Mocks enables a streamlined process for controlling how objects behave during testing, and can be described as an intricate system that manages interaction with the mocked objects efficiently. The culmination of this process ensures that at the end of every test, if expectations aren’t fulfilled, an exception is raised accordingly.
00:24:18.470 To wrap things up, what we’ve seen here today is that RSpec is structured in two main components: RSpec Core, which focuses on structuring and executing tests, and RSpec Expectations, which provides the functionality for writing sophisticated and descriptive expressions. Ultimately, RSpec Mocks rounds out the framework, providing the capabilities necessary for effective stubbing and interaction management of objects throughout tests.
00:25:38.920 In summary, RSpec is comprised of three independent libraries that integrate seamlessly to enhance your testing experience in Ruby. I truly appreciate your attention this afternoon. I’m Sam Phippen, available everywhere on the Internet as @SamPhippen. Now, I’ll be happy to take any questions you might have!
00:26:36.740 [Audience interaction begins] Yes? [Audience member asks about shared examples being a code smell] Great question! I'll approach this broadly since I don't have specifics about your situation. If you find using a feature like shared examples painful, it may stem from design issues in your code rather than a flaw in RSpec itself. Shared examples are beneficial when the inheritance structure of your application is sound, but if you’re struggling with them, something might be off with your application's design or abstraction layers.
00:32:45.420 I believe RSpec features serve specific purposes, even if they might not seem great in every scenario. Are there any other questions?
00:32:58.570 [Audience interaction continues with no further questions.] Thank you very much!